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

@@ -188,12 +188,18 @@ def test_datafield_tiff_data_round_trip():
assert arr.shape == field.data.shape
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"]
assert meta["xreal"] == field.xreal
assert meta["yreal"] == field.yreal
assert meta["si_unit_xy"] == "m"
assert meta["si_unit_z"] == "V"
assert meta["domain"] == "spatial"
assert meta["version"] == 1
assert len(meta["layers"]) == 1
layer0 = meta["layers"][0]
assert layer0["kind"] == "data_field"
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():
@@ -282,6 +288,268 @@ def test_datafield_hdf5_ergo_round_trip():
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():
"""The legacy TIFF format for DATA_FIELD must keep producing 8-bit RGB."""
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