feature focus on 3d viewer, add copy/paste

This commit is contained in:
2026-03-26 21:25:35 -07:00
parent de0b49acc5
commit 30671a5362
24 changed files with 1680 additions and 320 deletions

View File

@@ -1,18 +1 @@
from __future__ import annotations
import numpy as np
def _apply_numpy_compat_aliases() -> None:
"""Restore removed NumPy scalar aliases still used by some dependencies."""
aliases = {
"complex": complex,
"float": float,
"int": int,
}
for name, value in aliases.items():
if not hasattr(np, name):
setattr(np, name, value)
_apply_numpy_compat_aliases()

View File

@@ -67,6 +67,43 @@ class LineData:
return self.data[item]
@dataclass
class MeshModel:
vertices: np.ndarray
faces: np.ndarray
colors: np.ndarray | None = None
def __post_init__(self) -> None:
self.vertices = np.asarray(self.vertices, dtype=np.float32).reshape(-1, 3)
self.faces = np.asarray(self.faces, dtype=np.int32).reshape(-1, 3)
if self.colors is not None:
self.colors = np.asarray(self.colors, dtype=np.uint8).reshape(-1, 3)
if len(self.colors) != len(self.vertices):
raise ValueError("MeshModel.colors must have one RGB triplet per vertex.")
class ImageData(np.ndarray):
def __new__(cls, data: Any, metadata: dict[str, Any] | None = None):
obj = np.asarray(data).view(cls)
obj.metadata = deepcopy(metadata) if isinstance(metadata, dict) else {}
return obj
def __array_finalize__(self, obj):
self.metadata = deepcopy(getattr(obj, "metadata", {})) if obj is not None else {}
def copy_with_metadata(self, *, data: Any | None = None, metadata: dict[str, Any] | None = None) -> "ImageData":
base = np.asarray(self if data is None else data)
merged = deepcopy(self.metadata)
if isinstance(metadata, dict):
merged.update(deepcopy(metadata))
return ImageData(base, metadata=merged)
def image_metadata(image: Any) -> dict[str, Any]:
metadata = getattr(image, "metadata", None)
return deepcopy(metadata) if isinstance(metadata, dict) else {}
def _normalize_hex_color(color: Any, default: str = "#000000") -> str:
if isinstance(color, str):
text = color.strip()
@@ -638,10 +675,28 @@ def _sanitize_markup_shapes(shapes: Any) -> list[dict[str, Any]]:
return parsed
def _apply_annotation_overlay(
def _annotation_context_from_field(field: DataField, colormap: Any) -> dict[str, Any]:
legend_min, legend_mid, legend_max = _display_value_range(field)
return {
"xreal": float(field.xreal),
"si_unit_xy": str(field.si_unit_xy),
"legend_min": float(legend_min),
"legend_mid": float(legend_mid),
"legend_max": float(legend_max),
"legend_unit": str(field.si_unit_z),
"colormap": normalize_colormap_spec(colormap, fallback=field.colormap),
}
def _annotation_context_from_image(image: Any) -> dict[str, Any] | None:
metadata = image_metadata(image)
context = metadata.get("annotation_context")
return deepcopy(context) if isinstance(context, dict) else None
def _apply_annotation_overlay_from_context(
image: np.ndarray,
field: DataField,
colormap: Any,
context: dict[str, Any],
spec: dict[str, Any],
) -> np.ndarray:
from PIL import Image, ImageDraw
@@ -657,8 +712,7 @@ def _apply_annotation_overlay(
current = np.repeat(current[:, :, np.newaxis], 3, axis=2)
height, current_width = current.shape[:2]
field_width = max(1, int(field.xres))
legend_width = max(72, int(round(field_width * 0.18))) if show_color_map else 0
legend_width = max(72, int(round(current_width * 0.18))) if show_color_map else 0
canvas_width = current_width + legend_width
canvas = np.full((height, canvas_width, 3), 255, dtype=np.uint8)
canvas[:, :current_width] = current
@@ -667,20 +721,40 @@ def _apply_annotation_overlay(
draw = ImageDraw.Draw(pil_image)
base_font_px = max(6, int(round(text_size)))
if show_scale_bar and field_width > 0 and np.isfinite(field.xreal) and field.xreal > 0:
target_real = field.xreal / 5.0
xreal_raw = context.get("xreal")
xreal = float(xreal_raw) if xreal_raw is not None else 0.0
si_unit_xy = str(context.get("si_unit_xy", "") or "")
legend_unit = str(context.get("legend_unit", "") or "")
legend_min_raw = context.get("legend_min")
legend_mid_raw = context.get("legend_mid")
legend_max_raw = context.get("legend_max")
legend_min = float(legend_min_raw) if legend_min_raw is not None else 0.0
legend_mid = float(legend_mid_raw) if legend_mid_raw is not None else 0.0
legend_max = float(legend_max_raw) if legend_max_raw is not None else 0.0
colormap = normalize_colormap_spec(context.get("colormap", "gray"), fallback="gray")
has_scale_bar = np.isfinite(xreal) and xreal > 0 and bool(si_unit_xy)
has_color_legend = (
np.isfinite(legend_min)
and np.isfinite(legend_mid)
and np.isfinite(legend_max)
and bool(legend_unit)
)
if show_scale_bar and has_scale_bar and current_width > 0:
target_real = xreal / 5.0
bar_real = _nice_length(target_real)
if bar_real > 0 and np.isfinite(field.dx) and field.dx > 0:
bar_px = max(1, int(round(bar_real / field.dx)))
margin_x = max(8, field_width // 24)
if bar_real > 0:
px_per_real = current_width / xreal
bar_px = max(1, int(round(bar_real * px_per_real)))
margin_x = max(8, current_width // 24)
margin_y = max(8, height // 24)
bar_height = max(3, int(round(height * 0.012)))
bar_px = min(bar_px, max(1, field_width - 2 * margin_x))
bar_px = min(bar_px, max(1, current_width - 2 * margin_x))
x0 = margin_x
x1 = x0 + bar_px
y1 = height - margin_y
y0 = y1 - bar_height
text = _format_with_unit(bar_real, field.si_unit_xy)
text = _format_with_unit(bar_real, si_unit_xy)
text_image = _render_overlay_text(text, base_font_px, (255, 255, 255), font_spec=font_spec)
text_w, text_h = text_image.size
label_pad = 2
@@ -692,7 +766,7 @@ def _apply_annotation_overlay(
draw.rectangle((x0, y0, x1, y1), fill=(255, 255, 255))
pil_image.paste(text_image, (x0, bg_top + label_pad), text_image)
if show_color_map and legend_width > 0:
if show_color_map and has_color_legend and legend_width > 0:
panel_x0 = current_width
draw.rectangle((panel_x0, 0, canvas_width, height), fill=(245, 245, 245))
grad_x0 = panel_x0 + max(8, legend_width // 7)
@@ -706,7 +780,6 @@ def _apply_annotation_overlay(
pil_image.paste(Image.fromarray(gradient_rgb), (grad_x0, grad_y0))
draw.rectangle((grad_x0, grad_y0, grad_x0 + grad_w, grad_y1), outline=(40, 40, 40), width=1)
legend_min, legend_mid, legend_max = _display_value_range(field)
labels = [
(legend_max, grad_y0),
(legend_mid, grad_y0 + grad_h // 2),
@@ -715,7 +788,7 @@ def _apply_annotation_overlay(
text_x = grad_x0 + grad_w + 8
for value, y_center in labels:
text_image = _render_overlay_text(
_format_with_unit(value, field.si_unit_z),
_format_with_unit(value, legend_unit),
base_font_px,
(20, 20, 20),
font_spec=font_spec,
@@ -727,7 +800,20 @@ def _apply_annotation_overlay(
return np.asarray(pil_image, dtype=np.uint8)
def _apply_markup_overlay(image: np.ndarray, field: DataField, spec: dict[str, Any]) -> np.ndarray:
def _apply_annotation_overlay(
image: np.ndarray,
field: DataField,
colormap: Any,
spec: dict[str, Any],
) -> np.ndarray:
return _apply_annotation_overlay_from_context(
image,
_annotation_context_from_field(field, colormap),
spec,
)
def _apply_markup_overlay(image: np.ndarray, field: DataField | None, spec: dict[str, Any]) -> np.ndarray:
from PIL import Image, ImageDraw
current = np.asarray(image, dtype=np.uint8)
@@ -736,8 +822,8 @@ def _apply_markup_overlay(image: np.ndarray, field: DataField, spec: dict[str, A
pil_image = Image.fromarray(current.copy())
draw = ImageDraw.Draw(pil_image)
field_width = max(1, int(field.xres))
field_height = max(1, int(field.yres))
field_width = max(1, int(field.xres)) if isinstance(field, DataField) else max(1, current.shape[1])
field_height = max(1, int(field.yres)) if isinstance(field, DataField) else max(1, current.shape[0])
for shape in _sanitize_markup_shapes(spec.get("shapes")):
x1 = float(shape["x1"]) * field_width

View File

@@ -221,6 +221,7 @@ class ExecutionEngine:
from backend.nodes.preview_image import PreviewImage
from backend.nodes.print_table import PrintTable
from backend.nodes.view_3d import View3D
from backend.nodes.annotations import Annotations
from backend.nodes.value_display import ValueDisplay
from backend.nodes.markup import Markup
from backend.nodes.cross_section import CrossSection
@@ -234,6 +235,7 @@ class ExecutionEngine:
from backend.nodes.mask_invert import MaskInvert
from backend.nodes.mask_combine import MaskCombine
from backend.nodes.draw_mask import DrawMask
from backend.nodes.save import Save
from backend.nodes.save_image import SaveImage
from backend.nodes.image import Image
from backend.nodes.image_demo import ImageDemo
@@ -245,6 +247,7 @@ class ExecutionEngine:
MaskCombine._broadcast_fn = on_preview
DrawMask._broadcast_overlay_fn = on_overlay
View3D._broadcast_mesh_fn = on_mesh
Annotations._broadcast_warning_fn = on_warning
PrintTable._broadcast_table_fn = on_table
ValueDisplay._broadcast_value_fn = on_value
Stats._broadcast_value_fn = on_value
@@ -256,6 +259,7 @@ class ExecutionEngine:
Markup._broadcast_overlay_fn = on_overlay
Image._broadcast_warning_fn = on_warning
ImageDemo._broadcast_warning_fn = on_warning
Save._broadcast_warning_fn = on_warning
SaveImage._broadcast_warning_fn = on_warning
def _set_node_id_on_display(self, cls: type, node_id: str) -> None:
@@ -263,6 +267,7 @@ class ExecutionEngine:
from backend.nodes.preview_image import PreviewImage
from backend.nodes.print_table import PrintTable
from backend.nodes.view_3d import View3D
from backend.nodes.annotations import Annotations
from backend.nodes.value_display import ValueDisplay
from backend.nodes.markup import Markup
from backend.nodes.cross_section import CrossSection
@@ -278,10 +283,11 @@ class ExecutionEngine:
from backend.nodes.draw_mask import DrawMask
from backend.nodes.image import Image
from backend.nodes.image_demo import ImageDemo
from backend.nodes.save import Save
from backend.nodes.save_image import SaveImage
if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, Stats, Histogram, CrossSection, Cursors, CropResizeField, RotateField, Markup,
if cls in (PreviewImage, PrintTable, View3D, Annotations, ValueDisplay, Stats, Histogram, CrossSection, Cursors, CropResizeField, RotateField, Markup,
ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask,
Image, ImageDemo, SaveImage):
Image, ImageDemo, Save, SaveImage):
cls._current_node_id = node_id
def _auto_preview(
@@ -331,6 +337,16 @@ class ExecutionEngine:
on_preview(node_id, encode_preview(arr))
return
if type_name == "ANNOTATION_SOURCE" and on_preview:
if isinstance(value, DataField):
arr = render_datafield_preview(value, value.colormap)
on_preview(node_id, encode_preview(arr))
return
if isinstance(value, np.ndarray):
arr = image_to_uint8(value)
on_preview(node_id, encode_preview(arr))
return
if type_name == "LINE" and isinstance(value, (np.ndarray, LineData)) and on_preview:
preview = self._render_line_preview(cls, slot, result)
if preview:

View File

@@ -25,6 +25,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
],
"Output": [
"PreviewImage",
"Save",
"SaveImage",
"View3D",
"PrintTable",

View File

@@ -8,6 +8,7 @@ from backend.nodes import (
coordinate_pair,
number,
range_slider,
save,
save_image,
# Filters
gaussian_filter,

View File

@@ -1,16 +1,28 @@
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import COLORMAPS, DataField, normalize_font_spec, resolve_colormap_input
from backend.data_types import (
COLORMAPS,
DataField,
ImageData,
_apply_annotation_overlay_from_context,
_annotation_context_from_image,
image_to_uint8,
normalize_font_spec,
resolve_colormap_input,
)
@register_node(display_name="Annotations")
class Annotations:
_broadcast_warning_fn = None
_current_node_id: str = ""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"input": ("ANNOTATION_SOURCE", {"label": "Input"}),
"colormap": (["auto"] + list(COLORMAPS), {"hide_when_input_connected": "colormap_map"}),
"show_scale_bar": ("BOOLEAN", {"default": True}),
"show_color_map": ("BOOLEAN", {"default": True}),
@@ -27,18 +39,18 @@ class Annotations:
},
}
RETURN_TYPES = ("DATA_FIELD",)
RETURN_NAMES = ("annotated",)
RETURN_TYPES = ("ANNOTATION_SOURCE",)
RETURN_NAMES = ("Output",)
FUNCTION = "render"
DESCRIPTION = (
"Attach optional publication-style annotations to a DATA_FIELD without flattening the raw data. "
"The preview shows a scale bar and/or side colour legend, while downstream field operations keep the underlying AFM values."
"Attach optional publication-style annotations to a DATA_FIELD without flattening the raw data, "
"or annotate an IMAGE that carries viewport metadata from View3D."
)
def render(
self,
field: DataField,
input,
colormap: str,
show_scale_bar: bool,
show_color_map: bool,
@@ -46,24 +58,69 @@ class Annotations:
colormap_map=None,
font=None,
) -> tuple:
annotation_spec = {
"kind": "annotation",
"show_scale_bar": bool(show_scale_bar),
"show_color_map": bool(show_color_map),
"text_size": float(np.clip(text_size, 6.0, 96.0)) if np.isfinite(text_size) else 14.0,
"font": normalize_font_spec(font),
}
if isinstance(input, DataField):
resolved_colormap = resolve_colormap_input(
colormap,
colormap_input=colormap_map,
inherited=input.colormap,
default="gray",
)
out = input.replace(
colormap=resolved_colormap,
overlays=[
*input.overlays,
annotation_spec,
],
)
return (out,)
context = _annotation_context_from_image(input)
if context is None:
self._send_warning(
"Annotations image input has no scale metadata, so scale bar and color-map legend cannot be added."
)
return (ImageData(image_to_uint8(input)),)
resolved_colormap = resolve_colormap_input(
colormap,
colormap_input=colormap_map,
inherited=field.colormap,
inherited=context.get("colormap"),
default="gray",
)
text_size = float(np.clip(text_size, 6.0, 96.0)) if np.isfinite(text_size) else 14.0
out = field.replace(
colormap=resolved_colormap,
overlays=[
*field.overlays,
{
"kind": "annotation",
"show_scale_bar": bool(show_scale_bar),
"show_color_map": bool(show_color_map),
"text_size": text_size,
"font": normalize_font_spec(font),
},
],
context["colormap"] = resolved_colormap
missing_features = []
xreal = context.get("xreal")
if bool(show_scale_bar) and not (isinstance(xreal, (int, float)) and np.isfinite(float(xreal)) and float(xreal) > 0 and str(context.get("si_unit_xy", "")).strip()):
missing_features.append("scale bar")
if bool(show_color_map):
legend_values = (context.get("legend_min"), context.get("legend_mid"), context.get("legend_max"))
has_legend_values = all(
isinstance(value, (int, float)) and np.isfinite(float(value))
for value in legend_values
)
if not (has_legend_values and str(context.get("legend_unit", "")).strip()):
missing_features.append("color-map legend")
if missing_features:
self._send_warning(
f"Annotations image input is missing metadata for: {', '.join(missing_features)}."
)
annotated = _apply_annotation_overlay_from_context(
image_to_uint8(input),
context,
annotation_spec,
)
return (out,)
return (ImageData(annotated, metadata={"annotation_context": context}),)
def _send_warning(self, message: str):
fn = Annotations._broadcast_warning_fn
nid = Annotations._current_node_id
if fn and nid:
fn(nid, message)

View File

@@ -1,4 +1,5 @@
from __future__ import annotations
from functools import lru_cache
import numpy as np
from pathlib import Path
@@ -48,17 +49,21 @@ class Image:
ext = path_obj.suffix.lower()
resolved_colormap = resolve_colormap_input(colormap, colormap_input=colormap_map, default="viridis")
stat = path_obj.stat()
cached_fields = Image._load_fields_cached(
str(path_obj.resolve()),
int(stat.st_mtime_ns),
int(stat.st_size),
)
fields = tuple(field.copy() for field in cached_fields)
if ext in _SPM_EXTENSIONS:
fields = self._load_spm_all(path_obj, ext)
for f in fields:
f.colormap = resolved_colormap
return tuple(fields)
for field in fields:
field.colormap = resolved_colormap
field = self._load_image_or_array(path_obj, ext)
field.colormap = resolved_colormap
self._send_warning("Uncalibrated data — no physical dimensions.")
return (field,)
if ext not in _SPM_EXTENSIONS:
self._send_warning("Uncalibrated data — no physical dimensions.")
return fields
def _send_warning(self, message: str):
fn = Image._broadcast_warning_fn
@@ -66,17 +71,28 @@ class Image:
if fn and nid:
fn(nid, message)
def _load_spm_all(self, path: Path, ext: str) -> list[DataField]:
@staticmethod
@lru_cache(maxsize=32)
def _load_fields_cached(path_str: str, mtime_ns: int, size_bytes: int) -> tuple[DataField, ...]:
path = Path(path_str)
ext = path.suffix.lower()
if ext in _SPM_EXTENSIONS:
return tuple(Image._load_spm_all(path, ext))
return (Image._load_image_or_array(path, ext),)
@staticmethod
def _load_spm_all(path: Path, ext: str) -> list[DataField]:
if ext == ".gwy":
return self._load_gwy_all(path)
return Image._load_gwy_all(path)
elif ext == ".sxm":
return self._load_sxm_all(path)
return Image._load_sxm_all(path)
elif ext == ".ibw":
return self._load_ibw_all(path)
return Image._load_ibw_all(path)
else:
raise ValueError(f"Unsupported SPM format: {ext}")
def _load_gwy_all(self, path: Path) -> list[DataField]:
@staticmethod
def _load_gwy_all(path: Path) -> list[DataField]:
try:
import gwyfile
except ImportError:
@@ -101,7 +117,8 @@ class Image:
))
return fields
def _load_sxm_all(self, path: Path) -> list[DataField]:
@staticmethod
def _load_sxm_all(path: Path) -> list[DataField]:
try:
import nanonispy as nap
except ImportError:
@@ -130,7 +147,8 @@ class Image:
))
return fields
def _load_ibw_all(self, path: Path) -> list[DataField]:
@staticmethod
def _load_ibw_all(path: Path) -> list[DataField]:
try:
from igor.binarywave import load as load_ibw
except ImportError:
@@ -193,7 +211,8 @@ class Image:
return fields
def _load_image_or_array(self, path: Path, ext: str) -> DataField:
@staticmethod
def _load_image_or_array(path: Path, ext: str) -> DataField:
if ext == ".npy":
arr = np.load(str(path)).astype(np.float64)
elif ext == ".npz":

View File

@@ -1,6 +1,14 @@
from __future__ import annotations
from backend.node_registry import register_node
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.data_types import (
DataField,
ImageData,
_apply_markup_overlay,
encode_preview,
image_metadata,
image_to_uint8,
render_datafield_preview,
)
from backend.nodes.helpers import _parse_markup_shapes, _normalize_markup_color
@@ -12,7 +20,7 @@ class Markup:
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"input": ("ANNOTATION_SOURCE", {"label": "Input"}),
"shape": (["line", "rectangle", "circle", "arrow"], {"default": "line"}),
"stroke_color": ("STRING", {"default": "#ffd54f", "color_picker": True}),
"stroke_width": ("INT", {"default": 3, "min": 1, "max": 64, "step": 1}),
@@ -21,13 +29,13 @@ class Markup:
}
}
RETURN_TYPES = ("DATA_FIELD",)
RETURN_NAMES = ("annotated",)
RETURN_TYPES = ("ANNOTATION_SOURCE",)
RETURN_NAMES = ("Output",)
FUNCTION = "process"
DESCRIPTION = (
"Draw simple vector markup over a DATA_FIELD without flattening the underlying data. "
"Choose a shape mode, colour, and stroke width, then drag directly on the preview to place lines, rectangles, circles, or arrows."
"Draw simple vector markup over a DATA_FIELD without flattening the underlying data, "
"or rasterize markup directly onto an IMAGE."
)
_broadcast_overlay_fn = None
@@ -35,22 +43,32 @@ class Markup:
def process(
self,
field: DataField,
input,
shape: str,
stroke_color: str,
stroke_width: int,
markup_shapes: str,
) -> tuple:
shapes = _parse_markup_shapes(markup_shapes)
out = field.replace(
overlays=[
*field.overlays,
{
"kind": "markup",
"shapes": shapes,
},
],
)
markup_spec = {
"kind": "markup",
"shapes": shapes,
}
if isinstance(input, DataField):
out = input.replace(
overlays=[
*input.overlays,
markup_spec,
],
)
preview_base = render_datafield_preview(input, input.colormap)
else:
preview_base = image_to_uint8(input)
out = ImageData(
_apply_markup_overlay(preview_base, None, markup_spec),
metadata=image_metadata(input),
)
if Markup._broadcast_overlay_fn is not None:
Markup._broadcast_overlay_fn(
@@ -58,7 +76,7 @@ class Markup:
{
"kind": "markup",
"section_title": "Markup",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"image": encode_preview(preview_base),
"shape": str(shape),
"stroke_color": _normalize_markup_color(stroke_color),
"stroke_width": max(1, int(stroke_width)),

260
backend/nodes/save.py Normal file
View File

@@ -0,0 +1,260 @@
from __future__ import annotations
import csv
import json
from pathlib import Path
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8
@register_node(display_name="Save")
class Save:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"filename": ("STRING", {
"default": "",
"placeholder": "filename",
"placement": "top",
}),
"directory_path": ("FOLDER_PICKER", {
"default": "",
"label": "directory",
"placement": "top",
"hide_when_input_connected": "directory",
"top_socket_input": "directory",
}),
"value": ("SAVE_VALUE", {"label": "value"}),
"format": ("STRING", {
"default": "TIFF",
"choices_by_source_type": {
"DATA_FIELD": ["TIFF", "PNG", "NPZ"],
"IMAGE": ["PNG", "TIFF", "NPZ"],
"LINE": ["CSV", "NPZ", "JSON"],
"MEASURE_TABLE": ["CSV", "JSON"],
"RECORD_TABLE": ["CSV", "JSON"],
"FLOAT": ["TXT", "JSON"],
"MESH_MODEL": ["OBJ", "STL"],
},
"source_type_input": "value",
}),
},
"optional": {
"directory": ("DIRECTORY", {"label": "directory"}),
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
MANUAL_TRIGGER = True
DESCRIPTION = (
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes."
)
_broadcast_warning_fn = None
_current_node_id = None
def save(
self,
filename: str,
directory_path: str,
format: str,
value,
directory: str | None = None,
):
path = self._resolve_save_path(filename, format, directory, directory_path)
if isinstance(value, MeshModel):
self._save_mesh(path, value, format)
elif isinstance(value, DataField):
self._save_datafield(path, value, format)
elif isinstance(value, np.ndarray):
if value.ndim == 1:
self._save_line(path, LineData(data=value), format)
else:
self._save_image_or_array(path, value, format)
elif isinstance(value, LineData):
self._save_line(path, value, format)
elif isinstance(value, list):
self._save_table(path, value, format)
elif isinstance(value, (int, float, np.floating, np.integer)):
self._save_scalar(path, float(value), format)
else:
raise ValueError(f"Save does not support input type: {type(value).__name__}")
self._send_warning(f"Saved to {path.name}")
return ()
def _resolve_save_path(
self,
filename: str,
format_name: str,
directory: str | None,
directory_path: str = "",
) -> Path:
ext_map = {
"PNG": ".png",
"TIFF": ".tiff",
"NPZ": ".npz",
"CSV": ".csv",
"JSON": ".json",
"OBJ": ".obj",
"STL": ".stl",
"TXT": ".txt",
}
ext = ext_map[format_name]
raw_filename = str(filename).strip() if filename is not None else ""
raw_directory = str(directory).strip() if directory is not None else ""
if not raw_directory:
raw_directory = str(directory_path).strip() if directory_path is not None else ""
if not raw_filename:
raise ValueError("No output filename selected — enter a file name.")
if raw_directory:
dir_path = Path(raw_directory).expanduser()
if dir_path.exists() and not dir_path.is_dir():
raise ValueError("Directory input expects a folder path, not a file path.")
if not dir_path.exists():
if dir_path.suffix:
raise ValueError("Directory input expects a folder path, not a file path.")
dir_path.mkdir(parents=True, exist_ok=True)
path = dir_path / Path(raw_filename).name
else:
path = Path(raw_filename).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
if path.suffix.lower() != ext:
path = path.with_suffix(ext)
return path
def _save_datafield(self, path: Path, field: DataField, format_name: str):
if format_name == "TIFF":
import tifffile
tifffile.imwrite(str(path), np.asarray(field.data, dtype=np.float32))
return
if format_name == "NPZ":
np.savez(str(path), field=np.asarray(field.data))
return
if format_name == "PNG":
from PIL import Image
Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path))
return
raise ValueError(f"Format {format_name} is not supported for DATA_FIELD.")
def _save_image_or_array(self, path: Path, image: np.ndarray, format_name: str):
arr = np.asarray(image)
if format_name == "PNG":
from PIL import Image
Image.fromarray(image_to_uint8(arr)).save(str(path))
return
if format_name == "TIFF":
import tifffile
tifffile.imwrite(str(path), image_to_uint8(arr))
return
if format_name == "NPZ":
np.savez(str(path), image=arr)
return
raise ValueError(f"Format {format_name} is not supported for IMAGE.")
def _save_line(self, path: Path, line: LineData, format_name: str):
y = np.asarray(line.data, dtype=np.float64).ravel()
x = np.asarray(line.x_axis, dtype=np.float64).ravel()[: len(y)] if line.x_axis is not None else np.arange(len(y), dtype=np.float64)
if format_name == "CSV":
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["x", "y", "x_unit", "y_unit"])
for xv, yv in zip(x, y):
writer.writerow([xv, yv, line.x_unit, line.y_unit])
return
if format_name == "NPZ":
np.savez(str(path), x=x, y=y)
return
if format_name == "JSON":
path.write_text(json.dumps({
"x": x.tolist(),
"y": y.tolist(),
"x_unit": line.x_unit,
"y_unit": line.y_unit,
}, indent=2), encoding="utf-8")
return
raise ValueError(f"Format {format_name} is not supported for LINE.")
def _save_table(self, path: Path, rows: list, format_name: str):
if format_name == "JSON":
path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
return
if format_name == "CSV":
columns: list[str] = []
for row in rows:
if isinstance(row, dict):
for key in row.keys():
if key not in columns:
columns.append(str(key))
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=columns)
writer.writeheader()
for row in rows:
writer.writerow(row if isinstance(row, dict) else {"value": row})
return
raise ValueError(f"Format {format_name} is not supported for table inputs.")
def _save_scalar(self, path: Path, value: float, format_name: str):
if format_name == "TXT":
path.write_text(f"{value}\n", encoding="utf-8")
return
if format_name == "JSON":
path.write_text(json.dumps({"value": value}, indent=2), encoding="utf-8")
return
raise ValueError(f"Format {format_name} is not supported for scalar values.")
def _save_mesh(self, path: Path, mesh: MeshModel, format_name: str):
if format_name == "OBJ":
self._save_obj(path, mesh)
return
if format_name == "STL":
self._save_stl(path, mesh)
return
raise ValueError(f"Format {format_name} is not supported for MESH_MODEL.")
def _save_obj(self, path: Path, mesh: MeshModel):
lines = []
for vertex in mesh.vertices:
lines.append(f"v {vertex[0]} {vertex[1]} {vertex[2]}")
for face in mesh.faces:
lines.append(f"f {int(face[0]) + 1} {int(face[1]) + 1} {int(face[2]) + 1}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _save_stl(self, path: Path, mesh: MeshModel):
def normal(a, b, c):
n = np.cross(b - a, c - a)
length = float(np.linalg.norm(n))
return n / length if length > 0 else np.array([0.0, 1.0, 0.0], dtype=np.float32)
lines = ["solid argonode"]
vertices = np.asarray(mesh.vertices, dtype=np.float32)
for face in np.asarray(mesh.faces, dtype=np.int32):
a, b, c = vertices[int(face[0])], vertices[int(face[1])], vertices[int(face[2])]
n = normal(a, b, c)
lines.append(f" facet normal {n[0]} {n[1]} {n[2]}")
lines.append(" outer loop")
lines.append(f" vertex {a[0]} {a[1]} {a[2]}")
lines.append(f" vertex {b[0]} {b[1]} {b[2]}")
lines.append(f" vertex {c[0]} {c[1]} {c[2]}")
lines.append(" endloop")
lines.append(" endfacet")
lines.append("endsolid argonode")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _send_warning(self, message: str):
fn = Save._broadcast_warning_fn
nid = Save._current_node_id
if fn and nid:
fn(nid, message)

View File

@@ -49,11 +49,11 @@ class SaveImage:
OUTPUT_NODE = True
MANUAL_TRIGGER = True
DESCRIPTION = (
"Save one or more layers to a single file. "
"Save one or more image/field layers to a single file. "
"Each layer input accepts either a DATA_FIELD or an IMAGE, including annotated images. "
"Optionally drive the output directory from a folder/path node, while keeping the filename widget for the file name. "
"A new slot appears as each one is filled, with a matching per-layer name field. "
"TIFF writes multi-page data and stores layer names as page descriptions; "
"Use this for composing multi-channel stacks. TIFF writes multi-page data and stores layer names as page descriptions; "
"NPZ writes named arrays using those layer names as keys. "
"Click Save to write (does not auto-run)."
)

View File

@@ -1,37 +1,119 @@
from __future__ import annotations
import base64
import io
import numpy as np
from backend.node_registry import register_node
from backend.data_types import (
COLORMAPS,
DataField,
ImageData,
MeshModel,
_annotation_context_from_field,
colormap_to_uint8,
normalize_for_colormap,
resolve_colormap_input,
)
def _darken_colors(colors: np.ndarray, factor: float) -> np.ndarray:
return np.clip(np.rint(colors.astype(np.float32) * factor), 0, 255).astype(np.uint8)
def _grid_triangle_indices(nx: int, ny: int, *, reverse: bool = False) -> list[list[int]]:
faces: list[list[int]] = []
for iy in range(ny - 1):
for ix in range(nx - 1):
a = iy * nx + ix
b = a + 1
c = a + nx
d = c + 1
if reverse:
faces.append([a, b, c])
faces.append([b, d, c])
else:
faces.append([a, c, b])
faces.append([b, c, d])
return faces
def _build_mesh_model(z: np.ndarray, colors_u8: np.ndarray, z_scale: float, make_solid: bool) -> MeshModel:
ny, nx = z.shape
zmin = float(z.min())
zmax = float(z.max())
z_range = zmax - zmin if zmax != zmin else 1.0
top_vertices = np.empty((nx * ny, 3), dtype=np.float32)
top_colors = colors_u8.reshape(-1, 3).astype(np.uint8)
for iy in range(ny):
py = iy / max(ny - 1, 1) - 0.5
for ix in range(nx):
idx = iy * nx + ix
px = ix / max(nx - 1, 1) - 0.5
pz = ((float(z[iy, ix]) - zmin) / z_range - 0.5) * z_scale
top_vertices[idx] = (px, pz, py)
faces = _grid_triangle_indices(nx, ny)
if not make_solid:
return MeshModel(vertices=top_vertices, faces=np.asarray(faces, dtype=np.int32), colors=top_colors)
base_y = float(top_vertices[:, 1].min())
bottom_vertices = top_vertices.copy()
bottom_vertices[:, 1] = base_y
bottom_colors = _darken_colors(top_colors, 0.35)
vertices = np.vstack([top_vertices, bottom_vertices]).astype(np.float32)
colors = np.vstack([top_colors, bottom_colors]).astype(np.uint8)
bottom_offset = len(top_vertices)
faces.extend([[a + bottom_offset, b + bottom_offset, c + bottom_offset] for a, b, c in _grid_triangle_indices(nx, ny, reverse=True)])
def _add_wall(a: int, b: int):
faces.append([a, a + bottom_offset, b])
faces.append([b, a + bottom_offset, b + bottom_offset])
for ix in range(nx - 1):
_add_wall(ix, ix + 1)
top_row = (ny - 1) * nx
_add_wall(top_row + ix + 1, top_row + ix)
for iy in range(ny - 1):
_add_wall((iy + 1) * nx, iy * nx)
_add_wall(iy * nx + (nx - 1), (iy + 1) * nx + (nx - 1))
return MeshModel(vertices=vertices, faces=np.asarray(faces, dtype=np.int32), colors=colors)
@register_node(display_name="3D View")
class View3D:
_CUSTOM_PREVIEW = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"field": ("DATA_FIELD", {"label": "mesh"}),
"colormap": (["auto"] + list(COLORMAPS), {"hide_when_input_connected": "colormap_map"}),
"z_scale": ("FLOAT", {"default": 1, "min": 0.1, "max": 10.0, "step": 0.05}),
"resolution": ("INT", {"default": 128, "min": 32, "max": 512, "step": 16}),
"make_solid": ("BOOLEAN", {"default": False}),
"camera_azimuth": ("FLOAT", {"default": 0.0, "hidden": True}),
"camera_polar": ("FLOAT", {"default": 1.1, "hidden": True}),
"camera_distance": ("FLOAT", {"default": 1.8, "hidden": True}),
"viewport_snapshot": ("STRING", {"default": "", "hidden": True}),
},
"optional": {
"map_field": ("DATA_FIELD", {"label": "map"}),
"colormap_map": ("COLORMAP", {"label": "colormap"}),
},
}
RETURN_TYPES = ()
RETURN_TYPES = ("MESH_MODEL", "IMAGE")
RETURN_NAMES = ("mesh", "viewport")
FUNCTION = "render"
OUTPUT_NODE = True
DESCRIPTION = (
"Interactive 3D surface view of a DATA_FIELD. "
"Use the mesh input for geometry and optionally a second map input for coloring. "
"Drag to rotate, scroll to zoom. z_scale exaggerates height."
)
@@ -40,9 +122,12 @@ class View3D:
def render(
self, field: DataField,
colormap: str, z_scale: float, resolution: int, colormap_map=None,
colormap: str, z_scale: float, resolution: int, make_solid: bool = False,
camera_azimuth: float = 0.0, camera_polar: float = 1.1, camera_distance: float = 1.8,
viewport_snapshot: str = "",
map_field: DataField | None = None, colormap_map=None,
) -> tuple:
import base64
from scipy.ndimage import map_coordinates
data = field.data
yres, xres = data.shape
@@ -53,33 +138,75 @@ class View3D:
ny, nx = z.shape
zmin, zmax = float(z.min()), float(z.max())
color_field = map_field if map_field is not None else field
color_data = color_field.data
if color_field is field and color_data.shape == z.shape:
color_samples = z
elif color_field is field:
color_samples = color_data[::step_y, ::step_x].astype(np.float32)
else:
x_phys = np.linspace(field.xoff, field.xoff + field.xreal, nx, dtype=np.float64)
y_phys = np.linspace(field.yoff, field.yoff + field.yreal, ny, dtype=np.float64)
grid_y, grid_x = np.meshgrid(y_phys, x_phys, indexing="ij")
map_x = np.clip(
(grid_x - color_field.xoff) / max(color_field.xreal, 1e-12) * max(color_field.xres - 1, 0),
0.0,
max(color_field.xres - 1, 0),
)
map_y = np.clip(
(grid_y - color_field.yoff) / max(color_field.yreal, 1e-12) * max(color_field.yres - 1, 0),
0.0,
max(color_field.yres - 1, 0),
)
color_samples = map_coordinates(
color_data.astype(np.float64),
[map_y, map_x],
order=1,
mode="nearest",
).astype(np.float32)
z_norm = normalize_for_colormap(
z,
offset=field.display_offset,
scale=field.display_scale,
data_min=float(field.data.min()),
data_max=float(field.data.max()),
color_samples,
offset=color_field.display_offset,
scale=color_field.display_scale,
data_min=float(color_field.data.min()),
data_max=float(color_field.data.max()),
)
resolved_colormap = resolve_colormap_input(
colormap,
colormap_input=colormap_map,
inherited=field.colormap,
inherited=color_field.colormap,
default="gray",
)
colors_u8 = colormap_to_uint8(z_norm, resolved_colormap)
mesh_model = _build_mesh_model(z, colors_u8, float(z_scale * 0.1), bool(make_solid))
z_b64 = base64.b64encode(z.tobytes()).decode()
colors_b64 = base64.b64encode(colors_u8.tobytes()).decode()
positions_b64 = base64.b64encode(np.asarray(mesh_model.vertices, dtype=np.float32).tobytes()).decode()
indices_b64 = base64.b64encode(np.asarray(mesh_model.faces, dtype=np.uint32).tobytes()).decode()
mesh_colors_b64 = None
if mesh_model.colors is not None:
mesh_colors_b64 = base64.b64encode(np.asarray(mesh_model.colors, dtype=np.uint8).tobytes()).decode()
mesh_data = {
"width": nx,
"height": ny,
"z_data": z_b64,
"colors": colors_b64,
"positions": positions_b64,
"indices": indices_b64,
"vertex_colors": mesh_colors_b64,
"z_min": zmin,
"z_max": zmax,
"z_scale": float(z_scale * 0.1),
"make_solid": bool(make_solid),
"camera_azimuth": float(camera_azimuth),
"camera_polar": float(camera_polar),
"camera_distance": float(camera_distance),
"x_range": [float(field.xoff), float(field.xoff + field.xreal)],
"y_range": [float(field.yoff), float(field.yoff + field.yreal)],
}
@@ -87,4 +214,32 @@ class View3D:
if View3D._broadcast_mesh_fn is not None:
View3D._broadcast_mesh_fn(View3D._current_node_id, mesh_data)
return ()
annotation_context = _annotation_context_from_field(color_field, resolved_colormap)
annotation_context["xreal"] = float(field.xreal)
annotation_context["si_unit_xy"] = str(field.si_unit_xy)
viewport_image = ImageData(
self._decode_viewport_snapshot(viewport_snapshot),
metadata={
"annotation_context": annotation_context,
"viewport_camera": {
"azimuth": float(camera_azimuth),
"polar": float(camera_polar),
"distance": float(camera_distance),
},
},
)
return (mesh_model, viewport_image)
def _decode_viewport_snapshot(self, snapshot: str) -> np.ndarray:
text = str(snapshot or "").strip()
if not text.startswith("data:image/"):
return np.zeros((1, 1, 3), dtype=np.uint8)
try:
header, payload = text.split(",", 1)
raw = base64.b64decode(payload)
from PIL import Image
image = Image.open(io.BytesIO(raw)).convert("RGB")
return np.asarray(image, dtype=np.uint8)
except Exception:
return np.zeros((1, 1, 3), dtype=np.uint8)