add snapshot tool, masks, and build for mac

This commit is contained in:
2026-03-23 21:52:17 -07:00
parent 080eefbef6
commit a34b1c980d
29 changed files with 2016 additions and 170 deletions

445
tests/test_grains.py Normal file
View File

@@ -0,0 +1,445 @@
"""
Thorough tests for the grain/particle analysis pipeline:
ThresholdMask → GrainAnalysis
Covers synthetic geometry (known answers), the demo nanoparticles image,
edge cases, and physical-unit correctness.
Run from project root:
.venv/bin/python -m tests.test_grains
"""
import sys
import numpy as np
sys.path.insert(0, ".")
from backend.data_types import DataField
def make_field(data, xreal=1e-6, yreal=1e-6):
return DataField(data=data.astype(np.float64), xreal=xreal, yreal=yreal,
si_unit_xy="m", si_unit_z="m")
# =========================================================================
# ThresholdMask tests
# =========================================================================
def test_threshold_otsu_bimodal():
"""Otsu on a clean bimodal image should separate the two populations."""
print("=== Test: Otsu on bimodal image ===")
from backend.nodes.grains import ThresholdMask
node = ThresholdMask()
data = np.zeros((128, 128))
data[30:50, 30:50] = 10.0 # bright square
data[70:100, 80:110] = 10.0 # another bright region
field = make_field(data)
mask, = node.process(field, method="otsu", threshold=0.0, direction="above")
bright_pixels = (mask == 255)
# Should capture both bright regions
assert bright_pixels[40, 40], "Otsu missed bright region 1"
assert bright_pixels[85, 95], "Otsu missed bright region 2"
# Background should be dark
assert not bright_pixels[0, 0], "Otsu false positive in background"
assert not bright_pixels[60, 60], "Otsu false positive between regions"
print(" PASS\n")
def test_threshold_relative_range():
"""Relative threshold at 0.5 should be the midpoint of [min, max]."""
print("=== Test: Relative threshold at midpoint ===")
from backend.nodes.grains import ThresholdMask
node = ThresholdMask()
data = np.full((64, 64), 2.0)
data[10:20, 10:20] = 8.0 # bright patch, range = [2, 8], midpoint = 5
field = make_field(data)
mask, = node.process(field, method="relative", threshold=0.5, direction="above")
# Only the bright patch (value 8 >= 5) should be masked
assert np.all(mask[10:20, 10:20] == 255)
assert np.all(mask[0:10, :] == 0)
assert np.all(mask[20:, :] == 0)
print(" PASS\n")
def test_threshold_empty_mask():
"""Very high absolute threshold on low data should produce an empty mask."""
print("=== Test: Empty mask from high threshold ===")
from backend.nodes.grains import ThresholdMask
node = ThresholdMask()
data = np.ones((64, 64))
field = make_field(data)
mask, = node.process(field, method="absolute", threshold=999.0, direction="above")
assert mask.sum() == 0, "Mask should be completely empty"
print(" PASS\n")
def test_threshold_full_mask():
"""Very low absolute threshold should produce an all-white mask."""
print("=== Test: Full mask from low threshold ===")
from backend.nodes.grains import ThresholdMask
node = ThresholdMask()
data = np.ones((64, 64)) * 5.0
field = make_field(data)
mask, = node.process(field, method="absolute", threshold=-1.0, direction="above")
assert np.all(mask == 255), "Mask should be all white"
print(" PASS\n")
# =========================================================================
# GrainAnalysis tests
# =========================================================================
def test_single_circle_area():
"""A single filled circle — verify pixel count and physical area."""
print("=== Test: Single circle area ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
N = 200
XREAL = 2e-6 # 2 µm
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
# Draw a filled circle, radius 30 px, centred at (100, 100)
yy, xx = np.mgrid[0:N, 0:N]
r = 30
circle = ((xx - 100) ** 2 + (yy - 100) ** 2) <= r ** 2
data[circle] = 5.0
mask[circle] = 255
field = make_field(data, xreal=XREAL, yreal=XREAL)
table, = node.process(field, mask=mask, min_size=1)
assert len(table) == 1, f"Expected 1 grain, got {len(table)}"
grain = table[0]
# Pixel area of a discrete circle: should be close to π r²
expected_px = np.pi * r ** 2
assert abs(grain["area_px"] - expected_px) / expected_px < 0.02, \
f"area_px={grain['area_px']}, expected≈{expected_px:.0f}"
# Physical area
pixel_area = (XREAL / N) ** 2
expected_m2 = grain["area_px"] * pixel_area
assert abs(grain["area_m2"] - expected_m2) < 1e-20, \
f"area_m2 mismatch: {grain['area_m2']} vs {expected_m2}"
# Equivalent diameter should be close to 2r in physical units
expected_diam = 2 * r * (XREAL / N)
assert abs(grain["equiv_diam_m"] - expected_diam) / expected_diam < 0.02, \
f"equiv_diam={grain['equiv_diam_m']:.3e}, expected≈{expected_diam:.3e}"
# Heights
assert abs(grain["mean_height"] - 5.0) < 1e-10
assert abs(grain["max_height"] - 5.0) < 1e-10
print(" PASS\n")
def test_multiple_grains_separation():
"""Three well-separated grains of different sizes — check each is reported."""
print("=== Test: Multiple grain separation ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
N = 128
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
# Grain A: 20×20 block, height 10
data[10:30, 10:30] = 10.0
mask[10:30, 10:30] = 255
# Grain B: 10×10 block, height 7
data[60:70, 60:70] = 7.0
mask[60:70, 60:70] = 255
# Grain C: 5×5 block, height 3
data[100:105, 100:105] = 3.0
mask[100:105, 100:105] = 255
field = make_field(data)
table, = node.process(field, mask=mask, min_size=1)
assert len(table) == 3, f"Expected 3 grains, got {len(table)}"
table.sort(key=lambda r: r["area_px"], reverse=True)
assert table[0]["area_px"] == 400 # 20×20
assert table[1]["area_px"] == 100 # 10×10
assert table[2]["area_px"] == 25 # 5×5
assert abs(table[0]["mean_height"] - 10.0) < 1e-10
assert abs(table[1]["mean_height"] - 7.0) < 1e-10
assert abs(table[2]["mean_height"] - 3.0) < 1e-10
print(" PASS\n")
def test_min_size_filtering():
"""min_size should exclude grains smaller than the threshold."""
print("=== Test: min_size filtering ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
N = 64
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
# Large grain: 15×15 = 225 px
data[5:20, 5:20] = 1.0
mask[5:20, 5:20] = 255
# Medium grain: 8×8 = 64 px
data[30:38, 30:38] = 1.0
mask[30:38, 30:38] = 255
# Tiny grain: 3×3 = 9 px
data[50:53, 50:53] = 1.0
mask[50:53, 50:53] = 255
field = make_field(data)
# min_size=1: all three
table, = node.process(field, mask=mask, min_size=1)
assert len(table) == 3
# min_size=10: drops the 3×3
table, = node.process(field, mask=mask, min_size=10)
assert len(table) == 2
# min_size=100: drops the 3×3 and 8×8
table, = node.process(field, mask=mask, min_size=100)
assert len(table) == 1
assert table[0]["area_px"] == 225
# min_size=300: drops everything
table, = node.process(field, mask=mask, min_size=300)
assert len(table) == 0
print(" PASS\n")
def test_grain_bounding_box():
"""Bounding box should match the grain extents."""
print("=== Test: Grain bounding box ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
N = 64
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
# Place a grain at rows 20:35, cols 10:45
data[20:35, 10:45] = 2.0
mask[20:35, 10:45] = 255
field = make_field(data)
table, = node.process(field, mask=mask, min_size=1)
assert len(table) == 1
bbox = table[0]["bbox"]
# Format: "(xmin,ymin)-(xmax,ymax)" = "(10,20)-(44,34)"
assert bbox == "(10,20)-(44,34)", f"bbox={bbox}, expected (10,20)-(44,34)"
print(" PASS\n")
def test_empty_mask_produces_no_grains():
"""An all-zero mask should yield zero grains."""
print("=== Test: Empty mask → no grains ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
field = make_field(np.ones((64, 64)))
mask = np.zeros((64, 64), dtype=np.uint8)
table, = node.process(field, mask=mask, min_size=1)
assert len(table) == 0
print(" PASS\n")
def test_grain_at_image_edge():
"""A grain touching the image border should still be detected."""
print("=== Test: Grain at image edge ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
N = 64
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
# Grain touching top-left corner
data[0:10, 0:10] = 4.0
mask[0:10, 0:10] = 255
field = make_field(data)
table, = node.process(field, mask=mask, min_size=1)
assert len(table) == 1
assert table[0]["area_px"] == 100
assert table[0]["bbox"] == "(0,0)-(9,9)"
print(" PASS\n")
def test_adjacent_grains_connectivity():
"""Two diagonally-touching blocks should be separate grains
(scipy.ndimage.label uses 4-connectivity by default)."""
print("=== Test: Diagonal adjacency → separate grains ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
N = 32
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
# Block A
data[5:10, 5:10] = 1.0
mask[5:10, 5:10] = 255
# Block B diagonally adjacent (touching only at corner 10,10)
data[10:15, 10:15] = 1.0
mask[10:15, 10:15] = 255
field = make_field(data)
table, = node.process(field, mask=mask, min_size=1)
# Default label() uses structure that connects diagonals? Let's verify.
# scipy.ndimage.label default is cross-shaped (no diagonals) for 2D
assert len(table) == 2, f"Expected 2 separate grains, got {len(table)}"
print(" PASS\n")
# =========================================================================
# End-to-end pipeline: ThresholdMask → GrainAnalysis
# =========================================================================
def test_pipeline_synthetic():
"""Full pipeline on a synthetic image with known geometry."""
print("=== Test: Full pipeline on synthetic particles ===")
from backend.nodes.grains import ThresholdMask, GrainAnalysis
N = 200
XREAL = 10e-6 # 10 µm
rng = np.random.default_rng(99)
# Background at 0 with small noise, particles as raised bumps
bg = rng.normal(0, 0.1, (N, N))
particles = np.zeros((N, N))
yy, xx = np.mgrid[0:N, 0:N]
specs = [
(50, 50, 15, 5.0), # (cx, cy, radius_px, height)
(150, 50, 20, 8.0),
(100, 100, 10, 3.0),
(50, 160, 25, 6.0),
(160, 160, 12, 4.0),
]
for cx, cy, r, h in specs:
inside = ((xx - cx) ** 2 + (yy - cy) ** 2) <= r ** 2
particles[inside] = h
data = bg + particles
field = make_field(data, xreal=XREAL, yreal=XREAL)
# Step 1: threshold
thresh = ThresholdMask()
mask, = thresh.process(field, method="absolute", threshold=1.0, direction="above")
# Particles are well above noise, so mask should capture all 5
assert mask.max() == 255, "No particles detected"
# Step 2: grain analysis
ga = GrainAnalysis()
table, = ga.process(field, mask=mask, min_size=5)
assert len(table) == 5, f"Expected 5 grains, got {len(table)}"
# Verify that detected areas are in the right ballpark
table.sort(key=lambda r: r["area_px"], reverse=True)
expected_areas = sorted([np.pi * r ** 2 for _, _, r, _ in specs], reverse=True)
for grain, expected_px in zip(table, expected_areas):
ratio = grain["area_px"] / expected_px
assert 0.85 < ratio < 1.15, \
f"grain area_px={grain['area_px']}, expected≈{expected_px:.0f}, ratio={ratio:.2f}"
print(" PASS\n")
def test_pipeline_demo_image():
"""Run the full pipeline on the bundled demo nanoparticles image."""
print("=== Test: Full pipeline on demo nanoparticles.npy ===")
from pathlib import Path
from backend.nodes.grains import ThresholdMask, GrainAnalysis
from backend.runtime_paths import demo_dir
npy_path = demo_dir() / "nanoparticles.npy"
if not npy_path.exists():
print(" SKIP (demo image not found)\n")
return
data = np.load(str(npy_path)).astype(np.float64)
# The demo image is a 5 µm × 5 µm scan
field = make_field(data, xreal=5e-6, yreal=5e-6)
# Threshold to find particles (they are raised above background)
thresh = ThresholdMask()
mask, = thresh.process(field, method="otsu", threshold=0.0, direction="above")
# Should detect particles
assert mask.max() == 255, "No particles found in demo image"
particle_fraction = (mask == 255).sum() / mask.size
assert 0.01 < particle_fraction < 0.5, \
f"Suspicious particle fraction: {particle_fraction:.3f}"
print(f" Mask: {particle_fraction*100:.1f}% of pixels are particles")
# Grain analysis
ga = GrainAnalysis()
table, = ga.process(field, mask=mask, min_size=20)
assert len(table) > 0, "No grains detected"
print(f" Found {len(table)} grains (min_size=20)")
# Sanity checks on grain properties
for grain in table:
assert grain["area_px"] >= 20
assert grain["area_m2"] > 0
assert grain["equiv_diam_m"] > 0
assert grain["max_height"] >= grain["mean_height"]
assert grain["mean_height"] > 0
# Physical size sanity: equivalent diameters should be in the nmµm range
diams_nm = [g["equiv_diam_m"] * 1e9 for g in table]
print(f" Diameters: min={min(diams_nm):.0f} nm, max={max(diams_nm):.0f} nm")
assert all(1 < d < 2000 for d in diams_nm), \
f"Grain diameters out of expected range: {diams_nm}"
print(" PASS\n")
# =========================================================================
# Run all tests
# =========================================================================
if __name__ == "__main__":
# ThresholdMask
test_threshold_otsu_bimodal()
test_threshold_relative_range()
test_threshold_empty_mask()
test_threshold_full_mask()
# GrainAnalysis
test_single_circle_area()
test_multiple_grains_separation()
test_min_size_filtering()
test_grain_bounding_box()
test_empty_mask_produces_no_grains()
test_grain_at_image_edge()
test_adjacent_grains_connectivity()
# End-to-end pipeline
test_pipeline_synthetic()
test_pipeline_demo_image()
print("All grain tests passed!")

View File

@@ -85,6 +85,88 @@ def test_edge_detect():
print(" PASS\n")
def test_fft_filter_1d():
print("=== Test: FFTFilter1D ===")
from backend.nodes.filters import FFTFilter1D
node = FFTFilter1D()
# Signal: low-frequency sine + high-frequency sine
n = 256
t = np.arange(n, dtype=np.float64) / n
low = np.sin(2 * np.pi * 3 * t) # 3 cycles — low freq
high = np.sin(2 * np.pi * 80 * t) # 80 cycles — high freq
line = low + high
# Lowpass should keep low, suppress high
filtered_lp, = node.process(line, filter_type="lowpass", cutoff=0.15, cutoff_high=0.4, order=4)
assert len(filtered_lp) == n
corr_low = np.corrcoef(filtered_lp, low)[0, 1]
corr_high = np.corrcoef(filtered_lp, high)[0, 1]
assert corr_low > 0.95, f"Lowpass: correlation with low={corr_low}"
assert abs(corr_high) < 0.3, f"Lowpass: correlation with high={corr_high}"
# Highpass should keep high, suppress low
filtered_hp, = node.process(line, filter_type="highpass", cutoff=0.4, cutoff_high=0.4, order=4)
corr_low_hp = np.corrcoef(filtered_hp, low)[0, 1]
corr_high_hp = np.corrcoef(filtered_hp, high)[0, 1]
assert abs(corr_low_hp) < 0.3, f"Highpass: correlation with low={corr_low_hp}"
assert corr_high_hp > 0.95, f"Highpass: correlation with high={corr_high_hp}"
# Bandpass centred on the high frequency
filtered_bp, = node.process(line, filter_type="bandpass", cutoff=0.4, cutoff_high=0.8, order=4)
corr_low_bp = np.corrcoef(filtered_bp, low)[0, 1]
corr_high_bp = np.corrcoef(filtered_bp, high)[0, 1]
assert abs(corr_low_bp) < 0.3, f"Bandpass: correlation with low={corr_low_bp}"
assert corr_high_bp > 0.9, f"Bandpass: correlation with high={corr_high_bp}"
# Notch (band-reject) centred on the high frequency — should remove it
filtered_notch, = node.process(line, filter_type="notch", cutoff=0.4, cutoff_high=0.8, order=4)
corr_low_notch = np.corrcoef(filtered_notch, low)[0, 1]
corr_high_notch = np.corrcoef(filtered_notch, high)[0, 1]
assert corr_low_notch > 0.95, f"Notch: correlation with low={corr_low_notch}"
assert abs(corr_high_notch) < 0.3, f"Notch: correlation with high={corr_high_notch}"
print(" PASS\n")
def test_fft_filter_2d():
print("=== Test: FFTFilter2D ===")
from backend.nodes.filters import FFTFilter2D
node = FFTFilter2D()
N = 128
y, x = np.mgrid[0:N, 0:N] / N
# Low-frequency 2D pattern + high-frequency pattern
low_2d = np.sin(2 * np.pi * 3 * x) + np.sin(2 * np.pi * 3 * y)
high_2d = np.sin(2 * np.pi * 40 * x) + np.sin(2 * np.pi * 40 * y)
data = low_2d + high_2d
field = make_field(data=data, shape=None, xreal=1e-6, yreal=1e-6)
# Lowpass — should preserve low, remove high
result_lp, = node.process(field, filter_type="lowpass", cutoff=0.15, cutoff_high=0.4, order=4)
assert result_lp.data.shape == (N, N)
assert result_lp.xreal == field.xreal
assert result_lp.si_unit_z == field.si_unit_z
corr_low = np.corrcoef(result_lp.data.ravel(), low_2d.ravel())[0, 1]
corr_high = np.corrcoef(result_lp.data.ravel(), high_2d.ravel())[0, 1]
assert corr_low > 0.9, f"2D lowpass: correlation with low={corr_low}"
assert abs(corr_high) < 0.3, f"2D lowpass: correlation with high={corr_high}"
# Highpass — should preserve high, remove low
result_hp, = node.process(field, filter_type="highpass", cutoff=0.4, cutoff_high=0.4, order=4)
corr_low_hp = np.corrcoef(result_hp.data.ravel(), low_2d.ravel())[0, 1]
corr_high_hp = np.corrcoef(result_hp.data.ravel(), high_2d.ravel())[0, 1]
assert abs(corr_low_hp) < 0.3, f"2D highpass: correlation with low={corr_low_hp}"
assert corr_high_hp > 0.9, f"2D highpass: correlation with high={corr_high_hp}"
# Constant field should be unchanged by lowpass (DC preservation)
const = make_field(data=np.ones((32, 32)) * 7.0)
result_const, = node.process(const, filter_type="lowpass", cutoff=0.5, cutoff_high=0.5, order=2)
assert np.allclose(result_const.data, 7.0, atol=1e-10), "Lowpass should preserve constant field"
print(" PASS\n")
# =========================================================================
# Level
# =========================================================================
@@ -199,7 +281,7 @@ def test_height_histogram():
data = np.linspace(0, 1, 1000).reshape(25, 40)
field = make_field(data=data)
counts, bin_centers = node.process(field, n_bins=10)
counts, bin_centers = node.process(field, n_bins=10, y_scale="linear")
assert len(counts) == 10
assert len(bin_centers) == 10
assert counts.dtype == np.float64
@@ -265,7 +347,7 @@ def test_cross_section():
def test_threshold_mask():
print("=== Test: ThresholdMask ===")
from backend.nodes.grains import ThresholdMask
from backend.nodes.mask import ThresholdMask
node = ThresholdMask()
# Clear bimodal data: left half = 0, right half = 1
@@ -273,6 +355,11 @@ def test_threshold_mask():
data[:, 32:] = 1.0
field = make_field(data=data)
# Capture overlay preview
previews = []
ThresholdMask._broadcast_fn = lambda nid, uri: previews.append(uri)
ThresholdMask._current_node_id = "test"
# Absolute threshold at 0.5
mask, = node.process(field, method="absolute", threshold=0.5, direction="above")
assert mask.dtype == np.uint8
@@ -280,6 +367,10 @@ def test_threshold_mask():
assert np.all(mask[:, :32] == 0)
assert np.all(mask[:, 32:] == 255)
# Verify overlay preview was broadcast
assert len(previews) == 1
assert previews[0].startswith("data:image/png;base64,")
# Direction "below"
mask_below, = node.process(field, method="absolute", threshold=0.5, direction="below")
assert np.all(mask_below[:, :32] == 255)
@@ -292,20 +383,117 @@ def test_threshold_mask():
# Otsu should find the bimodal threshold
mask_otsu, = node.process(field, method="otsu", threshold=0.0, direction="above")
assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum()
ThresholdMask._broadcast_fn = None
print(" PASS\n")
def test_grain_analysis():
print("=== Test: GrainAnalysis ===")
from backend.nodes.grains import GrainAnalysis
node = GrainAnalysis()
def test_mask_morphology():
print("=== Test: MaskMorphology ===")
from backend.nodes.mask import MaskMorphology
node = MaskMorphology()
# Create a field with two distinct "grains"
# Small square blob in the centre
mask = np.zeros((64, 64), dtype=np.uint8)
mask[28:36, 28:36] = 255 # 8x8 block
orig_count = np.count_nonzero(mask)
# Dilate should grow the region
dilated, = node.process(mask, operation="dilate", radius=1, shape="square")
assert dilated.dtype == np.uint8
assert np.count_nonzero(dilated) > orig_count
# Erode should shrink it
eroded, = node.process(mask, operation="erode", radius=1, shape="square")
assert np.count_nonzero(eroded) < orig_count
# Open on a clean block should give back roughly the same block
opened, = node.process(mask, operation="open", radius=1, shape="square")
assert np.count_nonzero(opened) <= orig_count
# Close on a mask with a 1-pixel hole should fill the hole
mask_hole = mask.copy()
mask_hole[32, 32] = 0 # poke a hole
assert np.count_nonzero(mask_hole) == orig_count - 1
closed, = node.process(mask_hole, operation="close", radius=1, shape="square")
assert closed[32, 32] == 255, "Close should fill the 1-pixel hole"
# Disk structuring element should also work
dilated_disk, = node.process(mask, operation="dilate", radius=2, shape="disk")
assert np.count_nonzero(dilated_disk) > orig_count
print(" PASS\n")
def test_mask_invert():
print("=== Test: MaskInvert ===")
from backend.nodes.mask import MaskInvert
node = MaskInvert()
mask = np.zeros((64, 64), dtype=np.uint8)
mask[10:20, 10:20] = 255
inverted, = node.process(mask)
assert inverted.dtype == np.uint8
assert np.all(inverted[10:20, 10:20] == 0)
assert np.all(inverted[0:10, 0:10] == 255)
# Double-invert should return to original
double, = node.process(inverted)
assert np.array_equal(double, mask)
print(" PASS\n")
def test_mask_combine():
print("=== Test: MaskCombine ===")
from backend.nodes.mask import MaskCombine
node = MaskCombine()
# Two overlapping squares
a = np.zeros((64, 64), dtype=np.uint8)
a[10:30, 10:30] = 255 # 20x20
b = np.zeros((64, 64), dtype=np.uint8)
b[20:40, 20:40] = 255 # 20x20, overlaps 10x10
# AND — only the overlap
result_and, = node.process(a, b, operation="and")
assert np.all(result_and[20:30, 20:30] == 255)
assert result_and[15, 15] == 0 # a-only region
assert result_and[35, 35] == 0 # b-only region
# OR — union
result_or, = node.process(a, b, operation="or")
assert result_or[15, 15] == 255
assert result_or[35, 35] == 255
assert result_or[25, 25] == 255
assert result_or[5, 5] == 0
# XOR — symmetric difference
result_xor, = node.process(a, b, operation="xor")
assert result_xor[15, 15] == 255 # a-only
assert result_xor[35, 35] == 255 # b-only
assert result_xor[25, 25] == 0 # overlap excluded
# Subtract — a minus b
result_sub, = node.process(a, b, operation="subtract")
assert result_sub[15, 15] == 255 # a-only kept
assert result_sub[25, 25] == 0 # overlap removed
assert result_sub[35, 35] == 0 # b-only not included
print(" PASS\n")
def test_particle_analysis():
print("=== Test: ParticleAnalysis ===")
from backend.nodes.grains import ParticleAnalysis
node = ParticleAnalysis()
# Create a field with two distinct particles
N = 64
data = np.zeros((N, N))
# Grain 1: 10x10 block at top-left with height 5
# Particle 1: 10x10 block at top-left with height 5
data[5:15, 5:15] = 5.0
# Grain 2: 8x8 block at bottom-right with height 3
# Particle 2: 8x8 block at bottom-right with height 3
data[45:53, 45:53] = 3.0
field = make_field(data=data, xreal=1e-6, yreal=1e-6)
@@ -315,7 +503,7 @@ def test_grain_analysis():
mask[45:53, 45:53] = 255
table, = node.process(field, mask=mask, min_size=10)
assert len(table) == 2, f"Expected 2 grains, got {len(table)}"
assert len(table) == 2, f"Expected 2 particles, got {len(table)}"
# Sort by area descending
table.sort(key=lambda r: r["area_px"], reverse=True)
@@ -324,7 +512,7 @@ def test_grain_analysis():
assert abs(table[0]["mean_height"] - 5.0) < 1e-10
assert abs(table[1]["mean_height"] - 3.0) < 1e-10
# min_size filtering: only keep grains >= 80 px
# min_size filtering: only keep particles >= 80 px
table_filtered, = node.process(field, mask=mask, min_size=80)
assert len(table_filtered) == 1
assert table_filtered[0]["area_px"] == 100
@@ -462,6 +650,8 @@ if __name__ == "__main__":
test_gaussian_filter()
test_median_filter()
test_edge_detect()
test_fft_filter_1d()
test_fft_filter_2d()
# Level
test_plane_level()
@@ -473,9 +663,14 @@ if __name__ == "__main__":
test_height_histogram()
test_cross_section()
# Grains
# Mask
test_threshold_mask()
test_grain_analysis()
test_mask_morphology()
test_mask_invert()
test_mask_combine()
# Grains
test_particle_analysis()
# I/O
test_load_image()