clean tests
This commit is contained in:
@@ -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. |
|
| 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. |
|
| 16 | Slope Distribution | slope_dist.c | Angular histogram of surface slopes. Characterizes surface texture directionality. |
|
||||||
| 17 | Grain Filtering | grain_filter.c | Remove particles by size, height, or border contact. Refine grain masks post-detection. |
|
| 17 | Grain Filtering | grain_filter.c | Remove grains 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. |
|
| 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). |
|
| 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. |
|
| 20 | Tip Modeling / Deconvolution | tip_blind.c, tip_model.c | Estimate tip shape from image, deconvolve to recover true surface. |
|
||||||
@@ -94,8 +94,8 @@ For reference, these Gwyddion equivalents are already covered:
|
|||||||
| Threshold Mask | mask | threshold.c, otsu_threshold.c |
|
| Threshold Mask | mask | threshold.c, otsu_threshold.c |
|
||||||
| Mask Morphology | mask | mask_morph.c (erode, dilate, open, close) |
|
| Mask Morphology | mask | mask_morph.c (erode, dilate, open, close) |
|
||||||
| Mask Invert | mask | — |
|
| Mask Invert | mask | — |
|
||||||
| Mask Combine | mask | — (boolean AND, OR, XOR, subtract) |
|
| Mask Operations | mask | — (boolean logic on two masks: AND, OR, XOR, NAND, NOR, XNOR, implication, etc.) |
|
||||||
| Grain Distance Transform | mask | mask_edt.c |
|
| Grain Distance Transform | mask | mask_edt.c |
|
||||||
| Watershed Segmentation | particles | grain_wshed.c |
|
| Watershed Segmentation | grains | grain_wshed.c |
|
||||||
| Particle Analysis | particles | grain_stat.c |
|
| Grain Analysis | grains | grain_stat.c |
|
||||||
| Preview / 3D View / Print Table | display | Presentation, 3D view |
|
| Preview / 3D View / Print Table | display | Presentation, 3D view |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ PREVIEW_MARKUP_REFERENCE_DIM = 512
|
|||||||
|
|
||||||
|
|
||||||
class RecordTable(list):
|
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):
|
class MeasureTable(list):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
import inspect
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
Callback = Callable[[str, Any], None]
|
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)
|
_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
|
@contextmanager
|
||||||
def execution_callbacks(
|
def execution_callbacks(
|
||||||
@@ -50,12 +60,42 @@ def current_node_id() -> str | None:
|
|||||||
return _node_id_var.get()
|
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:
|
def _emit(kind: str, payload: Any) -> None:
|
||||||
callbacks = _callbacks_var.get()
|
callbacks = _callbacks_var.get()
|
||||||
callback = callbacks.get(kind)
|
callback = callbacks.get(kind)
|
||||||
node_id = current_node_id()
|
node_id = current_node_id()
|
||||||
if callback is not None and node_id:
|
if callback is not None and node_id:
|
||||||
callback(node_id, payload)
|
callback(node_id, payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
_legacy_emit(kind, payload)
|
||||||
|
|
||||||
|
|
||||||
def emit_preview(payload: Any) -> None:
|
def emit_preview(payload: Any) -> None:
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
],
|
],
|
||||||
"Output": [
|
"Output": [
|
||||||
"PreviewImage",
|
"PreviewImage",
|
||||||
|
"ValueDisplay",
|
||||||
|
"View3D",
|
||||||
"Save",
|
"Save",
|
||||||
"SaveImage",
|
"SaveImage",
|
||||||
"View3D",
|
|
||||||
"PrintTable",
|
"PrintTable",
|
||||||
"ValueDisplay",
|
|
||||||
],
|
],
|
||||||
"Overlay": [
|
"Overlay": [
|
||||||
"Markup",
|
"Markup",
|
||||||
@@ -37,10 +37,10 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"AngleMeasure",
|
"AngleMeasure",
|
||||||
],
|
],
|
||||||
"Modify": [
|
"Modify": [
|
||||||
"ColormapAdjust",
|
|
||||||
"CropResizeField",
|
"CropResizeField",
|
||||||
"RotateField",
|
"RotateField",
|
||||||
"FlipField",
|
"FlipField",
|
||||||
|
"ColormapAdjust",
|
||||||
],
|
],
|
||||||
"Filter": [
|
"Filter": [
|
||||||
"GaussianFilter",
|
"GaussianFilter",
|
||||||
@@ -51,24 +51,24 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"ScarRemoval",
|
"ScarRemoval",
|
||||||
],
|
],
|
||||||
"Frequency": [
|
"Frequency": [
|
||||||
"FFT2D",
|
|
||||||
"PSDF",
|
"PSDF",
|
||||||
|
"FFT2D",
|
||||||
"InverseFFT2D",
|
"InverseFFT2D",
|
||||||
],
|
],
|
||||||
"Flatten": [
|
"Flatten": [
|
||||||
"PlaneLevelField",
|
|
||||||
"FacetLevelField",
|
|
||||||
"PolyLevelField",
|
|
||||||
"FixZero",
|
"FixZero",
|
||||||
"LineCorrection",
|
"LineCorrection",
|
||||||
|
"PlaneLevelField",
|
||||||
|
"PolyLevelField",
|
||||||
|
"FacetLevelField",
|
||||||
],
|
],
|
||||||
"Measure": [
|
"Measure": [
|
||||||
"CrossSection",
|
"CrossSection",
|
||||||
"Curvature",
|
|
||||||
"Histogram",
|
"Histogram",
|
||||||
|
"Cursors",
|
||||||
|
"Curvature",
|
||||||
"FractalDimension",
|
"FractalDimension",
|
||||||
"ACF",
|
"ACF",
|
||||||
"Cursors",
|
|
||||||
"Statistics",
|
"Statistics",
|
||||||
"Stats",
|
"Stats",
|
||||||
],
|
],
|
||||||
@@ -77,12 +77,12 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"ThresholdMask",
|
"ThresholdMask",
|
||||||
"MaskMorphology",
|
"MaskMorphology",
|
||||||
"MaskInvert",
|
"MaskInvert",
|
||||||
"MaskCombine",
|
"MaskOperations",
|
||||||
"GrainDistanceTransform",
|
|
||||||
],
|
],
|
||||||
"Particles": [
|
"Grains": [
|
||||||
|
"GrainAnalysis",
|
||||||
|
"GrainDistanceTransform",
|
||||||
"WatershedSegmentation",
|
"WatershedSegmentation",
|
||||||
"ParticleAnalysis",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from backend.nodes import (
|
|||||||
threshold_mask,
|
threshold_mask,
|
||||||
mask_morphology,
|
mask_morphology,
|
||||||
mask_invert,
|
mask_invert,
|
||||||
mask_combine,
|
mask_operations,
|
||||||
grain_distance_transform,
|
grain_distance_transform,
|
||||||
# Correction
|
# Correction
|
||||||
scar_removal,
|
scar_removal,
|
||||||
@@ -59,9 +59,5 @@ from backend.nodes import (
|
|||||||
cross_section,
|
cross_section,
|
||||||
stats,
|
stats,
|
||||||
watershed_segmentation,
|
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
|
from backend.nodes.helpers import _square_unit
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Particle Analysis")
|
@register_node(display_name="Grain Analysis")
|
||||||
class ParticleAnalysis:
|
class GrainAnalysis:
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
@@ -18,44 +18,44 @@ class ParticleAnalysis:
|
|||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("RECORD_TABLE",)
|
RETURN_TYPES = ("RECORD_TABLE",)
|
||||||
RETURN_NAMES = ("particle_stats",)
|
RETURN_NAMES = ("grain_stats",)
|
||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
DESCRIPTION = (
|
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. "
|
"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:
|
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||||
from scipy.ndimage import label
|
from scipy.ndimage import label
|
||||||
|
|
||||||
binary = (mask > 127).astype(np.int32)
|
binary = (mask > 127).astype(np.int32)
|
||||||
labeled, n_particles = label(binary)
|
labeled, n_grains = label(binary)
|
||||||
|
|
||||||
pixel_area = field.dx * field.dy
|
pixel_area = field.dx * field.dy
|
||||||
xy_unit = str(field.si_unit_xy or "").strip()
|
xy_unit = str(field.si_unit_xy or "").strip()
|
||||||
z_unit = str(field.si_unit_z or "").strip()
|
z_unit = str(field.si_unit_z or "").strip()
|
||||||
|
|
||||||
rows = RecordTable()
|
rows = RecordTable()
|
||||||
for pid in range(1, n_particles + 1):
|
for gid in range(1, n_grains + 1):
|
||||||
particle_pixels = labeled == pid
|
grain_pixels = labeled == gid
|
||||||
area_px = int(particle_pixels.sum())
|
area_px = int(grain_pixels.sum())
|
||||||
if area_px < min_size:
|
if area_px < min_size:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
area_m2 = area_px * pixel_area
|
area_m2 = area_px * pixel_area
|
||||||
equiv_diam = float(2.0 * np.sqrt(area_m2 / np.pi))
|
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())
|
mean_h = float(heights.mean())
|
||||||
max_h = float(heights.max())
|
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())})"
|
bbox = f"({int(xs.min())},{int(ys.min())})-({int(xs.max())},{int(ys.max())})"
|
||||||
|
|
||||||
rows.append({
|
rows.append({
|
||||||
"particle_id": pid,
|
"grain_id": gid,
|
||||||
"area_px": area_px,
|
"area_px": area_px,
|
||||||
"area_px_unit": _square_unit("px"),
|
"area_px_unit": _square_unit("px"),
|
||||||
"area_m2": area_m2,
|
"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
|
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")
|
@register_node(display_name="Plane Level")
|
||||||
class PlaneLevelField:
|
class PlaneLevelField:
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -11,7 +54,11 @@ class PlaneLevelField:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"field": ("DATA_FIELD",),
|
"field": ("DATA_FIELD",),
|
||||||
}
|
"masking": (["ignore", "include", "exclude"], {"default": "ignore"}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"mask": ("IMAGE",),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("DATA_FIELD",)
|
RETURN_TYPES = ("DATA_FIELD",)
|
||||||
@@ -19,27 +66,19 @@ class PlaneLevelField:
|
|||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Fit and subtract a least-squares plane from the data. "
|
"Fit and subtract a least-squares plane from the data. Supports include/exclude mask fitting "
|
||||||
"Equivalent to gwy_data_field_fit_plane + gwy_data_field_plane_level."
|
"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()
|
data = field.data.copy()
|
||||||
yres, xres = data.shape
|
mask_array = _normalize_mask(mask, data.shape)
|
||||||
|
pa, pbx, pby, xx, yy = _fit_plane(data, mask_array, masking)
|
||||||
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
|
|
||||||
|
|
||||||
plane = (pa + pbx * xx + pby * yy)
|
plane = (pa + pbx * xx + pby * yy)
|
||||||
return (field.replace(data=data - plane),)
|
return (field.replace(data=data - plane),)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const CAT_COLORS = {
|
|||||||
modify: '#0f766e',
|
modify: '#0f766e',
|
||||||
level: '#1b5e20',
|
level: '#1b5e20',
|
||||||
analysis: '#4a148c',
|
analysis: '#4a148c',
|
||||||
particles: '#bf360c',
|
Grains: '#bf360c',
|
||||||
display: '#212121',
|
display: '#212121',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
test('getTableColumns hides companion record-table unit columns', () => {
|
test('getTableColumns hides companion record-table unit columns', () => {
|
||||||
const columns = getTableColumns([
|
const columns = getTableColumns([
|
||||||
{
|
{
|
||||||
particle_id: 1,
|
grain_id: 1,
|
||||||
area_m2: 2.5e-12,
|
area_m2: 2.5e-12,
|
||||||
area_m2_unit: 'm^2',
|
area_m2_unit: 'm^2',
|
||||||
mean_height: 1.5e-9,
|
mean_height: 1.5e-9,
|
||||||
@@ -20,7 +20,7 @@ test('getTableColumns hides companion record-table unit columns', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(columns, ['particle_id', 'area_m2', 'mean_height', 'bbox']);
|
assert.deepEqual(columns, ['grain_id', 'area_m2', 'mean_height', 'bbox']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatTableRowCell appends companion units inline for record-table values', () => {
|
test('formatTableRowCell appends companion units inline for record-table values', () => {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
norecursedirs = .git .venv .pytest_cache frontend/node_modules frontend/dist pytest-cache-files-*
|
testpaths = tests
|
||||||
|
norecursedirs = .git .venv .pytest_cache frontend/node_modules frontend/dist pytest-cache-files-* desktop-dist desktop-build
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Thorough tests for the particles/particle analysis pipeline:
|
Thorough tests for the grain-analysis pipeline:
|
||||||
ThresholdMask → GrainAnalysis
|
ThresholdMask -> GrainAnalysis
|
||||||
|
|
||||||
Covers synthetic geometry (known answers), the demo nanoparticles image,
|
Covers synthetic geometry (known answers), the demo nanoparticles image,
|
||||||
edge cases, and physical-unit correctness.
|
edge cases, and physical-unit correctness.
|
||||||
|
|
||||||
Run from project root:
|
Run from project root:
|
||||||
.venv/bin/python -m tests.test_particles
|
.venv/bin/python -m tests.test_grains
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -100,7 +100,7 @@ def test_threshold_full_mask():
|
|||||||
def test_single_circle_area():
|
def test_single_circle_area():
|
||||||
"""A single filled circle — verify pixel count and physical area."""
|
"""A single filled circle — verify pixel count and physical area."""
|
||||||
print("=== Test: Single circle area ===")
|
print("=== Test: Single circle area ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
N = 200
|
N = 200
|
||||||
@@ -118,35 +118,35 @@ def test_single_circle_area():
|
|||||||
field = make_field(data, xreal=XREAL, yreal=XREAL)
|
field = make_field(data, xreal=XREAL, yreal=XREAL)
|
||||||
table, = node.process(field, mask=mask, min_size=1)
|
table, = node.process(field, mask=mask, min_size=1)
|
||||||
|
|
||||||
assert len(table) == 1, f"Expected 1 particles, got {len(table)}"
|
assert len(table) == 1, f"Expected 1 grain, got {len(table)}"
|
||||||
particles = table[0]
|
grain = table[0]
|
||||||
|
|
||||||
# Pixel area of a discrete circle: should be close to π r²
|
# Pixel area of a discrete circle: should be close to π r²
|
||||||
expected_px = np.pi * r ** 2
|
expected_px = np.pi * r ** 2
|
||||||
assert abs(particles["area_px"] - expected_px) / expected_px < 0.02, \
|
assert abs(grain["area_px"] - expected_px) / expected_px < 0.02, \
|
||||||
f"area_px={particles['area_px']}, expected≈{expected_px:.0f}"
|
f"area_px={grain['area_px']}, expected≈{expected_px:.0f}"
|
||||||
|
|
||||||
# Physical area
|
# Physical area
|
||||||
pixel_area = (XREAL / N) ** 2
|
pixel_area = (XREAL / N) ** 2
|
||||||
expected_m2 = particles["area_px"] * pixel_area
|
expected_m2 = grain["area_px"] * pixel_area
|
||||||
assert abs(particles["area_m2"] - expected_m2) < 1e-20, \
|
assert abs(grain["area_m2"] - expected_m2) < 1e-20, \
|
||||||
f"area_m2 mismatch: {particles['area_m2']} vs {expected_m2}"
|
f"area_m2 mismatch: {grain['area_m2']} vs {expected_m2}"
|
||||||
|
|
||||||
# Equivalent diameter should be close to 2r in physical units
|
# Equivalent diameter should be close to 2r in physical units
|
||||||
expected_diam = 2 * r * (XREAL / N)
|
expected_diam = 2 * r * (XREAL / N)
|
||||||
assert abs(particles["equiv_diam_m"] - expected_diam) / expected_diam < 0.02, \
|
assert abs(grain["equiv_diam_m"] - expected_diam) / expected_diam < 0.02, \
|
||||||
f"equiv_diam={particles['equiv_diam_m']:.3e}, expected≈{expected_diam:.3e}"
|
f"equiv_diam={grain['equiv_diam_m']:.3e}, expected≈{expected_diam:.3e}"
|
||||||
|
|
||||||
# Heights
|
# Heights
|
||||||
assert abs(particles["mean_height"] - 5.0) < 1e-10
|
assert abs(grain["mean_height"] - 5.0) < 1e-10
|
||||||
assert abs(particles["max_height"] - 5.0) < 1e-10
|
assert abs(grain["max_height"] - 5.0) < 1e-10
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_particles_separation():
|
def test_multiple_grains_separation():
|
||||||
"""Three well-separated particles of different sizes — check each is reported."""
|
"""Three well-separated grains of different sizes — check each is reported."""
|
||||||
print("=== Test: Multiple particles separation ===")
|
print("=== Test: Multiple grains separation ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
N = 128
|
N = 128
|
||||||
@@ -168,7 +168,7 @@ def test_multiple_particles_separation():
|
|||||||
field = make_field(data)
|
field = make_field(data)
|
||||||
table, = node.process(field, mask=mask, min_size=1)
|
table, = node.process(field, mask=mask, min_size=1)
|
||||||
|
|
||||||
assert len(table) == 3, f"Expected 3 particles, got {len(table)}"
|
assert len(table) == 3, f"Expected 3 grains, got {len(table)}"
|
||||||
|
|
||||||
table.sort(key=lambda r: r["area_px"], reverse=True)
|
table.sort(key=lambda r: r["area_px"], reverse=True)
|
||||||
assert table[0]["area_px"] == 400 # 20×20
|
assert table[0]["area_px"] == 400 # 20×20
|
||||||
@@ -182,24 +182,24 @@ def test_multiple_particles_separation():
|
|||||||
|
|
||||||
|
|
||||||
def test_min_size_filtering():
|
def test_min_size_filtering():
|
||||||
"""min_size should exclude particles smaller than the threshold."""
|
"""min_size should exclude grains smaller than the threshold."""
|
||||||
print("=== Test: min_size filtering ===")
|
print("=== Test: min_size filtering ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
N = 64
|
N = 64
|
||||||
data = np.zeros((N, N))
|
data = np.zeros((N, N))
|
||||||
mask = np.zeros((N, N), dtype=np.uint8)
|
mask = np.zeros((N, N), dtype=np.uint8)
|
||||||
|
|
||||||
# Large particles: 15×15 = 225 px
|
# Large grains: 15x15 = 225 px
|
||||||
data[5:20, 5:20] = 1.0
|
data[5:20, 5:20] = 1.0
|
||||||
mask[5:20, 5:20] = 255
|
mask[5:20, 5:20] = 255
|
||||||
|
|
||||||
# Medium particles: 8×8 = 64 px
|
# Medium grains: 8x8 = 64 px
|
||||||
data[30:38, 30:38] = 1.0
|
data[30:38, 30:38] = 1.0
|
||||||
mask[30:38, 30:38] = 255
|
mask[30:38, 30:38] = 255
|
||||||
|
|
||||||
# Tiny particles: 3×3 = 9 px
|
# Tiny grains: 3x3 = 9 px
|
||||||
data[50:53, 50:53] = 1.0
|
data[50:53, 50:53] = 1.0
|
||||||
mask[50:53, 50:53] = 255
|
mask[50:53, 50:53] = 255
|
||||||
|
|
||||||
@@ -224,16 +224,16 @@ def test_min_size_filtering():
|
|||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_particles_bounding_box():
|
def test_grains_bounding_box():
|
||||||
"""Bounding box should match the particles extents."""
|
"""Bounding box should match the grain extents."""
|
||||||
print("=== Test: Grain bounding box ===")
|
print("=== Test: Grain bounding box ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
N = 64
|
N = 64
|
||||||
data = np.zeros((N, N))
|
data = np.zeros((N, N))
|
||||||
mask = np.zeros((N, N), dtype=np.uint8)
|
mask = np.zeros((N, N), dtype=np.uint8)
|
||||||
# Place a particles at rows 20:35, cols 10:45
|
# Place a grain at rows 20:35, cols 10:45
|
||||||
data[20:35, 10:45] = 2.0
|
data[20:35, 10:45] = 2.0
|
||||||
mask[20:35, 10:45] = 255
|
mask[20:35, 10:45] = 255
|
||||||
|
|
||||||
@@ -247,10 +247,10 @@ def test_particles_bounding_box():
|
|||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_empty_mask_produces_no_particles():
|
def test_empty_mask_produces_no_grains():
|
||||||
"""An all-zero mask should yield zero particles."""
|
"""An all-zero mask should yield zero grains."""
|
||||||
print("=== Test: Empty mask → no particles ===")
|
print("=== Test: Empty mask -> no grains ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
field = make_field(np.ones((64, 64)))
|
field = make_field(np.ones((64, 64)))
|
||||||
@@ -261,10 +261,10 @@ def test_empty_mask_produces_no_particles():
|
|||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_particles_at_image_edge():
|
def test_grains_at_image_edge():
|
||||||
"""A particles touching the image border should still be detected."""
|
"""A grain touching the image border should still be detected."""
|
||||||
print("=== Test: Grain at image edge ===")
|
print("=== Test: Grain at image edge ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
N = 64
|
N = 64
|
||||||
@@ -282,11 +282,11 @@ def test_particles_at_image_edge():
|
|||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_adjacent_particles_connectivity():
|
def test_adjacent_grains_connectivity():
|
||||||
"""Two diagonally-touching blocks should be separate particles
|
"""Two diagonally-touching blocks should be separate grains
|
||||||
(scipy.ndimage.label uses 4-connectivity by default)."""
|
(scipy.ndimage.label uses 4-connectivity by default)."""
|
||||||
print("=== Test: Diagonal adjacency → separate particles ===")
|
print("=== Test: Diagonal adjacency -> separate grains ===")
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = GrainAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
N = 32
|
N = 32
|
||||||
@@ -305,7 +305,7 @@ def test_adjacent_particles_connectivity():
|
|||||||
table, = node.process(field, mask=mask, min_size=1)
|
table, = node.process(field, mask=mask, min_size=1)
|
||||||
# Default label() uses structure that connects diagonals? Let's verify.
|
# Default label() uses structure that connects diagonals? Let's verify.
|
||||||
# scipy.ndimage.label default is cross-shaped (no diagonals) for 2D
|
# scipy.ndimage.label default is cross-shaped (no diagonals) for 2D
|
||||||
assert len(table) == 2, f"Expected 2 separate particles, got {len(table)}"
|
assert len(table) == 2, f"Expected 2 separate grains, got {len(table)}"
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -315,17 +315,17 @@ def test_adjacent_particles_connectivity():
|
|||||||
|
|
||||||
def test_pipeline_synthetic():
|
def test_pipeline_synthetic():
|
||||||
"""Full pipeline on a synthetic image with known geometry."""
|
"""Full pipeline on a synthetic image with known geometry."""
|
||||||
print("=== Test: Full pipeline on synthetic particles ===")
|
print("=== Test: Full pipeline on synthetic grains ===")
|
||||||
from backend.nodes.threshold_mask import ThresholdMask
|
from backend.nodes.threshold_mask import ThresholdMask
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
|
|
||||||
N = 200
|
N = 200
|
||||||
XREAL = 10e-6 # 10 µm
|
XREAL = 10e-6 # 10 µm
|
||||||
rng = np.random.default_rng(99)
|
rng = np.random.default_rng(99)
|
||||||
|
|
||||||
# Background at 0 with small noise, particles as raised bumps
|
# Background at 0 with small noise, grains as raised bumps
|
||||||
bg = rng.normal(0, 0.1, (N, N))
|
bg = rng.normal(0, 0.1, (N, N))
|
||||||
particles = np.zeros((N, N))
|
grains = np.zeros((N, N))
|
||||||
|
|
||||||
yy, xx = np.mgrid[0:N, 0:N]
|
yy, xx = np.mgrid[0:N, 0:N]
|
||||||
|
|
||||||
@@ -338,32 +338,32 @@ def test_pipeline_synthetic():
|
|||||||
]
|
]
|
||||||
for cx, cy, r, h in specs:
|
for cx, cy, r, h in specs:
|
||||||
inside = ((xx - cx) ** 2 + (yy - cy) ** 2) <= r ** 2
|
inside = ((xx - cx) ** 2 + (yy - cy) ** 2) <= r ** 2
|
||||||
particles[inside] = h
|
grains[inside] = h
|
||||||
|
|
||||||
data = bg + particles
|
data = bg + grains
|
||||||
field = make_field(data, xreal=XREAL, yreal=XREAL)
|
field = make_field(data, xreal=XREAL, yreal=XREAL)
|
||||||
|
|
||||||
# Step 1: threshold
|
# Step 1: threshold
|
||||||
thresh = ThresholdMask()
|
thresh = ThresholdMask()
|
||||||
mask, = thresh.process(field, method="absolute", threshold=1.0, direction="above")
|
mask, = thresh.process(field, method="absolute", threshold=1.0, direction="above")
|
||||||
|
|
||||||
# Particles are well above noise, so mask should capture all 5
|
# Grains are well above noise, so mask should capture all 5
|
||||||
assert mask.max() == 255, "No particles detected"
|
assert mask.max() == 255, "No grains detected"
|
||||||
|
|
||||||
# Step 2: particles analysis
|
# Step 2: grain analysis
|
||||||
ga = GrainAnalysis()
|
ga = GrainAnalysis()
|
||||||
table, = ga.process(field, mask=mask, min_size=5)
|
table, = ga.process(field, mask=mask, min_size=5)
|
||||||
|
|
||||||
assert len(table) == 5, f"Expected 5 particles, got {len(table)}"
|
assert len(table) == 5, f"Expected 5 grains, got {len(table)}"
|
||||||
|
|
||||||
# Verify that detected areas are in the right ballpark
|
# Verify that detected areas are in the right ballpark
|
||||||
table.sort(key=lambda r: r["area_px"], reverse=True)
|
table.sort(key=lambda r: r["area_px"], reverse=True)
|
||||||
expected_areas = sorted([np.pi * r ** 2 for _, _, r, _ in specs], reverse=True)
|
expected_areas = sorted([np.pi * r ** 2 for _, _, r, _ in specs], reverse=True)
|
||||||
|
|
||||||
for particles, expected_px in zip(table, expected_areas):
|
for grain, expected_px in zip(table, expected_areas):
|
||||||
ratio = particles["area_px"] / expected_px
|
ratio = grain["area_px"] / expected_px
|
||||||
assert 0.85 < ratio < 1.15, \
|
assert 0.85 < ratio < 1.15, \
|
||||||
f"particles area_px={particles['area_px']}, expected≈{expected_px:.0f}, ratio={ratio:.2f}"
|
f"grain area_px={grain['area_px']}, expected≈{expected_px:.0f}, ratio={ratio:.2f}"
|
||||||
|
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
@@ -373,7 +373,7 @@ def test_pipeline_demo_image():
|
|||||||
print("=== Test: Full pipeline on demo nanoparticles.npy ===")
|
print("=== Test: Full pipeline on demo nanoparticles.npy ===")
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from backend.nodes.threshold_mask import ThresholdMask
|
from backend.nodes.threshold_mask import ThresholdMask
|
||||||
from backend.nodes.particle_analysis import GrainAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
from backend.runtime_paths import demo_dir
|
from backend.runtime_paths import demo_dir
|
||||||
|
|
||||||
npy_path = demo_dir() / "nanoparticles.npy"
|
npy_path = demo_dir() / "nanoparticles.npy"
|
||||||
@@ -385,31 +385,31 @@ def test_pipeline_demo_image():
|
|||||||
# The demo image is a 5 µm × 5 µm scan
|
# The demo image is a 5 µm × 5 µm scan
|
||||||
field = make_field(data, xreal=5e-6, yreal=5e-6)
|
field = make_field(data, xreal=5e-6, yreal=5e-6)
|
||||||
|
|
||||||
# Threshold to find particles (they are raised above background)
|
# Threshold to find grains (they are raised above background)
|
||||||
thresh = ThresholdMask()
|
thresh = ThresholdMask()
|
||||||
mask, = thresh.process(field, method="otsu", threshold=0.0, direction="above")
|
mask, = thresh.process(field, method="otsu", threshold=0.0, direction="above")
|
||||||
|
|
||||||
# Should detect particles
|
# Should detect grains
|
||||||
assert mask.max() == 255, "No particles found in demo image"
|
assert mask.max() == 255, "No grains found in demo image"
|
||||||
particle_fraction = (mask == 255).sum() / mask.size
|
particle_fraction = (mask == 255).sum() / mask.size
|
||||||
assert 0.01 < particle_fraction < 0.5, \
|
assert 0.01 < particle_fraction < 0.5, \
|
||||||
f"Suspicious particle fraction: {particle_fraction:.3f}"
|
f"Suspicious particle fraction: {particle_fraction:.3f}"
|
||||||
print(f" Mask: {particle_fraction*100:.1f}% of pixels are particles")
|
print(f" Mask: {particle_fraction*100:.1f}% of pixels are grains")
|
||||||
|
|
||||||
# Grain analysis
|
# Grain analysis
|
||||||
ga = GrainAnalysis()
|
ga = GrainAnalysis()
|
||||||
table, = ga.process(field, mask=mask, min_size=20)
|
table, = ga.process(field, mask=mask, min_size=20)
|
||||||
|
|
||||||
assert len(table) > 0, "No particles detected"
|
assert len(table) > 0, "No grains detected"
|
||||||
print(f" Found {len(table)} particles (min_size=20)")
|
print(f" Found {len(table)} grains (min_size=20)")
|
||||||
|
|
||||||
# Sanity checks on particles properties
|
# Sanity checks on grain properties
|
||||||
for particles in table:
|
for grain in table:
|
||||||
assert particles["area_px"] >= 20
|
assert grain["area_px"] >= 20
|
||||||
assert particles["area_m2"] > 0
|
assert grain["area_m2"] > 0
|
||||||
assert particles["equiv_diam_m"] > 0
|
assert grain["equiv_diam_m"] > 0
|
||||||
assert particles["max_height"] >= particles["mean_height"]
|
assert grain["max_height"] >= grain["mean_height"]
|
||||||
assert particles["mean_height"] > 0
|
assert grain["mean_height"] > 0
|
||||||
|
|
||||||
# Physical size sanity: equivalent diameters should be in the nm–µm range
|
# Physical size sanity: equivalent diameters should be in the nm–µm range
|
||||||
diams_nm = [g["equiv_diam_m"] * 1e9 for g in table]
|
diams_nm = [g["equiv_diam_m"] * 1e9 for g in table]
|
||||||
@@ -433,15 +433,15 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# GrainAnalysis
|
# GrainAnalysis
|
||||||
test_single_circle_area()
|
test_single_circle_area()
|
||||||
test_multiple_particles_separation()
|
test_multiple_grains_separation()
|
||||||
test_min_size_filtering()
|
test_min_size_filtering()
|
||||||
test_particles_bounding_box()
|
test_grains_bounding_box()
|
||||||
test_empty_mask_produces_no_particles()
|
test_empty_mask_produces_no_grains()
|
||||||
test_particles_at_image_edge()
|
test_grains_at_image_edge()
|
||||||
test_adjacent_particles_connectivity()
|
test_adjacent_grains_connectivity()
|
||||||
|
|
||||||
# End-to-end pipeline
|
# End-to-end pipeline
|
||||||
test_pipeline_synthetic()
|
test_pipeline_synthetic()
|
||||||
test_pipeline_demo_image()
|
test_pipeline_demo_image()
|
||||||
|
|
||||||
print("All particles tests passed!")
|
print("All grain tests passed!")
|
||||||
|
|||||||
@@ -523,6 +523,31 @@ def test_plane_level():
|
|||||||
# The signal should remain (correlation with original sine)
|
# The signal should remain (correlation with original sine)
|
||||||
corr = np.corrcoef(result.data.ravel(), signal.ravel())[0, 1]
|
corr = np.corrcoef(result.data.ravel(), signal.ravel())[0, 1]
|
||||||
assert corr > 0.98, f"Signal correlation after leveling: {corr}"
|
assert corr > 0.98, f"Signal correlation after leveling: {corr}"
|
||||||
|
|
||||||
|
yy_px, xx_px = np.mgrid[0:N, 0:N]
|
||||||
|
|
||||||
|
def fit_pixel_plane(data_in: np.ndarray, region: np.ndarray) -> tuple[float, float, float]:
|
||||||
|
A = np.column_stack([
|
||||||
|
np.ones(int(np.count_nonzero(region)), dtype=np.float64),
|
||||||
|
xx_px[region].astype(np.float64),
|
||||||
|
yy_px[region].astype(np.float64),
|
||||||
|
])
|
||||||
|
coeffs, _, _, _ = np.linalg.lstsq(A, data_in[region].ravel().astype(np.float64), rcond=None)
|
||||||
|
return float(coeffs[0]), float(coeffs[1]), float(coeffs[2])
|
||||||
|
|
||||||
|
mask = np.zeros((N, N), dtype=np.uint8)
|
||||||
|
mask[20:44, 22:46] = 255
|
||||||
|
feature = np.zeros((N, N), dtype=np.float64)
|
||||||
|
feature[mask > 0] = 35.0
|
||||||
|
masked_field = make_field(data=100 * x + 50 * y + feature)
|
||||||
|
|
||||||
|
unmasked, = node.process(masked_field)
|
||||||
|
masked, = node.process(masked_field, masking="exclude", mask=mask)
|
||||||
|
|
||||||
|
outside = mask == 0
|
||||||
|
_, unmasked_bx, unmasked_by = fit_pixel_plane(unmasked.data, outside)
|
||||||
|
_, masked_bx, masked_by = fit_pixel_plane(masked.data, outside)
|
||||||
|
assert np.hypot(masked_bx, masked_by) < np.hypot(unmasked_bx, unmasked_by) * 1e-3
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -1261,10 +1286,10 @@ def test_mask_invert():
|
|||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_mask_combine():
|
def test_mask_operations():
|
||||||
print("=== Test: MaskCombine ===")
|
print("=== Test: MaskOperations ===")
|
||||||
from backend.nodes.mask_combine import MaskCombine
|
from backend.nodes.mask_operations import MaskOperations
|
||||||
node = MaskCombine()
|
node = MaskOperations()
|
||||||
|
|
||||||
# Two overlapping squares
|
# Two overlapping squares
|
||||||
a = np.zeros((64, 64), dtype=np.uint8)
|
a = np.zeros((64, 64), dtype=np.uint8)
|
||||||
@@ -1291,12 +1316,26 @@ def test_mask_combine():
|
|||||||
assert result_xor[35, 35] == 255 # b-only
|
assert result_xor[35, 35] == 255 # b-only
|
||||||
assert result_xor[25, 25] == 0 # overlap excluded
|
assert result_xor[25, 25] == 0 # overlap excluded
|
||||||
|
|
||||||
# Subtract — a minus b
|
# A minus B
|
||||||
result_sub, = node.process(a, b, operation="subtract")
|
result_sub, = node.process(a, b, operation="a_minus_b")
|
||||||
assert result_sub[15, 15] == 255 # a-only kept
|
assert result_sub[15, 15] == 255 # a-only kept
|
||||||
assert result_sub[25, 25] == 0 # overlap removed
|
assert result_sub[25, 25] == 0 # overlap removed
|
||||||
assert result_sub[35, 35] == 0 # b-only not included
|
assert result_sub[35, 35] == 0 # b-only not included
|
||||||
|
|
||||||
|
# NAND — everything except overlap
|
||||||
|
result_nand, = node.process(a, b, operation="nand")
|
||||||
|
assert result_nand[15, 15] == 255
|
||||||
|
assert result_nand[35, 35] == 255
|
||||||
|
assert result_nand[25, 25] == 0
|
||||||
|
assert result_nand[5, 5] == 255
|
||||||
|
|
||||||
|
# XNOR — overlap plus shared background
|
||||||
|
result_xnor, = node.process(a, b, operation="xnor")
|
||||||
|
assert result_xnor[25, 25] == 255
|
||||||
|
assert result_xnor[5, 5] == 255
|
||||||
|
assert result_xnor[15, 15] == 0
|
||||||
|
assert result_xnor[35, 35] == 0
|
||||||
|
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -1347,17 +1386,17 @@ def test_draw_mask():
|
|||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
def test_particle_analysis():
|
def test_grain_analysis():
|
||||||
print("=== Test: ParticleAnalysis ===")
|
print("=== Test: GrainAnalysis ===")
|
||||||
from backend.nodes.particle_analysis import ParticleAnalysis
|
from backend.nodes.grain_analysis import GrainAnalysis
|
||||||
node = ParticleAnalysis()
|
node = GrainAnalysis()
|
||||||
|
|
||||||
# Create a field with two distinct particles
|
# Create a field with two distinct grains
|
||||||
N = 64
|
N = 64
|
||||||
data = np.zeros((N, N))
|
data = np.zeros((N, N))
|
||||||
# Particle 1: 10x10 block at top-left with height 5
|
# Grain 1: 10x10 block at top-left with height 5
|
||||||
data[5:15, 5:15] = 5.0
|
data[5:15, 5:15] = 5.0
|
||||||
# Particle 2: 8x8 block at bottom-right with height 3
|
# Grain 2: 8x8 block at bottom-right with height 3
|
||||||
data[45:53, 45:53] = 3.0
|
data[45:53, 45:53] = 3.0
|
||||||
field = make_field(data=data, xreal=1e-6, yreal=1e-6)
|
field = make_field(data=data, xreal=1e-6, yreal=1e-6)
|
||||||
|
|
||||||
@@ -1367,7 +1406,7 @@ def test_particle_analysis():
|
|||||||
mask[45:53, 45:53] = 255
|
mask[45:53, 45:53] = 255
|
||||||
|
|
||||||
table, = node.process(field, mask=mask, min_size=10)
|
table, = node.process(field, mask=mask, min_size=10)
|
||||||
assert len(table) == 2, f"Expected 2 particles, got {len(table)}"
|
assert len(table) == 2, f"Expected 2 grains, got {len(table)}"
|
||||||
|
|
||||||
# Sort by area descending
|
# Sort by area descending
|
||||||
table.sort(key=lambda r: r["area_px"], reverse=True)
|
table.sort(key=lambda r: r["area_px"], reverse=True)
|
||||||
@@ -1381,7 +1420,7 @@ def test_particle_analysis():
|
|||||||
assert table[0]["mean_height_unit"] == "m"
|
assert table[0]["mean_height_unit"] == "m"
|
||||||
assert table[0]["max_height_unit"] == "m"
|
assert table[0]["max_height_unit"] == "m"
|
||||||
|
|
||||||
# min_size filtering: only keep particles >= 80 px
|
# min_size filtering: only keep grains >= 80 px
|
||||||
table_filtered, = node.process(field, mask=mask, min_size=80)
|
table_filtered, = node.process(field, mask=mask, min_size=80)
|
||||||
assert len(table_filtered) == 1
|
assert len(table_filtered) == 1
|
||||||
assert table_filtered[0]["area_px"] == 100
|
assert table_filtered[0]["area_px"] == 100
|
||||||
@@ -3140,11 +3179,11 @@ if __name__ == "__main__":
|
|||||||
test_threshold_mask()
|
test_threshold_mask()
|
||||||
test_mask_morphology()
|
test_mask_morphology()
|
||||||
test_mask_invert()
|
test_mask_invert()
|
||||||
test_mask_combine()
|
test_mask_operations()
|
||||||
test_draw_mask()
|
test_draw_mask()
|
||||||
|
|
||||||
# Grains
|
# Grains
|
||||||
test_particle_analysis()
|
test_grain_analysis()
|
||||||
|
|
||||||
# I/O
|
# I/O
|
||||||
test_load_file()
|
test_load_file()
|
||||||
|
|||||||
Reference in New Issue
Block a user