from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.data_types import DataField, RecordTable @register_node(display_name="Particle Analysis") class ParticleAnalysis: @classmethod def INPUT_TYPES(cls): return { "required": { "field": ("DATA_FIELD",), "mask": ("IMAGE",), "min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}), } } RETURN_TYPES = ("RECORD_TABLE",) RETURN_NAMES = ("particle_stats",) FUNCTION = "process" 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_particles_get_values." ) 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) pixel_area = field.dx * field.dy rows = RecordTable() for pid in range(1, n_particles + 1): particle_pixels = labeled == pid area_px = int(particle_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] mean_h = float(heights.mean()) max_h = float(heights.max()) ys, xs = np.where(particle_pixels) bbox = f"({int(xs.min())},{int(ys.min())})-({int(xs.max())},{int(ys.max())})" rows.append({ "particle_id": pid, "area_px": area_px, "area_m2": area_m2, "equiv_diam_m": equiv_diam, "mean_height": mean_h, "max_height": max_h, "bbox": bbox, }) return (rows,)