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
|
||||
|
||||
Reference in New Issue
Block a user