combine save and save layers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user