combine save and save layers

This commit is contained in:
2026-04-05 14:12:34 -07:00
parent 08aff81f02
commit c38c2dc29a
8 changed files with 767 additions and 418 deletions

View File

@@ -1,34 +1,44 @@
""" """
Exporter for DATA_FIELD values. Exporter for DATA_FIELD values (single layer or multi-layer stacks).
Format choices: Format choices:
* **TIFF** — 8-bit RGB colormap preview. *Not* round-trippable. Useful for * **TIFF** — 8-bit RGB colormap preview. *Not* round-trippable and single-layer
figures and sharing; opening it back gives you pixels, not physics. only; connect multiple channels and pick "TIFF (data)" for a stack.
* **TIFF (data)** — float64 array with tono metadata JSON-embedded in the * **TIFF (data)** — float64 pixels with tono metadata JSON-embedded in the
TIFF ImageDescription tag. Round-trips via the array_image importer once TIFF ImageDescription tag. Round-trips and supports multi-page stacks: one
that importer learns to read the tag (see tests/node_tests/exporters.py). IFD per layer, the first page's description carries a ``{"tono": {...},
* **PNG** — 8-bit RGB colormap preview. Not round-trippable. "layers": [...]}`` document.
* **NPZ** — raw ``data`` array only. Not round-trippable (units are dropped). * **PNG** — 8-bit RGB colormap preview. Single-layer only.
* **GWY** — Gwyddion native format via the ``gwyfile`` package. Round-trips * **NPZ** — for a single layer, writes a plain ``field=...`` key. For a stack,
and opens directly in Gwyddion. Recommended for "save and come back later". each layer gets its own key derived from its display name (identifier-safe,
* **HDF5** — generic HDF5 with one ``/data`` dataset and physical dimensions deduplicated).
as dataset attrs. Round-trips via our generic ``hdf5`` importer. * **GWY** — Gwyddion native format via the ``gwyfile`` package. A multi-layer
* **HDF5 (Ergo)** — Asylum Research / Ergo layout with the dataset at save writes one channel per layer (``/0/data``, ``/1/data``, …), each with
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` and a sidecar group its own title, producing a true multi-channel .gwy file.
``Image/DataSetInfo/Global/Channels/<title>/ImageDims`` carrying * **HDF5** — generic HDF5 with one ``data`` dataset per layer and physical
``DimScaling`` / ``DimUnits`` / ``DataUnits``. Round-trips via our dimensions as dataset attrs. Round-trips via our generic ``hdf5`` importer,
``ergo_hdf5`` importer and opens in Asylum Ergo / Igor. which picks up every 2-D numeric dataset.
* **HDF5 (Ergo)** — Asylum Research / Ergo layout, one dataset per layer under
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` plus a matching sidecar
group ``Image/DataSetInfo/Global/Channels/<title>/ImageDims``. Round-trips
via our ``ergo_hdf5`` importer and opens in Ergo / Igor.
Mixed layer stacks (DataField + Image) are supported for TIFF (data) and NPZ
only; the physics-carrying formats (GWY, HDF5, HDF5 Ergo) require every layer
to be a DataField and raise a clear error otherwise.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any, Sequence
import numpy as np import numpy as np
from backend.data_types import DataField, datafield_to_uint8 from backend.data_types import DataField, datafield_to_uint8, image_to_uint8
from backend.exporters._base import FormatSpec from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("DATA_FIELD",) accepted_types: tuple[str, ...] = ("DATA_FIELD",)
@@ -43,173 +53,313 @@ FORMATS: dict[str, FormatSpec] = {
"HDF5 (Ergo)": FormatSpec(ext=".h5", round_trip=True, label="HDF5 (Asylum Research / Ergo)"), "HDF5 (Ergo)": FormatSpec(ext=".h5", round_trip=True, label="HDF5 (Asylum Research / Ergo)"),
} }
# Formats that only make sense for a single layer. When extra layers are
# connected, the Save node raises before we get here, but we keep the check
# defensive so the protocol is enforced at the exporter boundary too.
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"TIFF", "PNG"})
def save(
path: Path,
value: DataField,
format_name: str,
*,
extra_layers: Sequence[Any] | None = None,
layer_names: Sequence[str] | None = None,
**_opts,
) -> None:
extras = list(extra_layers or [])
layers: list[Any] = [value, *extras]
names = _resolve_layer_names(layers, layer_names, default_primary=path.stem or "field")
if extras and format_name in _SINGLE_LAYER_ONLY:
raise ValueError(
f"{format_name} only supports a single layer. Use 'TIFF (data)', "
f"'NPZ', 'GWY', or an HDF5 format for multi-layer saves."
)
def save(path: Path, value: DataField, format_name: str, **_opts) -> None:
if format_name == "TIFF": if format_name == "TIFF":
_save_tiff_preview(path, value) _save_tiff_preview(path, value)
return return
if format_name == "TIFF (data)": if format_name == "TIFF (data)":
_save_tiff_data(path, value) _save_tiff_data(path, layers, names)
return return
if format_name == "PNG": if format_name == "PNG":
_save_png_preview(path, value) _save_png_preview(path, value)
return return
if format_name == "NPZ": if format_name == "NPZ":
_save_npz(path, value) _save_npz(path, layers, names)
return return
if format_name == "GWY": if format_name == "GWY":
_save_gwy(path, value) _save_gwy(path, _require_all_datafields(layers, "GWY"), names)
return return
if format_name == "HDF5": if format_name == "HDF5":
_save_hdf5_generic(path, value) _save_hdf5_generic(path, _require_all_datafields(layers, "HDF5"), names)
return return
if format_name == "HDF5 (Ergo)": if format_name == "HDF5 (Ergo)":
_save_hdf5_ergo(path, value) _save_hdf5_ergo(path, _require_all_datafields(layers, "HDF5 (Ergo)"), names)
return return
raise ValueError(f"Format {format_name!r} is not supported for DATA_FIELD.") raise ValueError(f"Format {format_name!r} is not supported for DATA_FIELD.")
# ---------------------------------------------------------------------------
# Layer helpers
# ---------------------------------------------------------------------------
def _resolve_layer_names(
layers: Sequence[Any],
raw_names: Sequence[str] | None,
*,
default_primary: str,
) -> list[str]:
"""Fill in layer names, falling back to defaults for blank/missing entries.
The primary layer (index 0) defaults to ``default_primary`` (usually the
file stem), and each extra layer defaults to ``layer_N+1`` (1-indexed for
humans: "layer 2", "layer 3", …).
"""
raw_names = list(raw_names or [])
out: list[str] = []
for i in range(len(layers)):
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
if raw:
out.append(raw)
elif i == 0:
out.append(default_primary)
else:
out.append(f"layer_{i + 1}")
return out
def _require_all_datafields(layers: Sequence[Any], format_label: str) -> list[DataField]:
"""Return the list cast to DataFields, raising if any layer is not one."""
out: list[DataField] = []
for i, layer in enumerate(layers):
if not isinstance(layer, DataField):
raise ValueError(
f"{format_label} only supports DataField layers; layer {i + 1} "
f"is a {type(layer).__name__}. Use TIFF (data) or NPZ for mixed stacks."
)
out.append(layer)
return out
def _safe_identifier(name: str, index: int) -> str:
"""Turn a free-form layer name into a safe identifier (used as an NPZ key)."""
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
if not key:
key = f"layer_{index + 1}"
if key[0].isdigit():
key = f"layer_{key}"
return key
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
used: set[str] = set()
result: list[str] = []
for k in raw_keys:
candidate = k
suffix = 2
while candidate in used:
candidate = f"{k}_{suffix}"
suffix += 1
used.add(candidate)
result.append(candidate)
return result
def _layer_to_float_array(layer: Any) -> np.ndarray:
"""Coerce a layer into a float array for TIFF (data). Images are promoted."""
if isinstance(layer, DataField):
return np.ascontiguousarray(layer.data, dtype=np.float64)
if isinstance(layer, np.ndarray):
# Images are left as-is so multi-channel RGB pages survive the write.
return np.ascontiguousarray(layer)
raise ValueError(f"Unsupported layer type for TIFF (data): {type(layer).__name__}")
def _layer_to_npz_array(layer: Any) -> np.ndarray:
if isinstance(layer, DataField):
return np.asarray(layer.data)
if isinstance(layer, np.ndarray):
return np.asarray(layer)
raise ValueError(f"Unsupported layer type for NPZ: {type(layer).__name__}")
def _datafield_meta(field: DataField) -> dict:
"""Build the JSON-serializable physics metadata dict for a DataField."""
return {
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"xoff": float(field.xoff),
"yoff": float(field.yoff),
"si_unit_xy": str(field.si_unit_xy),
"si_unit_z": str(field.si_unit_z),
"domain": str(field.domain),
"colormap": field.colormap if isinstance(field.colormap, str) else "viridis",
}
# ---------------------------------------------------------------------------
# Per-format writers
# ---------------------------------------------------------------------------
def _save_tiff_preview(path: Path, field: DataField) -> None: def _save_tiff_preview(path: Path, field: DataField) -> None:
import tifffile import tifffile
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap)) tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
def _save_tiff_data(path: Path, field: DataField) -> None: def _save_tiff_data(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
"""Write the raw float64 data with tono metadata in the ImageDescription tag. """Write the raw pixels as a multi-page TIFF with tono metadata.
The description is a JSON document of shape ``{"tono": {...}}`` so future The ImageDescription tag on the first page carries a JSON document of
schema extensions can coexist with other tools' TIFF metadata. Only the shape ``{"tono": {"version": 1, "layers": [{...}, {...}]}}``. Each entry in
fields needed to reconstruct physical coordinates and z-scaling are ``layers`` gives the per-layer physics (xreal/yreal/xoff/yoff/units/domain)
embedded; display state (colormap, display_scale) is intentionally out of and its display name so a future multi-layer importer can reconstruct the
scope — this format is for data, not styling. whole stack. Non-DataField layers (plain images) get a minimal entry with
just the name and dtype — they're pixels, not physics.
""" """
import tifffile import tifffile
meta = { per_layer_meta: list[dict] = []
"tono": { for layer, layer_name in zip(layers, names):
"version": 1, if isinstance(layer, DataField):
"xreal": float(field.xreal), entry = {"name": layer_name, "kind": "data_field", **_datafield_meta(layer)}
"yreal": float(field.yreal), else:
"xoff": float(field.xoff), arr = np.asarray(layer)
"yoff": float(field.yoff), entry = {"name": layer_name, "kind": "image", "dtype": str(arr.dtype), "shape": list(arr.shape)}
"si_unit_xy": str(field.si_unit_xy), per_layer_meta.append(entry)
"si_unit_z": str(field.si_unit_z),
"domain": str(field.domain), description = json.dumps(
"colormap": field.colormap if isinstance(field.colormap, str) else "viridis", {"tono": {"version": 1, "layers": per_layer_meta}},
} separators=(",", ":"),
}
tifffile.imwrite(
str(path),
np.ascontiguousarray(field.data, dtype=np.float64),
description=json.dumps(meta, separators=(",", ":")),
) )
with tifffile.TiffWriter(str(path)) as tif:
for i, (layer, layer_name) in enumerate(zip(layers, names)):
arr = _layer_to_float_array(layer)
# Full metadata document lives on the first page; subsequent pages
# carry only their display name so readers that walk IFDs see
# something meaningful per channel.
page_desc = description if i == 0 else layer_name
tif.write(arr, description=page_desc)
def _save_png_preview(path: Path, field: DataField) -> None: def _save_png_preview(path: Path, field: DataField) -> None:
from PIL import Image from PIL import Image
Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path)) Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path))
def _save_npz(path: Path, field: DataField) -> None: def _save_npz(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
np.savez(str(path), field=np.asarray(field.data)) if len(layers) == 1:
# Single-layer: keep the historical `field` key so nothing that reads
# existing tono .npz outputs breaks.
np.savez(str(path), field=_layer_to_npz_array(layers[0]))
return
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
keys = _dedupe_keys(raw_keys)
arrays = {key: _layer_to_npz_array(layer) for key, layer in zip(keys, layers)}
np.savez(str(path), **arrays)
def _save_gwy(path: Path, field: DataField) -> None: def _save_gwy(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
"""Write a single-channel .gwy file via the gwyfile package.""" """Write an N-channel .gwy file via the gwyfile package."""
from gwyfile.objects import GwyContainer, GwyDataField, GwySIUnit from gwyfile.objects import GwyContainer, GwyDataField, GwySIUnit
# gwyfile's GwyDataField ctor expects the data array and physical extents. container_data: dict[str, Any] = {}
# si_unit_xy / si_unit_z accept a GwySIUnit wrapper with a .unitstr field. for i, (field, title) in enumerate(zip(fields, names)):
gwy_field = GwyDataField( gwy_field = GwyDataField(
np.ascontiguousarray(field.data, dtype=np.float64), np.ascontiguousarray(field.data, dtype=np.float64),
xreal=float(field.xreal), xreal=float(field.xreal),
yreal=float(field.yreal), yreal=float(field.yreal),
xoff=float(field.xoff), xoff=float(field.xoff),
yoff=float(field.yoff), yoff=float(field.yoff),
si_unit_xy=GwySIUnit(unitstr=str(field.si_unit_xy or "")), si_unit_xy=GwySIUnit(unitstr=str(field.si_unit_xy or "")),
si_unit_z=GwySIUnit(unitstr=str(field.si_unit_z or "")), si_unit_z=GwySIUnit(unitstr=str(field.si_unit_z or "")),
) )
title = path.stem or "field" container_data[f"/{i}/data"] = gwy_field
container = GwyContainer({ container_data[f"/{i}/data/title"] = title
"/0/data": gwy_field, GwyContainer(container_data).tofile(str(path))
"/0/data/title": title,
})
container.tofile(str(path))
def _save_hdf5_generic(path: Path, field: DataField) -> None: def _save_hdf5_generic(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
"""Write a single dataset ``/data`` with physical dimensions as dataset attrs. """Write one HDF5 dataset per layer with physical dims as dataset attrs.
The layout is the mirror of :mod:`backend.importers.hdf5`: any 2-D numeric Single-layer saves use ``/data`` for backward compatibility with the
dataset is picked up and its attrs (``xreal``, ``yreal``, ``xoff``, ``yoff``, tests that read the original layout; multi-layer saves use one
``si_unit_xy``, ``si_unit_z``) reconstruct the DataField. top-level dataset per channel, keyed by the safe-identifier form of its
name and deduplicated against collisions.
""" """
import h5py import h5py
arr = np.ascontiguousarray(field.data, dtype=np.float64)
with h5py.File(str(path), "w") as f: with h5py.File(str(path), "w") as f:
ds = f.create_dataset("data", data=arr) if len(fields) == 1:
ds.attrs["xreal"] = float(field.xreal) _write_hdf5_dataset(f, "data", fields[0])
ds.attrs["yreal"] = float(field.yreal) return
ds.attrs["xoff"] = float(field.xoff) raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
ds.attrs["yoff"] = float(field.yoff) keys = _dedupe_keys(raw_keys)
ds.attrs["si_unit_xy"] = str(field.si_unit_xy or "") for key, field in zip(keys, fields):
ds.attrs["si_unit_z"] = str(field.si_unit_z or "") _write_hdf5_dataset(f, key, field)
def _save_hdf5_ergo(path: Path, field: DataField) -> None: def _write_hdf5_dataset(h5file: Any, name: str, field: DataField) -> None:
"""Write an Asylum Research / Ergo-compatible HDF5 file. arr = np.ascontiguousarray(field.data, dtype=np.float64)
ds = h5file.create_dataset(name, data=arr)
ds.attrs["xreal"] = float(field.xreal)
ds.attrs["yreal"] = float(field.yreal)
ds.attrs["xoff"] = float(field.xoff)
ds.attrs["yoff"] = float(field.yoff)
ds.attrs["si_unit_xy"] = str(field.si_unit_xy or "")
ds.attrs["si_unit_z"] = str(field.si_unit_z or "")
The layout mirrors :mod:`backend.importers.ergo_hdf5`:
* The image dataset lives at def _save_hdf5_ergo(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` — the second-to-last """Write an Asylum Research / Ergo-compatible HDF5 file (N channels).
path component is the channel name that the importer keys off.
* A sidecar group at
``Image/DataSetInfo/Global/Channels/<title>/ImageDims`` carries
``DimScaling`` (a (2, 2) array of absolute physical ranges, Y-first),
``DimUnits`` (``[Y_unit, X_unit]``), and ``DataUnits`` (Z unit string).
This makes the file openable by Asylum Ergo / Igor and round-trippable Each channel gets its own dataset at
through our ergo_hdf5 importer. ``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` with a matching
sidecar group ``Image/DataSetInfo/Global/Channels/<title>/ImageDims``
carrying ``DimScaling`` / ``DimUnits`` / ``DataUnits``. The channel
names are the dedupe-safe form of each layer name. Opens in Ergo / Igor
and round-trips through :mod:`backend.importers.ergo_hdf5`.
""" """
import h5py import h5py
arr = np.ascontiguousarray(field.data, dtype=np.float64) raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
title = path.stem or "field" titles = _dedupe_keys(raw_keys)
x_start = float(field.xoff)
x_end = float(field.xoff) + float(field.xreal)
y_start = float(field.yoff)
y_end = float(field.yoff) + float(field.yreal)
# DimScaling is stored Y-first to match the importer's expectations
# (see ergo_hdf5.py:110-113).
dim_scaling = np.array(
[[y_start, y_end], [x_start, x_end]],
dtype=np.float64,
)
# DimUnits is [Y_unit, X_unit]; the importer takes the X (second) entry
# as the canonical lateral unit (see ergo_hdf5.py:129-135).
xy_unit = str(field.si_unit_xy or "m")
z_unit = str(field.si_unit_z or "")
dim_units = np.array([xy_unit, xy_unit], dtype=h5py.string_dtype())
with h5py.File(str(path), "w") as f: with h5py.File(str(path), "w") as f:
ds = f.create_dataset( for field, title in zip(fields, titles):
f"Image/DataSet/Resolution 0/Frame 0/{title}/Image", arr = np.ascontiguousarray(field.data, dtype=np.float64)
data=arr, ds = f.create_dataset(
) f"Image/DataSet/Resolution 0/Frame 0/{title}/Image",
# Also write the generic attrs so non-Ergo readers still see physics. data=arr,
ds.attrs["xreal"] = float(field.xreal) )
ds.attrs["yreal"] = float(field.yreal) ds.attrs["xreal"] = float(field.xreal)
ds.attrs["xoff"] = float(field.xoff) ds.attrs["yreal"] = float(field.yreal)
ds.attrs["yoff"] = float(field.yoff) ds.attrs["xoff"] = float(field.xoff)
ds.attrs["si_unit_xy"] = xy_unit ds.attrs["yoff"] = float(field.yoff)
ds.attrs["si_unit_z"] = z_unit xy_unit = str(field.si_unit_xy or "m")
z_unit = str(field.si_unit_z or "")
ds.attrs["si_unit_xy"] = xy_unit
ds.attrs["si_unit_z"] = z_unit
dims_grp = f.create_group( x_start = float(field.xoff)
f"Image/DataSetInfo/Global/Channels/{title}/ImageDims" x_end = float(field.xoff) + float(field.xreal)
) y_start = float(field.yoff)
dims_grp.attrs["DimScaling"] = dim_scaling y_end = float(field.yoff) + float(field.yreal)
dims_grp.attrs["DimUnits"] = dim_units # DimScaling is Y-first to match the importer (ergo_hdf5.py:110-113).
dims_grp.attrs["DataUnits"] = z_unit dim_scaling = np.array(
[[y_start, y_end], [x_start, x_end]],
dtype=np.float64,
)
dim_units = np.array([xy_unit, xy_unit], dtype=h5py.string_dtype())
dims_grp = f.create_group(
f"Image/DataSetInfo/Global/Channels/{title}/ImageDims"
)
dims_grp.attrs["DimScaling"] = dim_scaling
dims_grp.attrs["DimUnits"] = dim_units
dims_grp.attrs["DataUnits"] = z_unit

View File

@@ -5,11 +5,17 @@ Images are raw pixel arrays — no physical calibration by design — so none of
the formats here round-trip dimensions. PNG/TIFF convert to uint8 via the the formats here round-trip dimensions. PNG/TIFF convert to uint8 via the
same image_to_uint8 helper the preview pipeline uses; NPZ preserves the raw same image_to_uint8 helper the preview pipeline uses; NPZ preserves the raw
array. array.
Multi-layer stacks are supported for TIFF (multi-page uint8) and NPZ (one
named array per layer). PNG is single-layer only and raises if extra layers
are connected.
""" """
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
from typing import Any, Sequence
import numpy as np import numpy as np
@@ -24,18 +30,100 @@ FORMATS: dict[str, FormatSpec] = {
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"), "NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
} }
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"PNG"})
def save(
path: Path,
value: np.ndarray,
format_name: str,
*,
extra_layers: Sequence[Any] | None = None,
layer_names: Sequence[str] | None = None,
**_opts,
) -> None:
extras = list(extra_layers or [])
layers: list[Any] = [value, *extras]
names = _resolve_layer_names(len(layers), layer_names, default_primary=path.stem or "image")
if extras and format_name in _SINGLE_LAYER_ONLY:
raise ValueError(
f"{format_name} only supports a single layer. Use 'TIFF' or 'NPZ' "
f"for multi-layer image saves."
)
def save(path: Path, value: np.ndarray, format_name: str, **_opts) -> None:
arr = np.asarray(value)
if format_name == "PNG": if format_name == "PNG":
from PIL import Image from PIL import Image
Image.fromarray(image_to_uint8(arr)).save(str(path)) Image.fromarray(image_to_uint8(np.asarray(value))).save(str(path))
return return
if format_name == "TIFF": if format_name == "TIFF":
import tifffile _save_tiff(path, layers, names)
tifffile.imwrite(str(path), image_to_uint8(arr))
return return
if format_name == "NPZ": if format_name == "NPZ":
np.savez(str(path), image=arr) _save_npz(path, layers, names)
return return
raise ValueError(f"Format {format_name!r} is not supported for IMAGE.") raise ValueError(f"Format {format_name!r} is not supported for IMAGE.")
def _save_tiff(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
import tifffile
if len(layers) == 1:
tifffile.imwrite(str(path), image_to_uint8(np.asarray(layers[0])))
return
with tifffile.TiffWriter(str(path)) as tif:
for layer, layer_name in zip(layers, names):
tif.write(image_to_uint8(np.asarray(layer)), description=layer_name)
def _save_npz(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
if len(layers) == 1:
# Preserve the single-layer key used by the legacy test suite.
np.savez(str(path), image=np.asarray(layers[0]))
return
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
keys = _dedupe_keys(raw_keys)
arrays = {key: np.asarray(layer) for key, layer in zip(keys, layers)}
np.savez(str(path), **arrays)
def _resolve_layer_names(
count: int,
raw_names: Sequence[str] | None,
*,
default_primary: str,
) -> list[str]:
raw_names = list(raw_names or [])
out: list[str] = []
for i in range(count):
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
if raw:
out.append(raw)
elif i == 0:
out.append(default_primary)
else:
out.append(f"layer_{i + 1}")
return out
def _safe_identifier(name: str, index: int) -> str:
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
if not key:
key = f"layer_{index + 1}"
if key[0].isdigit():
key = f"layer_{key}"
return key
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
used: set[str] = set()
result: list[str] = []
for k in raw_keys:
candidate = k
suffix = 2
while candidate in used:
candidate = f"{k}_{suffix}"
suffix += 1
used.add(candidate)
result.append(candidate)
return result

View File

@@ -33,7 +33,6 @@ MENU_LAYOUT: dict[str, list[str]] = {
"ValueIO", "ValueIO",
"PrintTable", "PrintTable",
"Save", "Save",
"SaveImage",
"Shade", "Shade",
"PresentationOps", "PresentationOps",
], ],

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning, emit_file_download from backend.execution_context import emit_warning, emit_file_download
@@ -11,9 +12,15 @@ from backend.exporters import (
resolve_path, resolve_path,
type_name_for_value, type_name_for_value,
) )
from backend.nodes.helpers import _MAX_SAVE_FIELDS
DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads" DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads"
# Source types that expand into a layer stack (i.e., the Save node grows
# extra field_N inputs). Any other type (FLOAT, LINE, MESH, …) is a single
# value; no stacking UI is shown.
_STACKABLE_SOURCE_TYPES: tuple[str, ...] = ("DATA_FIELD", "IMAGE", "ANNOTATION_SOURCE")
def _choices_by_source_type() -> dict[str, list[str]]: def _choices_by_source_type() -> dict[str, list[str]]:
"""Build the format dropdown's source-type map from the exporter registry. """Build the format dropdown's source-type map from the exporter registry.
@@ -39,6 +46,43 @@ class Save:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
choices = _choices_by_source_type() choices = _choices_by_source_type()
optional: dict[str, Any] = {
"plot_title": ("STRING", {
"default": "",
"placeholder": "plot title (optional)",
"label": "title",
"show_when_source_type": {"value": ["LINE"]},
}),
# Name widget for the primary (value) layer. Only surfaces once
# the stack grows beyond one layer, so single-value saves stay
# clutter-free.
"primary_name": ("STRING", {
"default": "",
"placeholder": "name",
"show_when_input_visible": "field_0",
"inline_with_input": "value",
"hide_label": True,
}),
}
# Extra layer sockets for stackable source types. The frontend
# progressive-reveal block keys off `field_N` and only shows slot N
# once slot N-1 is connected; we further gate every slot on `value`
# being a stackable source type via `show_when_source_type`.
for i in range(_MAX_SAVE_FIELDS):
optional[f"field_{i}"] = ("DATA_FIELD", {
"label": f"layer {i + 2}", # primary is layer 1
"accepted_types": ["IMAGE", "ANNOTATION_SOURCE"],
"show_when_source_type": {"value": list(_STACKABLE_SOURCE_TYPES)},
})
optional[f"layer_name_{i}"] = ("STRING", {
"default": "",
"placeholder": "name",
"show_when_input_visible": f"field_{i}",
"inline_with_input": f"field_{i}",
"hide_label": True,
})
return { return {
"required": { "required": {
"filename": ("STRING", { "filename": ("STRING", {
@@ -64,14 +108,7 @@ class Save:
"source_type_input": "value", "source_type_input": "value",
}), }),
}, },
"optional": { "optional": optional,
"plot_title": ("STRING", {
"default": "",
"placeholder": "plot title (optional)",
"label": "title",
"show_when_source_type": {"value": ["LINE"]},
}),
},
} }
OUTPUTS = () OUTPUTS = ()
@@ -80,12 +117,18 @@ class Save:
OUTPUT_NODE = True OUTPUT_NODE = True
MANUAL_TRIGGER = True MANUAL_TRIGGER = True
DESCRIPTION = ( DESCRIPTION = (
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, " "Save one or more graph values to disk. A single value works for every type "
"and 3D meshes. Use 'GWY' or 'TIFF (data)' for DataFields you want to re-open later " "(fields, images, lines, tables, scalars, meshes). For DataFields and Images, "
"with their physical units preserved." "additional layer slots appear as you connect each one, letting you write "
"multi-channel TIFF, NPZ, GWY, or HDF5 stacks from a single node. "
"Use 'GWY' or 'TIFF (data)' when you need to re-open the result with its "
"physical units preserved."
) )
KEYWORDS = ("export", "write", "download", "png", "tiff", "csv", "json", "npz", "obj", "stl", "gwy") KEYWORDS = (
"export", "write", "download", "png", "tiff", "csv", "json", "npz",
"obj", "stl", "gwy", "hdf5", "layers", "stack", "channels",
)
def save( def save(
self, self,
@@ -93,12 +136,62 @@ class Save:
format: str, format: str,
value, value,
plot_title: str = "", plot_title: str = "",
primary_name: str = "",
**kwargs,
): ):
type_name = type_name_for_value(value) type_name = type_name_for_value(value)
module, spec = get_exporter(type_name, format) module, spec = get_exporter(type_name, format)
path = resolve_path(filename, spec, DOWNLOAD_DIR) path = resolve_path(filename, spec, DOWNLOAD_DIR)
module.save(path, value, format, plot_title=plot_title)
extra_layers, layer_names = self._collect_extra_layers(
type_name, primary_name, kwargs,
)
module.save(
path,
value,
format,
plot_title=plot_title,
extra_layers=extra_layers,
layer_names=layer_names,
)
emit_warning(f"Saved to {path.name}") emit_warning(f"Saved to {path.name}")
emit_file_download(str(path)) emit_file_download(str(path))
return () return ()
def _collect_extra_layers(
self,
type_name: str,
primary_name: str,
kwargs: dict[str, Any],
) -> tuple[list[Any], list[str]]:
"""Pull field_N + layer_name_N from kwargs into parallel lists.
Only applies when the primary value is a stackable source type; for
anything else (LINE, FLOAT, MESH_MODEL, tables) any stray field_N
kwargs are ignored — the frontend hides those sockets in that case
and the backend treats it as a single-value save.
"""
if type_name not in _STACKABLE_SOURCE_TYPES:
return [], []
extras: list[Any] = []
extra_names: list[str] = []
# Preserve the on-node order: iterate field_0, field_1, …, stopping at
# the first hole. An unconnected slot in the middle would be a UI bug,
# but bailing early keeps the saved stack matching what the user sees.
for i in range(_MAX_SAVE_FIELDS):
layer = kwargs.get(f"field_{i}")
if layer is None:
break
extras.append(layer)
extra_names.append(str(kwargs.get(f"layer_name_{i}", "") or "").strip())
if not extras:
return [], []
# Full names list starts with the primary's name (empty → exporter
# substitutes path.stem) and then each extra in order.
names = [str(primary_name or "").strip(), *extra_names]
return extras, names

View File

@@ -1,185 +0,0 @@
from __future__ import annotations
import re
import numpy as np
from pathlib import Path
from backend.node_registry import register_node
from backend.execution_context import emit_warning, emit_file_download
from backend.data_types import DataField, image_to_uint8
from backend.nodes.helpers import _MAX_SAVE_FIELDS
@register_node(display_name="Save Layers")
class SaveImage:
@classmethod
def INPUT_TYPES(cls):
optional = {
"directory": ("DIRECTORY", {"label": "directory"}),
}
for i in range(_MAX_SAVE_FIELDS):
optional[f"field_{i}"] = ("DATA_FIELD", {
"label": f"layer {i + 1}",
"accepted_types": ["IMAGE", "ANNOTATION_SOURCE"],
})
optional[f"layer_name_{i}"] = ("STRING", {
"default": "",
"placeholder": "name",
"show_when_input_visible": f"field_{i}",
"inline_with_input": f"field_{i}",
"hide_label": True,
})
return {
"required": {
"filename": ("STRING", {
"default": "",
"placeholder": "filename",
"placement": "top",
}),
"directory_path": ("STRING", {
"default": "",
"label": "directory",
"placeholder": "directory (optional, desktop only)",
"placement": "top",
"hide_when_input_connected": "directory",
"top_socket_input": "directory",
}),
"format": (["TIFF", "NPZ"],),
},
"optional": optional,
}
OUTPUTS = ()
FUNCTION = "save"
OUTPUT_NODE = True
MANUAL_TRIGGER = True
DESCRIPTION = (
"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. "
"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)."
)
KEYWORDS = ("export", "write", "multipage", "stack", "tiff", "npz", "channels")
def save(
self,
filename: str,
directory_path: str = "",
format: str = "TIFF",
directory: str | None = None,
**kwargs,
):
layers = []
layer_names = []
for i in range(_MAX_SAVE_FIELDS):
layer = kwargs.get(f"field_{i}")
if layer is not None:
layers.append(layer)
layer_names.append(self._resolve_layer_name(kwargs.get(f"layer_name_{i}"), i))
if not layers:
raise ValueError("No layers connected — connect at least one DATA_FIELD or IMAGE input.")
path = self._resolve_save_path(filename, format, directory, directory_path)
if format == "TIFF":
self._save_tiff(path, layers, layer_names)
else:
self._save_npz(path, layers, layer_names)
emit_warning(f"Saved {len(layers)} layer(s) to {path.name}")
emit_file_download(str(path))
return ()
def _save_tiff(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
import tifffile
with tifffile.TiffWriter(str(path)) as tif:
for layer, layer_name in zip(layers, layer_names):
tif.write(self._layer_array_for_tiff(layer), description=layer_name)
def _save_npz(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
arrays = {}
used_keys = set()
for i, (layer, layer_name) in enumerate(zip(layers, layer_names)):
arrays[self._unique_npz_key(layer_name, used_keys, i)] = self._layer_array_for_npz(layer)
np.savez(str(path), **arrays)
def _resolve_layer_name(self, raw_name: object, index: int) -> str:
text = str(raw_name).strip() if raw_name is not None else ""
return text or f"layer_{index}"
def _resolve_save_path(
self,
filename: str,
format: str,
directory: str | None,
directory_path: str = "",
) -> Path:
ext = ".tiff" if format == "TIFF" else ".npz"
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 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)
filename_part = Path(raw_filename).name if raw_filename else ""
if not filename_part:
raise ValueError("No output filename selected — enter a file name when using a directory input.")
path = dir_path / filename_part
else:
if not raw_filename:
raise ValueError("No output filename selected — enter a file name.")
candidate = Path(raw_filename).expanduser()
if candidate.is_absolute():
candidate.parent.mkdir(parents=True, exist_ok=True)
path = candidate
else:
from backend.nodes.save import DOWNLOAD_DIR
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
path = DOWNLOAD_DIR / candidate.name
if path.suffix.lower() != ext:
path = path.with_suffix(ext)
return path
def _unique_npz_key(self, raw_name: str, used_keys: set[str], index: int) -> str:
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(raw_name).strip()).strip("_")
if not key:
key = f"layer_{index}"
if key[0].isdigit():
key = f"layer_{key}"
candidate = key
suffix = 2
while candidate in used_keys:
candidate = f"{key}_{suffix}"
suffix += 1
used_keys.add(candidate)
return candidate
def _layer_array_for_tiff(self, layer: DataField | np.ndarray) -> np.ndarray:
if isinstance(layer, DataField):
return np.asarray(layer.data, dtype=np.float32)
if isinstance(layer, np.ndarray):
return image_to_uint8(layer)
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
def _layer_array_for_npz(self, layer: DataField | np.ndarray) -> np.ndarray:
if isinstance(layer, DataField):
return np.asarray(layer.data)
if isinstance(layer, np.ndarray):
return np.asarray(layer)
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")

View File

@@ -1110,11 +1110,24 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
for (const [name, spec] of Object.entries(optional)) { for (const [name, spec] of Object.entries(optional)) {
const [type, opts] = getSpecTypeAndOptions(spec as InputSpec); const [type, opts] = getSpecTypeAndOptions(spec as InputSpec);
if (isProgressive && isDataSocketSpec(spec as InputSpec)) { if (isProgressive && isDataSocketSpec(spec as InputSpec)) {
// Progressive: show this slot only if it's the first or the previous is connected // Progressive: show this slot only if it's the first or the previous
// is connected. If the socket also carries `show_when_source_type`,
// the gating input must be connected to a matching source type before
// any slot in the chain is revealed — this lets Save's layer stack
// stay hidden until `value` is a DataField or Image.
const match = name.match(/^field_(\d+)$/); const match = name.match(/^field_(\d+)$/);
if (match) { if (match) {
const idx = parseInt(match[1], 10); const idx = parseInt(match[1], 10);
if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) { const sourceTypeRules = opts?.show_when_source_type;
let sourceTypeOk = true;
if (sourceTypeRules && typeof sourceTypeRules === 'object') {
const gateInput = Object.keys(sourceTypeRules)[0];
const allowed = Array.isArray(sourceTypeRules[gateInput]) ? sourceTypeRules[gateInput] : [];
const actualSourceType = connectedSourceTypes?.[gateInput] ?? null;
sourceTypeOk = actualSourceType != null && allowed.includes(actualSourceType);
}
const progressiveOk = idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`));
if (sourceTypeOk && progressiveOk) {
dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) }); dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) });
visibleInputNames.add(name); visibleInputNames.add(name);
} }

View File

@@ -188,12 +188,18 @@ def test_datafield_tiff_data_round_trip():
assert arr.shape == field.data.shape assert arr.shape == field.data.shape
assert np.allclose(arr, field.data) assert np.allclose(arr, field.data)
# Per-layer metadata lives under tono.layers[*]; a single-layer save
# still produces the same shape, just with one entry.
meta = json.loads(desc)["tono"] meta = json.loads(desc)["tono"]
assert meta["xreal"] == field.xreal assert meta["version"] == 1
assert meta["yreal"] == field.yreal assert len(meta["layers"]) == 1
assert meta["si_unit_xy"] == "m" layer0 = meta["layers"][0]
assert meta["si_unit_z"] == "V" assert layer0["kind"] == "data_field"
assert meta["domain"] == "spatial" assert layer0["xreal"] == field.xreal
assert layer0["yreal"] == field.yreal
assert layer0["si_unit_xy"] == "m"
assert layer0["si_unit_z"] == "V"
assert layer0["domain"] == "spatial"
def test_datafield_hdf5_generic_round_trip(): def test_datafield_hdf5_generic_round_trip():
@@ -282,6 +288,268 @@ def test_datafield_hdf5_ergo_round_trip():
assert rf.si_unit_z == "N" assert rf.si_unit_z == "N"
def test_save_multi_layer_tiff_data():
"""TIFF (data) with extra layers writes multi-page float64 with per-layer metadata."""
import tifffile
from backend.nodes.save import Save
rng = np.random.default_rng(41)
primary = DataField(
data=rng.standard_normal((16, 20)).astype(np.float64) * 1e-9,
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="m",
)
layer2 = DataField(
data=rng.standard_normal((16, 20)).astype(np.float64) * 1e-12,
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="N",
)
layer3 = DataField(
data=rng.standard_normal((16, 20)).astype(np.float64),
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="V",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "stack"
Save().save(
filename=str(path),
format="TIFF (data)",
value=primary,
field_0=layer2,
field_1=layer3,
primary_name="height",
layer_name_0="force",
layer_name_1="potential",
)
out_path = path.with_suffix(".tiff")
assert out_path.exists()
with tifffile.TiffFile(out_path) as tif:
assert len(tif.pages) == 3
meta = json.loads(tif.pages[0].tags["ImageDescription"].value)["tono"]
assert len(meta["layers"]) == 3
assert [layer["name"] for layer in meta["layers"]] == ["height", "force", "potential"]
assert meta["layers"][1]["si_unit_z"] == "N"
assert meta["layers"][2]["si_unit_z"] == "V"
assert tif.pages[0].asarray().shape == (16, 20)
assert tif.pages[1].asarray().shape == (16, 20)
assert np.allclose(tif.pages[0].asarray(), primary.data)
assert np.allclose(tif.pages[2].asarray(), layer3.data)
def test_save_multi_layer_npz_named_keys():
"""Multi-layer NPZ uses safe-identifier keys from layer names."""
from backend.nodes.save import Save
rng = np.random.default_rng(47)
primary = DataField(data=rng.standard_normal((8, 8)).astype(np.float64))
layer2 = DataField(data=rng.standard_normal((8, 8)).astype(np.float64))
annotated = np.zeros((12, 12, 3), dtype=np.uint8)
annotated[..., 0] = 255
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "stack"
Save().save(
filename=str(path),
format="NPZ",
value=primary,
field_0=layer2,
field_1=annotated,
primary_name="height map",
layer_name_0="force-retrace",
layer_name_1="annotated overview",
)
out_path = path.with_suffix(".npz")
assert out_path.exists()
npz = np.load(out_path)
# Non-identifier characters collapse to underscores.
assert set(npz.files) == {"height_map", "force_retrace", "annotated_overview"}
assert np.allclose(npz["height_map"], primary.data)
assert np.allclose(npz["force_retrace"], layer2.data)
assert np.array_equal(npz["annotated_overview"], annotated)
def test_save_multi_layer_tiff_preview_rejected():
"""Single-layer-only formats must reject extra layers with a clear error."""
from backend.nodes.save import Save
field_a = DataField(data=np.zeros((4, 4)))
field_b = DataField(data=np.ones((4, 4)))
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "preview"
try:
Save().save(
filename=str(path),
format="TIFF", # preview format, single-layer only
value=field_a,
field_0=field_b,
)
assert False, "TIFF preview must reject extra layers"
except ValueError as exc:
assert "single layer" in str(exc).lower()
try:
Save().save(
filename=str(path),
format="PNG",
value=field_a,
field_0=field_b,
)
assert False, "PNG must reject extra layers"
except ValueError as exc:
assert "single layer" in str(exc).lower()
def test_save_multi_channel_gwy_round_trip():
"""A multi-channel GWY save round-trips via the gwy importer."""
from backend.importers import gwy as gwy_importer
from backend.nodes.save import Save
rng = np.random.default_rng(53)
primary = DataField(
data=rng.standard_normal((24, 32)).astype(np.float64) * 1e-9,
xreal=4e-6, yreal=3e-6, si_unit_xy="m", si_unit_z="m",
)
layer2 = DataField(
data=rng.standard_normal((24, 32)).astype(np.float64) * 1e-11,
xreal=4e-6, yreal=3e-6, si_unit_xy="m", si_unit_z="N",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(
filename=str(path),
format="GWY",
value=primary,
field_0=layer2,
primary_name="height",
layer_name_0="adhesion",
)
out_path = path.with_suffix(".gwy")
assert out_path.exists()
reloaded = gwy_importer.load(out_path)
assert len(reloaded) == 2
names = gwy_importer.channel_names(out_path)
assert set(names) == {"height", "adhesion"}
# GWY does not guarantee iteration order across channels, so match
# each input by content rather than by position.
assert any(np.allclose(f.data, primary.data) for f in reloaded)
assert any(np.allclose(f.data, layer2.data) for f in reloaded)
for f in reloaded:
assert np.isclose(f.xreal, 4e-6)
assert np.isclose(f.yreal, 3e-6)
def test_save_multi_channel_hdf5_round_trip():
"""Multi-channel generic HDF5 round-trips via the hdf5 importer."""
from backend.importers import hdf5 as hdf5_importer
from backend.nodes.save import Save
rng = np.random.default_rng(59)
primary = DataField(
data=rng.standard_normal((12, 18)).astype(np.float64) * 1e-7,
xreal=2e-6, yreal=1.5e-6, si_unit_xy="m", si_unit_z="V",
)
layer2 = DataField(
data=rng.standard_normal((12, 18)).astype(np.float64) * 1e-9,
xreal=2e-6, yreal=1.5e-6, si_unit_xy="m", si_unit_z="A",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "stack"
Save().save(
filename=str(path),
format="HDF5",
value=primary,
field_0=layer2,
primary_name="potential",
layer_name_0="current",
)
out_path = path.with_suffix(".h5")
assert out_path.exists()
reloaded = hdf5_importer.load(out_path)
assert len(reloaded) == 2
# Identify the two channels by their unique z-units.
by_unit = {rf.si_unit_z: rf for rf in reloaded}
assert set(by_unit.keys()) == {"V", "A"}
assert np.allclose(by_unit["V"].data, primary.data)
assert np.allclose(by_unit["A"].data, layer2.data)
def test_save_multi_channel_hdf5_ergo_round_trip():
"""Multi-channel Ergo-layout HDF5 round-trips via the ergo_hdf5 importer."""
from backend.importers import ergo_hdf5 as ergo_importer
from backend.nodes.save import Save
rng = np.random.default_rng(61)
primary = DataField(
data=rng.standard_normal((10, 14)).astype(np.float64) * 1e-9,
xreal=1.5e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m",
)
layer2 = DataField(
data=rng.standard_normal((10, 14)).astype(np.float64) * 1e-11,
xreal=1.5e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="N",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(
filename=str(path),
format="HDF5 (Ergo)",
value=primary,
field_0=layer2,
primary_name="height",
layer_name_0="adhesion",
)
out_path = path.with_suffix(".h5")
assert out_path.exists()
reloaded = ergo_importer.load(out_path)
assert len(reloaded) == 2
by_unit = {rf.si_unit_z: rf for rf in reloaded}
assert set(by_unit.keys()) == {"m", "N"}
assert np.allclose(by_unit["m"].data, primary.data)
assert np.allclose(by_unit["N"].data, layer2.data)
def test_save_gwy_rejects_image_layer():
"""GWY/HDF5 formats must error cleanly on non-DataField layers."""
from backend.nodes.save import Save
field = DataField(data=np.zeros((4, 4)))
image = np.zeros((4, 4, 3), dtype=np.uint8)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
try:
Save().save(
filename=str(path),
format="GWY",
value=field,
field_0=image,
)
assert False, "GWY must reject non-DataField layers"
except ValueError as exc:
assert "DataField" in str(exc) or "data field" in str(exc).lower()
def test_save_ignores_extra_layers_for_non_stackable_types():
"""Stray field_N kwargs must be ignored when value is a scalar/line/table."""
from backend.nodes.save import Save
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "scalar"
# field_0 is connected but should be silently ignored for a FLOAT value.
Save().save(
filename=str(path),
format="TXT",
value=1.25,
field_0=DataField(data=np.zeros((4, 4))),
)
assert Path(tmpdir, "scalar.txt").read_text(encoding="utf-8").strip() == "1.25"
def test_tiff_preview_is_still_rgb_uint8(): def test_tiff_preview_is_still_rgb_uint8():
"""The legacy TIFF format for DATA_FIELD must keep producing 8-bit RGB.""" """The legacy TIFF format for DATA_FIELD must keep producing 8-bit RGB."""
import tifffile import tifffile

View File

@@ -1,77 +0,0 @@
import os
import tempfile
import numpy as np
import tifffile
from PIL import Image
from tests.node_tests._shared import make_field
def test_save_image():
from backend.nodes.save_layers import SaveImage
node = SaveImage()
input_types = SaveImage.INPUT_TYPES()
field_spec = input_types["optional"]["field_0"]
assert field_spec[0] == "DATA_FIELD"
assert field_spec[1]["accepted_types"] == ["IMAGE", "ANNOTATION_SOURCE"]
field_a = make_field(data=np.random.default_rng(4).random((32, 32)))
field_b = make_field(data=np.random.default_rng(5).random((32, 32)))
annotated = np.zeros((24, 24, 3), dtype=np.uint8)
annotated[..., 0] = 255
with tempfile.TemporaryDirectory() as tmpdir:
tiff_path = os.path.join(tmpdir, "out.tiff")
node.save(filename=tiff_path, format="TIFF", field_0=field_a)
assert os.path.exists(tiff_path)
im = Image.open(tiff_path)
assert im.n_frames == 1
assert np.array(im).shape == (32, 32)
tiff_path2 = os.path.join(tmpdir, "multi.tiff")
node.save(filename=tiff_path2, format="TIFF", field_0=field_a, field_1=field_b)
im2 = Image.open(tiff_path2)
assert im2.n_frames == 2
annotated_tiff = os.path.join(tmpdir, "annotated.tiff")
node.save(filename=annotated_tiff, format="TIFF", field_0=annotated, layer_name_0="annotated overview")
with tifffile.TiffFile(annotated_tiff) as tif:
assert len(tif.pages) == 1
assert tif.pages[0].description == "annotated overview"
assert tif.pages[0].asarray().shape == annotated.shape
npz_path = os.path.join(tmpdir, "out.npz")
node.save(filename=npz_path, format="NPZ", field_0=field_a, field_1=annotated, layer_name_0="height map", layer_name_1="annotated-overview")
assert os.path.exists(npz_path)
npz = np.load(npz_path)
assert len(npz.files) == 2
assert np.allclose(npz["height_map"], field_a.data)
assert np.array_equal(npz["annotated_overview"], annotated)
wrong_ext = os.path.join(tmpdir, "output.png")
node.save(filename=wrong_ext, format="TIFF", field_0=field_a)
assert os.path.exists(os.path.join(tmpdir, "output.tiff"))
driven_dir = os.path.join(tmpdir, "nested-output")
node.save(filename="driven_name", directory=driven_dir, format="NPZ", field_0=field_a)
assert os.path.exists(os.path.join(driven_dir, "driven_name.npz"))
try:
node.save(filename="bad", directory=os.path.join(tmpdir, "looks_like_file.txt"), format="TIFF", field_0=field_a)
assert False, "Should have raised ValueError for file-like directory path"
except ValueError:
pass
try:
node.save(filename=os.path.join(tmpdir, "empty.tiff"), format="TIFF")
assert False, "Should have raised ValueError"
except ValueError:
pass
try:
node.save(filename="", format="TIFF", field_0=field_a)
assert False, "Should have raised ValueError"
except ValueError:
pass