"""Grain summary statistics — aggregate statistics for all grains.""" from __future__ import annotations import numpy as np from scipy.ndimage import label from backend.node_registry import register_node from backend.data_types import DataField, RecordTable from backend.nodes.helpers import mask_to_bool, _square_unit @register_node(display_name="Grain Summary") class GrainSummary: @classmethod def INPUT_TYPES(cls): return { "required": { "field": ("DATA_FIELD",), "mask": ("IMAGE",), "min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}), } } OUTPUTS = ( ('RECORD_TABLE', 'summary'), ) FUNCTION = "process" DESCRIPTION = ( "Compute aggregate statistics for all grains in a mask: count, density, " "coverage fraction, mean/median area, total volume, and height statistics. " ) def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple: data = np.asarray(field.data, dtype=np.float64) grain_mask = mask_to_bool(mask) labeled, n_grains = label(grain_mask.astype(np.int32)) pixel_area = field.dx * field.dy total_area = field.xreal * field.yreal xy_unit = field.si_unit_xy or "m" z_unit = field.si_unit_z or "m" # Collect per-grain properties areas = [] heights = [] volumes = [] base_height = float(data[~grain_mask].mean()) if (~grain_mask).any() else 0.0 for gid in range(1, n_grains + 1): gpx = labeled == gid n_px = int(gpx.sum()) if n_px < min_size: continue area = n_px * pixel_area areas.append(area) heights.append(float(data[gpx].mean())) volumes.append(float(np.sum(data[gpx] - base_height) * pixel_area)) records = RecordTable() n_valid = len(areas) records.append({"quantity": "Grain count", "value": str(n_valid), "unit": ""}) records.append({"quantity": "Grain density", "value": f"{n_valid / total_area:.4g}" if total_area > 0 else "0", "unit": f"1/{_square_unit(xy_unit)}"}) coverage = sum(areas) / total_area if total_area > 0 else 0.0 records.append({"quantity": "Coverage fraction", "value": f"{coverage:.4f}", "unit": ""}) if n_valid > 0: records.append({"quantity": "Mean area", "value": f"{np.mean(areas):.4g}", "unit": _square_unit(xy_unit)}) records.append({"quantity": "Median area", "value": f"{np.median(areas):.4g}", "unit": _square_unit(xy_unit)}) records.append({"quantity": "Total volume", "value": f"{sum(volumes):.4g}", "unit": f"{_square_unit(xy_unit)}·{z_unit}"}) records.append({"quantity": "Mean height", "value": f"{np.mean(heights):.4g}", "unit": z_unit}) records.append({"quantity": "Median height", "value": f"{np.median(heights):.4g}", "unit": z_unit}) records.append({"quantity": "Max area", "value": f"{max(areas):.4g}", "unit": _square_unit(xy_unit)}) records.append({"quantity": "Min area", "value": f"{min(areas):.4g}", "unit": _square_unit(xy_unit)}) return (records,)