feature focus on 3d viewer, add copy/paste
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -25,6 +25,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
],
|
||||
"Output": [
|
||||
"PreviewImage",
|
||||
"Save",
|
||||
"SaveImage",
|
||||
"View3D",
|
||||
"PrintTable",
|
||||
|
||||
@@ -8,6 +8,7 @@ from backend.nodes import (
|
||||
coordinate_pair,
|
||||
number,
|
||||
range_slider,
|
||||
save,
|
||||
save_image,
|
||||
# Filters
|
||||
gaussian_filter,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
260
backend/nodes/save.py
Normal 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)
|
||||
@@ -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)."
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user