deduplication pass
This commit is contained in:
@@ -86,7 +86,7 @@ class Annotations:
|
||||
|
||||
context = _annotation_context_from_image(input)
|
||||
if context is None:
|
||||
self._send_warning(
|
||||
emit_warning(
|
||||
"Annotations image input has no scale metadata, so scale bar and color-map legend cannot be added."
|
||||
)
|
||||
return (ImageData(image_to_uint8(input)),)
|
||||
@@ -111,7 +111,7 @@ class Annotations:
|
||||
if not (has_legend_values and str(context.get("legend_unit", "")).strip()):
|
||||
missing_features.append("color-map legend")
|
||||
if missing_features:
|
||||
self._send_warning(
|
||||
emit_warning(
|
||||
f"Annotations image input is missing metadata for: {', '.join(missing_features)}."
|
||||
)
|
||||
annotated = _apply_annotation_overlay_from_context(
|
||||
@@ -120,6 +120,3 @@ class Annotations:
|
||||
annotation_spec,
|
||||
)
|
||||
return (ImageData(annotated, metadata={"annotation_context": context}),)
|
||||
|
||||
def _send_warning(self, message: str):
|
||||
emit_warning(message)
|
||||
|
||||
@@ -3,6 +3,7 @@ import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_overlay
|
||||
from backend.data_types import DataField, LineData, RecordTable, encode_preview, render_datafield_preview
|
||||
from backend.nodes.helpers import frac_to_index
|
||||
|
||||
|
||||
@register_node(display_name="Cursors")
|
||||
@@ -76,16 +77,8 @@ class Cursors:
|
||||
xmin = float(np.min(x)) if len(x) else 0.0
|
||||
xmax = float(np.max(x)) if len(x) else 1.0
|
||||
|
||||
def x_frac_to_idx(frac):
|
||||
if n <= 1:
|
||||
return 0
|
||||
if xmax == xmin:
|
||||
return 0
|
||||
target_x = xmin + frac * (xmax - xmin)
|
||||
return int(np.argmin(np.abs(x - target_x)))
|
||||
|
||||
idx_a = x_frac_to_idx(x1)
|
||||
idx_b = x_frac_to_idx(x2)
|
||||
idx_a = frac_to_index(x, x1)
|
||||
idx_b = frac_to_index(x, x2)
|
||||
|
||||
xa, ya = float(x[idx_a]), float(y[idx_a])
|
||||
xb, yb = float(x[idx_b]), float(y[idx_b])
|
||||
|
||||
@@ -16,6 +16,7 @@ from backend.data_types import (
|
||||
from backend.execution_context import emit_preview, emit_table, emit_warning
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.surface_common import require_compatible_xy_z_units
|
||||
from backend.nodes.helpers import normalize_mask, apply_masking
|
||||
|
||||
_CURVATURE_COLOR = "#ff9800"
|
||||
_CENTER_COLOR = "#8bd3ff"
|
||||
@@ -28,16 +29,6 @@ class _Intersection:
|
||||
y: float
|
||||
|
||||
|
||||
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 _canonicalize_half_pi(angle: float) -> float:
|
||||
wrapped = (float(angle) + 0.5 * np.pi) % np.pi - 0.5 * np.pi
|
||||
if wrapped <= -0.5 * np.pi + 1e-15:
|
||||
@@ -52,9 +43,7 @@ def _fit_quadratic_surface(data: np.ndarray, mask: np.ndarray | None, masking: s
|
||||
x = 2.0 * xx.astype(np.float64) / max(xres - 1, 1) - 1.0
|
||||
y = 2.0 * yy.astype(np.float64) / max(yres - 1, 1) - 1.0
|
||||
|
||||
valid = np.ones(data.shape, dtype=bool)
|
||||
if mask is not None and masking != "ignore":
|
||||
valid = mask if masking == "include" else ~mask
|
||||
valid = apply_masking(data, mask, masking)
|
||||
|
||||
if np.count_nonzero(valid) < 6:
|
||||
return None
|
||||
@@ -309,7 +298,7 @@ class Curvature:
|
||||
mask: np.ndarray | None = None,
|
||||
) -> tuple:
|
||||
require_compatible_xy_z_units(field, "Curvature")
|
||||
mask_array = _normalize_mask(mask, field.data.shape)
|
||||
mask_array = normalize_mask(mask, field.data.shape)
|
||||
results = _compute_curvature_results(field, mask_array, masking)
|
||||
|
||||
if results is None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from scipy.ndimage import map_coordinates
|
||||
from backend.data_types import LineData, RecordTable
|
||||
from backend.execution_context import emit_overlay, emit_table, emit_warning
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.spectral_common import _window_vector
|
||||
|
||||
|
||||
_LOG_TINY = float(np.finfo(np.float64).tiny)
|
||||
@@ -79,13 +80,6 @@ def _row_level2(row: np.ndarray) -> np.ndarray:
|
||||
return values - (coeffs[0] + coeffs[1] * x)
|
||||
|
||||
|
||||
def _hann_window(size: int) -> np.ndarray:
|
||||
if size <= 0:
|
||||
return np.ones(0, dtype=np.float64)
|
||||
t = (np.arange(size, dtype=np.float64) + 0.5) / float(size)
|
||||
return 0.5 - 0.5 * np.cos(2.0 * np.pi * t)
|
||||
|
||||
|
||||
def _window_with_rms_compensation(values: np.ndarray, window: np.ndarray) -> np.ndarray:
|
||||
row = np.asarray(values, dtype=np.float64)
|
||||
rms = float(np.sqrt(np.mean(row * row)))
|
||||
@@ -207,7 +201,7 @@ def _fractal_psdf(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
if width < 2 or rows < 1:
|
||||
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
|
||||
|
||||
window = _hann_window(width)
|
||||
window = _window_vector(width, "hann")
|
||||
accum = np.zeros(width // 2 + 1, dtype=np.float64)
|
||||
for row in np.asarray(data, dtype=np.float64):
|
||||
leveled = _row_level2(row)
|
||||
|
||||
@@ -31,30 +31,20 @@ class Gradient:
|
||||
)
|
||||
|
||||
def process(self, field: DataField, component: str) -> tuple:
|
||||
from scipy.ndimage import sobel
|
||||
from backend.nodes.surface_common import physical_sobel_gradient, slope_unit
|
||||
|
||||
data = field.data
|
||||
# Sobel kernel sums to ±8 over 2-pixel span; divide by 8·dx to get z/xy slope.
|
||||
gx = sobel(data, axis=1) / (8.0 * field.dx)
|
||||
gy = sobel(data, axis=0) / (8.0 * field.dy)
|
||||
gx, gy = physical_sobel_gradient(field)
|
||||
|
||||
if component == "magnitude":
|
||||
result = np.hypot(gx, gy)
|
||||
z = str(field.si_unit_z or "").strip()
|
||||
xy = str(field.si_unit_xy or "").strip()
|
||||
out_unit_z = f"{z}/{xy}" if z and xy else (z or xy)
|
||||
out_unit_z = slope_unit(field)
|
||||
elif component == "x":
|
||||
result = gx
|
||||
z = str(field.si_unit_z or "").strip()
|
||||
xy = str(field.si_unit_xy or "").strip()
|
||||
out_unit_z = f"{z}/{xy}" if z and xy else (z or xy)
|
||||
out_unit_z = slope_unit(field)
|
||||
elif component == "y":
|
||||
result = gy
|
||||
z = str(field.si_unit_z or "").strip()
|
||||
xy = str(field.si_unit_xy or "").strip()
|
||||
out_unit_z = f"{z}/{xy}" if z and xy else (z or xy)
|
||||
out_unit_z = slope_unit(field)
|
||||
elif component == "azimuth":
|
||||
# Azimuth: local slope direction, radians, matches Gwyddion's filter_azimuth
|
||||
result = np.arctan2(gy, gx)
|
||||
out_unit_z = "rad"
|
||||
else:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, DataTable
|
||||
from backend.nodes.helpers import _square_unit
|
||||
from backend.nodes.helpers import _square_unit, mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Grain Analysis")
|
||||
@@ -31,7 +31,7 @@ class GrainAnalysis:
|
||||
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||
from scipy.ndimage import label
|
||||
|
||||
binary = (mask > 127).astype(np.int32)
|
||||
binary = mask_to_bool(mask).astype(np.int32)
|
||||
labeled, n_grains = label(binary)
|
||||
|
||||
pixel_area = field.dx * field.dy
|
||||
|
||||
@@ -7,13 +7,14 @@ from scipy.ndimage import binary_erosion, distance_transform_edt
|
||||
|
||||
from backend.data_types import DataField
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
def _normalize_mask(mask: np.ndarray) -> np.ndarray:
|
||||
data = np.asarray(mask)
|
||||
if data.ndim != 2:
|
||||
raise ValueError("Grain Distance Transform requires a 2-D mask.")
|
||||
return data > 127
|
||||
return mask_to_bool(data)
|
||||
|
||||
|
||||
def _prepare_mask(binary: np.ndarray, from_border: bool) -> tuple[np.ndarray, tuple[slice, slice]]:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||
|
||||
|
||||
@register_node(display_name="Grain Filter")
|
||||
@@ -40,7 +41,7 @@ class GrainFilter:
|
||||
) -> tuple:
|
||||
from scipy.ndimage import label
|
||||
|
||||
binary = np.asarray(mask) > 127
|
||||
binary = mask_to_bool(mask)
|
||||
labeled, n_grains = label(binary)
|
||||
|
||||
# Build per-grain keep table (index 0 = background, always False)
|
||||
@@ -60,7 +61,7 @@ class GrainFilter:
|
||||
keep[gid] = True
|
||||
|
||||
result = keep[labeled]
|
||||
return (result.astype(np.uint8) * 255,)
|
||||
return (bool_to_mask(result),)
|
||||
|
||||
|
||||
def _touches_border(grain: np.ndarray) -> bool:
|
||||
|
||||
@@ -309,10 +309,34 @@ def _render_markup_image(image, shapes):
|
||||
# Mask helpers (from mask.py — used by multiple mask nodes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mask_to_bool(mask: np.ndarray) -> np.ndarray:
|
||||
"""Convert a uint8 mask (0/255) to a boolean array."""
|
||||
return np.asarray(mask) > 127
|
||||
|
||||
|
||||
def bool_to_mask(binary: np.ndarray) -> np.ndarray:
|
||||
"""Convert a boolean array to a uint8 mask (0/255)."""
|
||||
return np.asarray(binary, dtype=np.uint8) * 255
|
||||
|
||||
|
||||
def normalize_mask(
|
||||
mask: np.ndarray | None, shape: tuple[int, int],
|
||||
) -> np.ndarray | None:
|
||||
"""Validate mask shape and convert from uint8 to boolean."""
|
||||
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_to_bool(mask_array)
|
||||
|
||||
|
||||
def _mask_overlay(field, mask):
|
||||
from backend.data_types import datafield_to_uint8
|
||||
grey = datafield_to_uint8(field, "gray")
|
||||
mask_bool = mask > 127
|
||||
mask_bool = mask_to_bool(mask)
|
||||
if not np.any(mask_bool):
|
||||
return grey
|
||||
|
||||
@@ -728,6 +752,62 @@ def _square_unit(unit: str) -> str:
|
||||
return f"{unit}^2"
|
||||
|
||||
|
||||
def apply_masking(data: np.ndarray, mask: np.ndarray | None, masking: str) -> np.ndarray:
|
||||
"""Return a boolean validity array from a mask and masking mode.
|
||||
|
||||
Returns a bool array the same shape as *data* indicating which pixels
|
||||
should be included in calculations.
|
||||
"""
|
||||
if mask is None or masking == "ignore":
|
||||
return np.ones(data.shape, dtype=bool)
|
||||
if masking == "include":
|
||||
return np.asarray(mask, dtype=bool)
|
||||
if masking == "exclude":
|
||||
return ~np.asarray(mask, dtype=bool)
|
||||
raise ValueError(f"Unknown masking mode: {masking}")
|
||||
|
||||
|
||||
def masked_values(data: np.ndarray, mask: np.ndarray | None, masking: str) -> np.ndarray:
|
||||
"""Return the 1-D subset of *data* selected by the masking mode."""
|
||||
if mask is None or masking == "ignore":
|
||||
return data
|
||||
if masking == "include":
|
||||
return data[mask]
|
||||
if masking == "exclude":
|
||||
return data[~mask]
|
||||
raise ValueError(f"Unknown masking mode: {masking}")
|
||||
|
||||
|
||||
def emit_mask_preview(field, mask_uint8: np.ndarray) -> None:
|
||||
"""Emit a standard mask-on-field preview if *field* is not None."""
|
||||
if field is None:
|
||||
return
|
||||
from backend.execution_context import emit_preview
|
||||
from backend.data_types import encode_preview
|
||||
emit_preview(encode_preview(_mask_overlay(field, mask_uint8)))
|
||||
|
||||
|
||||
def histogram_with_centers(data: np.ndarray, bins: int = 256):
|
||||
"""Compute histogram and return (counts_float64, bin_centers)."""
|
||||
raw_counts, bin_edges = np.histogram(data.ravel(), bins=int(bins))
|
||||
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
||||
counts = raw_counts.astype(np.float64)
|
||||
return counts, bin_centers
|
||||
|
||||
|
||||
def frac_to_index(axis: np.ndarray, frac: float) -> int:
|
||||
"""Map a fractional position [0, 1] to the nearest index in *axis*."""
|
||||
n = len(axis)
|
||||
if n <= 1:
|
||||
return 0
|
||||
lo = float(axis[0])
|
||||
hi = float(axis[-1])
|
||||
if hi == lo:
|
||||
return 0
|
||||
target = lo + frac * (hi - lo)
|
||||
return int(np.argmin(np.abs(axis - target)))
|
||||
|
||||
|
||||
def _apply_scalar_unit(base_unit: str, operation: str) -> str:
|
||||
unit = str(base_unit or "").strip()
|
||||
if operation == "count":
|
||||
|
||||
@@ -3,6 +3,7 @@ import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_overlay
|
||||
from backend.data_types import DataField, RecordTable
|
||||
from backend.nodes.helpers import frac_to_index, histogram_with_centers
|
||||
|
||||
|
||||
@register_node(display_name="Histogram")
|
||||
@@ -44,9 +45,7 @@ class Histogram:
|
||||
x2: float = 0.75,
|
||||
y2: float = 0.5,
|
||||
) -> tuple:
|
||||
raw_counts, bin_edges = np.histogram(field.data.ravel(), bins=int(n_bins))
|
||||
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
||||
counts = raw_counts.astype(np.float64)
|
||||
counts, bin_centers = histogram_with_centers(field.data, n_bins)
|
||||
if y_scale == "log":
|
||||
counts = np.log10(1.0 + counts)
|
||||
|
||||
@@ -56,16 +55,8 @@ class Histogram:
|
||||
xmin = float(np.min(bin_centers)) if len(bin_centers) else 0.0
|
||||
xmax = float(np.max(bin_centers)) if len(bin_centers) else 1.0
|
||||
|
||||
def x_frac_to_idx(frac):
|
||||
if len(bin_centers) <= 1:
|
||||
return 0
|
||||
if xmax == xmin:
|
||||
return 0
|
||||
target_x = xmin + frac * (xmax - xmin)
|
||||
return int(np.argmin(np.abs(bin_centers - target_x)))
|
||||
|
||||
idx_a = x_frac_to_idx(x1)
|
||||
idx_b = x_frac_to_idx(x2)
|
||||
idx_a = frac_to_index(bin_centers, x1)
|
||||
idx_b = frac_to_index(bin_centers, x2)
|
||||
xa = float(bin_centers[idx_a]) if len(bin_centers) else 0.0
|
||||
xb = float(bin_centers[idx_b]) if len(bin_centers) else 0.0
|
||||
ya = float(counts[idx_a]) if len(counts) else 0.0
|
||||
|
||||
@@ -5,16 +5,7 @@ import numpy as np
|
||||
from backend.data_types import DataField
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.surface_common import require_compatible_xy_z_units
|
||||
|
||||
|
||||
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
|
||||
from backend.nodes.helpers import normalize_mask
|
||||
|
||||
|
||||
def _facet_cell_mask(mask: np.ndarray | None, masking: str, shape: tuple[int, int]) -> np.ndarray:
|
||||
@@ -141,6 +132,6 @@ class FacetLevelField:
|
||||
mask: np.ndarray | None = None,
|
||||
) -> tuple:
|
||||
require_compatible_xy_z_units(field, "Facet Level")
|
||||
mask_array = _normalize_mask(mask, field.data.shape)
|
||||
mask_array = normalize_mask(mask, field.data.shape)
|
||||
leveled = _facet_level_data(field, mask_array, masking, max_iterations=100)
|
||||
return (field.replace(data=leveled),)
|
||||
|
||||
@@ -2,16 +2,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
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
|
||||
from backend.nodes.helpers import normalize_mask, apply_masking
|
||||
|
||||
|
||||
def _fit_plane(
|
||||
@@ -24,14 +15,7 @@ def _fit_plane(
|
||||
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}")
|
||||
valid = apply_masking(data, mask, masking)
|
||||
|
||||
if np.count_nonzero(valid) < 3:
|
||||
raise ValueError("Plane Level requires at least three usable pixels for fitting.")
|
||||
@@ -78,7 +62,7 @@ class PlaneLevelField:
|
||||
mask: np.ndarray | None = None,
|
||||
) -> tuple:
|
||||
data = field.data.copy()
|
||||
mask_array = _normalize_mask(mask, data.shape)
|
||||
mask_array = normalize_mask(mask, data.shape)
|
||||
pa, pbx, pby, xx, yy = _fit_plane(data, mask_array, masking)
|
||||
|
||||
plane = (pa + pbx * xx + pby * yy)
|
||||
|
||||
@@ -4,16 +4,7 @@ import numpy as np
|
||||
|
||||
from backend.data_types import DataField, LineData
|
||||
from backend.node_registry import register_node
|
||||
|
||||
|
||||
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
|
||||
from backend.nodes.helpers import normalize_mask, apply_masking, masked_values
|
||||
|
||||
|
||||
def _trimmed_mean_or_median(values: np.ndarray, trim_fraction: float) -> float:
|
||||
@@ -33,18 +24,8 @@ def _trimmed_mean_or_median(values: np.ndarray, trim_fraction: float) -> float:
|
||||
return float(trimmed.mean()) if trimmed.size else float(np.median(sorted_values))
|
||||
|
||||
|
||||
def _masked_values(data: np.ndarray, mask: np.ndarray | None, masking: str) -> np.ndarray:
|
||||
if mask is None or masking == "ignore":
|
||||
return data
|
||||
if masking == "include":
|
||||
return data[mask]
|
||||
if masking == "exclude":
|
||||
return data[~mask]
|
||||
raise ValueError(f"Unknown masking mode: {masking}")
|
||||
|
||||
|
||||
def _global_masked_median(data: np.ndarray, mask: np.ndarray | None, masking: str) -> float:
|
||||
selected = _masked_values(data, mask, masking)
|
||||
selected = masked_values(data, mask, masking)
|
||||
if selected.size == 0:
|
||||
selected = np.asarray(data, dtype=np.float64).ravel()
|
||||
return float(np.median(selected))
|
||||
@@ -75,7 +56,7 @@ def _find_row_shifts_trimmed_mean(
|
||||
shifts[i] = _trimmed_mean_or_median(row, trim_fraction)
|
||||
continue
|
||||
|
||||
values = _masked_values(row, row_mask, masking)
|
||||
values = masked_values(row, row_mask, masking)
|
||||
if values.size >= mincount:
|
||||
shifts[i] = _trimmed_mean_or_median(values, trim_fraction)
|
||||
else:
|
||||
@@ -162,12 +143,7 @@ def _row_level_poly(
|
||||
row = data[i]
|
||||
row_mask = None if mask is None else mask[i]
|
||||
|
||||
if row_mask is None or masking == "ignore":
|
||||
valid = np.ones(xres, dtype=bool)
|
||||
elif masking == "include":
|
||||
valid = row_mask
|
||||
else:
|
||||
valid = ~row_mask
|
||||
valid = apply_masking(row, row_mask, masking)
|
||||
|
||||
coeffs = np.zeros(degree + 1, dtype=np.float64)
|
||||
if np.count_nonzero(valid) > degree:
|
||||
@@ -331,7 +307,7 @@ class LineCorrection:
|
||||
mask: np.ndarray | None = None,
|
||||
) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
mask_array = _normalize_mask(mask, data.shape)
|
||||
mask_array = normalize_mask(mask, data.shape)
|
||||
|
||||
if direction not in {"horizontal", "vertical"}:
|
||||
raise ValueError(f"Unknown direction: {direction}")
|
||||
|
||||
@@ -3,7 +3,7 @@ import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_overlay
|
||||
from backend.data_types import DataField, datafield_to_uint8, encode_preview
|
||||
from backend.nodes.helpers import _parse_mask_strokes, _rasterize_mask
|
||||
from backend.nodes.helpers import _parse_mask_strokes, _rasterize_mask, bool_to_mask, mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Draw Mask")
|
||||
@@ -37,7 +37,7 @@ class DrawMask:
|
||||
strokes = _parse_mask_strokes(mask_paths)
|
||||
mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size)
|
||||
if invert:
|
||||
mask = np.where(mask > 127, np.uint8(0), np.uint8(255))
|
||||
mask = bool_to_mask(~mask_to_bool(mask))
|
||||
|
||||
emit_overlay({
|
||||
"kind": "mask_paint",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import bool_to_mask, mask_to_bool, emit_mask_preview
|
||||
|
||||
|
||||
@register_node(display_name="Mask Invert")
|
||||
@@ -29,10 +28,8 @@ class MaskInvert:
|
||||
DESCRIPTION = "Invert a binary mask — swap masked and unmasked regions."
|
||||
|
||||
def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple:
|
||||
out = np.where(mask > 127, np.uint8(0), np.uint8(255))
|
||||
out = bool_to_mask(~mask_to_bool(mask))
|
||||
|
||||
if field is not None:
|
||||
overlay = _mask_overlay(field, out)
|
||||
emit_preview(encode_preview(overlay))
|
||||
emit_mask_preview(field, out)
|
||||
|
||||
return (out,)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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, _mask_structure
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import _mask_structure, mask_to_bool, bool_to_mask, emit_mask_preview
|
||||
|
||||
|
||||
@register_node(display_name="Mask Morphology")
|
||||
@@ -45,7 +44,7 @@ class MaskMorphology:
|
||||
field: DataField | None = None) -> tuple:
|
||||
from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening
|
||||
|
||||
binary = mask > 127
|
||||
binary = mask_to_bool(mask)
|
||||
struct = _mask_structure(radius, shape)
|
||||
|
||||
if operation == "dilate":
|
||||
@@ -59,10 +58,8 @@ class MaskMorphology:
|
||||
else:
|
||||
raise ValueError(f"Unknown morphological operation: {operation}")
|
||||
|
||||
out = result.astype(np.uint8) * 255
|
||||
out = bool_to_mask(result)
|
||||
|
||||
if field is not None:
|
||||
overlay = _mask_overlay(field, out)
|
||||
emit_preview(encode_preview(overlay))
|
||||
emit_mask_preview(field, out)
|
||||
|
||||
return (out,)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||
|
||||
|
||||
_MASK_BOOLEAN_OPERATIONS = {
|
||||
@@ -53,14 +54,14 @@ class MaskOperations:
|
||||
mask_b: np.ndarray,
|
||||
operation: str,
|
||||
) -> tuple:
|
||||
a = mask_a > 127
|
||||
b = mask_b > 127
|
||||
a = mask_to_bool(mask_a)
|
||||
b = mask_to_bool(mask_b)
|
||||
|
||||
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
|
||||
out = bool_to_mask(result)
|
||||
|
||||
return (out,)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_preview, emit_overlay
|
||||
from backend.data_types import DataField, encode_preview, RecordTable
|
||||
from backend.nodes.helpers import _mask_overlay
|
||||
from backend.execution_context import emit_overlay
|
||||
from backend.data_types import DataField, RecordTable
|
||||
from backend.nodes.helpers import bool_to_mask, histogram_with_centers, emit_mask_preview
|
||||
|
||||
|
||||
@register_node(display_name="Threshold Mask")
|
||||
@@ -36,9 +36,7 @@ class ThresholdMask:
|
||||
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
||||
data = field.data
|
||||
|
||||
raw_counts, bin_edges = np.histogram(data.ravel(), bins=256)
|
||||
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
||||
counts = raw_counts.astype(np.float64)
|
||||
counts, bin_centers = histogram_with_centers(data)
|
||||
xmin = float(bin_centers[0]) if len(bin_centers) else 0.0
|
||||
xmax = float(bin_centers[-1]) if len(bin_centers) else 1.0
|
||||
|
||||
@@ -70,11 +68,11 @@ class ThresholdMask:
|
||||
})
|
||||
|
||||
if direction == "above":
|
||||
mask = (data >= t).astype(np.uint8) * 255
|
||||
mask = bool_to_mask(data >= t)
|
||||
else:
|
||||
mask = (data < t).astype(np.uint8) * 255
|
||||
mask = bool_to_mask(data < t)
|
||||
|
||||
emit_preview(encode_preview(_mask_overlay(field, mask)))
|
||||
emit_mask_preview(field, mask)
|
||||
|
||||
table = RecordTable([
|
||||
{"quantity": "threshold", "value": threshold, "unit": field.si_unit_xy},
|
||||
|
||||
@@ -36,7 +36,7 @@ class RotateField:
|
||||
expand_canvas: bool,
|
||||
) -> tuple:
|
||||
if field.overlays:
|
||||
self._send_warning("Rotate clears annotation/markup overlays!")
|
||||
emit_warning("Rotate clears annotation/markup overlays!")
|
||||
|
||||
angle = float(angle)
|
||||
order_map = {
|
||||
@@ -82,9 +82,6 @@ class RotateField:
|
||||
)
|
||||
return (result,)
|
||||
|
||||
def _send_warning(self, message: str):
|
||||
emit_warning(message)
|
||||
|
||||
@staticmethod
|
||||
def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]:
|
||||
if not expand_canvas:
|
||||
|
||||
@@ -101,7 +101,7 @@ class Save:
|
||||
else:
|
||||
raise ValueError(f"Save does not support input type: {type(value).__name__}")
|
||||
|
||||
self._send_warning(f"Saved to {path.name}")
|
||||
emit_warning(f"Saved to {path.name}")
|
||||
emit_file_download(str(path))
|
||||
return ()
|
||||
|
||||
@@ -373,6 +373,3 @@ class Save:
|
||||
lines.append(" endfacet")
|
||||
lines.append("endsolid tono")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
def _send_warning(self, message: str):
|
||||
emit_warning(message)
|
||||
|
||||
@@ -89,7 +89,7 @@ class SaveImage:
|
||||
else:
|
||||
self._save_npz(path, layers, layer_names)
|
||||
|
||||
self._send_warning(f"Saved {len(layers)} layer(s) to {path.name}")
|
||||
emit_warning(f"Saved {len(layers)} layer(s) to {path.name}")
|
||||
emit_file_download(str(path))
|
||||
return ()
|
||||
|
||||
@@ -181,6 +181,3 @@ class SaveImage:
|
||||
if isinstance(layer, np.ndarray):
|
||||
return np.asarray(layer)
|
||||
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
|
||||
|
||||
def _send_warning(self, message: str):
|
||||
emit_warning(message)
|
||||
|
||||
@@ -6,6 +6,7 @@ import numpy as np
|
||||
|
||||
from backend.data_types import DataField
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
def _mark_scars_one_sign(
|
||||
@@ -218,4 +219,4 @@ class ScarRemoval:
|
||||
)
|
||||
scar_mask = marks > 0.0
|
||||
corrected = _laplace_inpaint(np.asarray(field.data, dtype=np.float64), scar_mask)
|
||||
return (field.replace(data=corrected), scar_mask.astype(np.uint8) * 255)
|
||||
return (field.replace(data=corrected), bool_to_mask(scar_mask))
|
||||
|
||||
@@ -32,26 +32,20 @@ class SlopeDistribution:
|
||||
)
|
||||
|
||||
def process(self, field: DataField, distribution: str, n_bins: int) -> tuple:
|
||||
from scipy.ndimage import sobel
|
||||
|
||||
# Physical slopes in z_unit/xy_unit — matches Gwyddion's gwy_data_field_filter_sobel
|
||||
gx = sobel(field.data, axis=1) / (8.0 * field.dx)
|
||||
gy = sobel(field.data, axis=0) / (8.0 * field.dy)
|
||||
from backend.nodes.surface_common import physical_sobel_gradient, slope_unit as _slope_unit
|
||||
|
||||
gx, gy = physical_sobel_gradient(field)
|
||||
gx = gx.ravel()
|
||||
gy = gy.ravel()
|
||||
n = len(gx)
|
||||
|
||||
z = str(field.si_unit_z or "").strip()
|
||||
xy = str(field.si_unit_xy or "").strip()
|
||||
slope_unit = f"{z}/{xy}" if z and xy else (z or xy)
|
||||
su = _slope_unit(field)
|
||||
|
||||
if distribution == "phi":
|
||||
return self._phi(gx, gy, n_bins, slope_unit)
|
||||
return self._phi(gx, gy, n_bins, su)
|
||||
elif distribution == "theta":
|
||||
return self._theta(gx, gy, n_bins)
|
||||
elif distribution == "gradient":
|
||||
return self._gradient(gx, gy, n_bins, slope_unit)
|
||||
return self._gradient(gx, gy, n_bins, su)
|
||||
else:
|
||||
raise ValueError(f"Unknown distribution type: {distribution!r}. "
|
||||
f"Choose from: theta, phi, gradient")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.helpers import _square_unit
|
||||
|
||||
|
||||
def _level_data(data: np.ndarray, level: str) -> np.ndarray:
|
||||
@@ -78,15 +79,6 @@ def _inverse_unit(unit: str) -> str:
|
||||
return f"1/{text}"
|
||||
|
||||
|
||||
def _square_unit(unit: str) -> str:
|
||||
text = str(unit or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if text.isalnum() or text in {"m", "nm", "um", "pm", "V", "A", "Hz", "px"}:
|
||||
return f"{text}^2"
|
||||
return f"({text})^2"
|
||||
|
||||
|
||||
def _product_unit(*units: str) -> str:
|
||||
parts = [str(unit).strip() for unit in units if str(unit or "").strip()]
|
||||
return " ".join(parts)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@@ -15,6 +17,21 @@ def unit_dimension_key(unit: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def slope_unit(field: DataField) -> str:
|
||||
"""Return the physical slope unit string (z_unit/xy_unit)."""
|
||||
z = str(field.si_unit_z or "").strip()
|
||||
xy = str(field.si_unit_xy or "").strip()
|
||||
return f"{z}/{xy}" if z and xy else (z or xy)
|
||||
|
||||
|
||||
def physical_sobel_gradient(field: DataField) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Compute physical Sobel gradient (gx, gy) in z_unit/xy_unit."""
|
||||
from scipy.ndimage import sobel
|
||||
gx = sobel(field.data, axis=1) / (8.0 * field.dx)
|
||||
gy = sobel(field.data, axis=0) / (8.0 * field.dy)
|
||||
return gx, gy
|
||||
|
||||
|
||||
def require_compatible_xy_z_units(field: DataField, node_name: str) -> None:
|
||||
xy_key = unit_dimension_key(field.si_unit_xy)
|
||||
z_key = unit_dimension_key(field.si_unit_z)
|
||||
|
||||
@@ -4,6 +4,7 @@ import numpy as np
|
||||
|
||||
from backend.data_types import DataField
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import bool_to_mask
|
||||
|
||||
|
||||
@register_node(display_name="Template Match")
|
||||
@@ -46,7 +47,7 @@ class TemplateMatch:
|
||||
# Clip to [0, 1] for display (match_template returns values in [-1, 1])
|
||||
score_clipped = np.clip(score, 0.0, 1.0)
|
||||
|
||||
detections = (score_clipped >= float(threshold)).astype(np.uint8) * 255
|
||||
detections = bool_to_mask(score_clipped >= float(threshold))
|
||||
|
||||
score_field = image.replace(data=score_clipped)
|
||||
return (score_field, detections)
|
||||
|
||||
@@ -8,7 +8,7 @@ from scipy.ndimage import label
|
||||
from backend.execution_context import emit_preview
|
||||
from backend.data_types import DataField, encode_preview
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import _mask_overlay
|
||||
from backend.nodes.helpers import _mask_overlay, mask_to_bool, bool_to_mask
|
||||
|
||||
|
||||
def _working_height(field: DataField, invert_height: bool) -> np.ndarray:
|
||||
@@ -184,7 +184,7 @@ def _combine_masks(result_mask: np.ndarray, existing_mask: np.ndarray | None, co
|
||||
if existing_mask is None or combine_mode == "replace":
|
||||
return result_mask
|
||||
|
||||
existing = np.asarray(existing_mask) > 127
|
||||
existing = mask_to_bool(existing_mask)
|
||||
current = np.asarray(result_mask, dtype=bool)
|
||||
if existing.shape != current.shape:
|
||||
raise ValueError("Existing mask must have the same shape as the watershed output.")
|
||||
@@ -196,7 +196,7 @@ def _combine_masks(result_mask: np.ndarray, existing_mask: np.ndarray | None, co
|
||||
else:
|
||||
raise ValueError(f"Unsupported combine mode: {combine_mode}")
|
||||
|
||||
return merged.astype(np.uint8) * 255
|
||||
return bool_to_mask(merged)
|
||||
|
||||
|
||||
@register_node(display_name="Watershed Segmentation")
|
||||
@@ -262,7 +262,7 @@ class WatershedSegmentation:
|
||||
_watershed_step(watershed_field, water, labels, seeds, watershed_drop)
|
||||
|
||||
labels = _mark_boundaries(labels)
|
||||
result_mask = (labels > 0).astype(np.uint8) * 255
|
||||
result_mask = bool_to_mask(labels > 0)
|
||||
result_mask = _combine_masks(result_mask, mask, combine_mode)
|
||||
|
||||
emit_preview(encode_preview(_mask_overlay(field, result_mask)))
|
||||
|
||||
Reference in New Issue
Block a user