dimensioned export (gwy, HDF5)

This commit is contained in:
2026-04-05 13:28:26 -07:00
parent 0f9b500c34
commit 08aff81f02
11 changed files with 1121 additions and 313 deletions

View File

@@ -0,0 +1,128 @@
"""
Exporter registry.
Each module in this package exports a tuple of tono type names it can handle
(`accepted_types`), a FORMATS map of format name → FormatSpec, and a `save()`
function. This registry walks those modules and builds lookup tables the
Save node uses to dispatch.
Usage::
from backend.exporters import get_exporter, resolve_path, type_name_for_value
type_name = type_name_for_value(value) # e.g. "DATA_FIELD"
exporter, spec = get_exporter(type_name, "GWY") # raises on unknown combo
path = resolve_path(filename, spec)
exporter.save(path, value, "GWY")
"""
from __future__ import annotations
from pathlib import Path
from types import ModuleType
from typing import Any
import numpy as np
from backend.data_types import (
DataField,
DataTable,
ImageData,
LineData,
MeshModel,
RecordTable,
)
from backend.exporters import datafield, image, line, mesh, scalar, table
from backend.exporters._base import Exporter, FormatSpec
_EXPORTER_MODULES: list[ModuleType] = [datafield, image, line, mesh, scalar, table]
# (type_name, format_name) → (module, FormatSpec)
_REGISTRY: dict[tuple[str, str], tuple[ModuleType, FormatSpec]] = {}
for _mod in _EXPORTER_MODULES:
for _type_name in _mod.accepted_types:
for _format_name, _spec in _mod.FORMATS.items():
_REGISTRY[(_type_name, _format_name)] = (_mod, _spec)
def get_exporter(type_name: str, format_name: str) -> tuple[ModuleType, FormatSpec]:
"""Return the (module, FormatSpec) for a type + format combination.
Raises ValueError with a user-readable message when the combination is
unknown. That message gets propagated straight to the UI status toast,
so keep it actionable.
"""
entry = _REGISTRY.get((type_name, format_name))
if entry is None:
raise ValueError(f"Format {format_name!r} is not supported for {type_name}.")
return entry
def available_formats(type_name: str) -> list[str]:
"""Format names available for a given tono type, in registration order."""
return [fmt for (t, fmt) in _REGISTRY if t == type_name]
def type_name_for_value(value: Any) -> str:
"""Classify a runtime Python value into a tono type name.
The ordering matters: ImageData is a subclass of ndarray, and RecordTable /
DataTable are subclasses of list, so check the more specific classes first.
"""
if isinstance(value, MeshModel):
return "MESH_MODEL"
if isinstance(value, DataField):
return "DATA_FIELD"
if isinstance(value, LineData):
return "LINE"
if isinstance(value, ImageData):
# Annotation outputs carry context in ``.metadata``; regardless, image
# formats are the right set.
return "IMAGE"
if isinstance(value, np.ndarray):
if value.ndim == 1:
return "LINE"
return "IMAGE"
if isinstance(value, RecordTable):
return "RECORD_TABLE"
if isinstance(value, DataTable):
return "DATA_TABLE"
if isinstance(value, list):
# Plain list — treat as a data table; the table exporter handles both.
return "DATA_TABLE"
if isinstance(value, (int, float, np.floating, np.integer)):
return "FLOAT"
raise ValueError(f"Save does not support input type: {type(value).__name__}")
def resolve_path(filename: str, spec: FormatSpec, default_dir: Path) -> Path:
"""Expand *filename* into an absolute Path with the correct extension.
Relative names are written under *default_dir* (the session download dir);
absolute paths are honored as-is, with parent directories created.
"""
raw_filename = str(filename).strip() if filename is not None 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:
default_dir.mkdir(parents=True, exist_ok=True)
path = default_dir / candidate.name
if path.suffix.lower() != spec.ext:
path = path.with_suffix(spec.ext)
return path
__all__ = [
"Exporter",
"FormatSpec",
"available_formats",
"get_exporter",
"resolve_path",
"type_name_for_value",
]

View File

@@ -0,0 +1,60 @@
"""
Base protocol for file exporters.
Each exporter module handles one tono value type (DATA_FIELD, IMAGE, LINE, …)
and implements one or more output formats. Registration is discovered via the
module-level attributes declared below, so adding a new exporter is a matter
of dropping a new file in this package and importing it from __init__.
A single file per value type (rather than per format) keeps format choices
that share plumbing — PNG & TIFF previews for DATA_FIELD, CSV & JSON for
tables — co-located, which is where most of the shared logic lives.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Protocol, runtime_checkable
@dataclass(frozen=True)
class FormatSpec:
"""One output format supported by an exporter module."""
#: File extension (leading dot), e.g. ".tiff".
ext: str
#: True if the format preserves enough information to reload the value
#: via the matching importer. Advertised in the UI so users can tell
#: "save a preview" and "save for later" apart.
round_trip: bool
#: Short human-readable label. The enum key used in the format dropdown
#: is the dict key in each module's FORMATS map; `label` is what we'd
#: surface in tooltips or docs. Leave empty to fall back to the key.
label: str = ""
@runtime_checkable
class Exporter(Protocol):
"""Structural protocol satisfied by every module in backend.exporters."""
#: Tono type names this exporter handles. Must match the upper-case names
#: used in node INPUT_TYPES / OUTPUTS (e.g. "DATA_FIELD", "IMAGE", "LINE").
accepted_types: tuple[str, ...]
#: Format name → spec. Format names are what users pick in the Save node's
#: format dropdown, so they should be short and recognizable.
FORMATS: dict[str, FormatSpec]
def save(self, path: Path, value: Any, format_name: str, **opts: Any) -> None:
"""Write *value* to *path* in *format_name*.
The caller is responsible for ensuring ``path`` has the correct
extension (see registry.resolve_path) and that ``value`` is of a type
listed in ``accepted_types``.
"""
...
# Re-exported so modules can write `from backend.exporters._base import FormatSpec`.
__all__ = ["FormatSpec", "Exporter"]

View File

@@ -0,0 +1,215 @@
"""
Exporter for DATA_FIELD values.
Format choices:
* **TIFF** — 8-bit RGB colormap preview. *Not* round-trippable. Useful for
figures and sharing; opening it back gives you pixels, not physics.
* **TIFF (data)** — float64 array with tono metadata JSON-embedded in the
TIFF ImageDescription tag. Round-trips via the array_image importer once
that importer learns to read the tag (see tests/node_tests/exporters.py).
* **PNG** — 8-bit RGB colormap preview. Not round-trippable.
* **NPZ** — raw ``data`` array only. Not round-trippable (units are dropped).
* **GWY** — Gwyddion native format via the ``gwyfile`` package. Round-trips
and opens directly in Gwyddion. Recommended for "save and come back later".
* **HDF5** — generic HDF5 with one ``/data`` dataset and physical dimensions
as dataset attrs. Round-trips via our generic ``hdf5`` importer.
* **HDF5 (Ergo)** — Asylum Research / Ergo layout with the dataset at
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` and a sidecar group
``Image/DataSetInfo/Global/Channels/<title>/ImageDims`` carrying
``DimScaling`` / ``DimUnits`` / ``DataUnits``. Round-trips via our
``ergo_hdf5`` importer and opens in Asylum Ergo / Igor.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from backend.data_types import DataField, datafield_to_uint8
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("DATA_FIELD",)
FORMATS: dict[str, FormatSpec] = {
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF (preview)"),
"TIFF (data)": FormatSpec(ext=".tiff", round_trip=True, label="TIFF (calibrated data)"),
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG (preview)"),
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
"GWY": FormatSpec(ext=".gwy", round_trip=True, label="Gwyddion (.gwy)"),
"HDF5": FormatSpec(ext=".h5", round_trip=True, label="HDF5 (generic)"),
"HDF5 (Ergo)": FormatSpec(ext=".h5", round_trip=True, label="HDF5 (Asylum Research / Ergo)"),
}
def save(path: Path, value: DataField, format_name: str, **_opts) -> None:
if format_name == "TIFF":
_save_tiff_preview(path, value)
return
if format_name == "TIFF (data)":
_save_tiff_data(path, value)
return
if format_name == "PNG":
_save_png_preview(path, value)
return
if format_name == "NPZ":
_save_npz(path, value)
return
if format_name == "GWY":
_save_gwy(path, value)
return
if format_name == "HDF5":
_save_hdf5_generic(path, value)
return
if format_name == "HDF5 (Ergo)":
_save_hdf5_ergo(path, value)
return
raise ValueError(f"Format {format_name!r} is not supported for DATA_FIELD.")
def _save_tiff_preview(path: Path, field: DataField) -> None:
import tifffile
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
def _save_tiff_data(path: Path, field: DataField) -> None:
"""Write the raw float64 data with tono metadata in the ImageDescription tag.
The description is a JSON document of shape ``{"tono": {...}}`` so future
schema extensions can coexist with other tools' TIFF metadata. Only the
fields needed to reconstruct physical coordinates and z-scaling are
embedded; display state (colormap, display_scale) is intentionally out of
scope — this format is for data, not styling.
"""
import tifffile
meta = {
"tono": {
"version": 1,
"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",
}
}
tifffile.imwrite(
str(path),
np.ascontiguousarray(field.data, dtype=np.float64),
description=json.dumps(meta, separators=(",", ":")),
)
def _save_png_preview(path: Path, field: DataField) -> None:
from PIL import Image
Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path))
def _save_npz(path: Path, field: DataField) -> None:
np.savez(str(path), field=np.asarray(field.data))
def _save_gwy(path: Path, field: DataField) -> None:
"""Write a single-channel .gwy file via the gwyfile package."""
from gwyfile.objects import GwyContainer, GwyDataField, GwySIUnit
# gwyfile's GwyDataField ctor expects the data array and physical extents.
# si_unit_xy / si_unit_z accept a GwySIUnit wrapper with a .unitstr field.
gwy_field = GwyDataField(
np.ascontiguousarray(field.data, dtype=np.float64),
xreal=float(field.xreal),
yreal=float(field.yreal),
xoff=float(field.xoff),
yoff=float(field.yoff),
si_unit_xy=GwySIUnit(unitstr=str(field.si_unit_xy or "")),
si_unit_z=GwySIUnit(unitstr=str(field.si_unit_z or "")),
)
title = path.stem or "field"
container = GwyContainer({
"/0/data": gwy_field,
"/0/data/title": title,
})
container.tofile(str(path))
def _save_hdf5_generic(path: Path, field: DataField) -> None:
"""Write a single dataset ``/data`` with physical dimensions as dataset attrs.
The layout is the mirror of :mod:`backend.importers.hdf5`: any 2-D numeric
dataset is picked up and its attrs (``xreal``, ``yreal``, ``xoff``, ``yoff``,
``si_unit_xy``, ``si_unit_z``) reconstruct the DataField.
"""
import h5py
arr = np.ascontiguousarray(field.data, dtype=np.float64)
with h5py.File(str(path), "w") as f:
ds = f.create_dataset("data", 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 "")
def _save_hdf5_ergo(path: Path, field: DataField) -> None:
"""Write an Asylum Research / Ergo-compatible HDF5 file.
The layout mirrors :mod:`backend.importers.ergo_hdf5`:
* The image dataset lives at
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` — the second-to-last
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
through our ergo_hdf5 importer.
"""
import h5py
arr = np.ascontiguousarray(field.data, dtype=np.float64)
title = path.stem or "field"
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:
ds = f.create_dataset(
f"Image/DataSet/Resolution 0/Frame 0/{title}/Image",
data=arr,
)
# Also write the generic attrs so non-Ergo readers still see physics.
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"] = xy_unit
ds.attrs["si_unit_z"] = z_unit
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

@@ -0,0 +1,41 @@
"""
Exporter for IMAGE values (numpy arrays, ImageData annotation sources).
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
same image_to_uint8 helper the preview pipeline uses; NPZ preserves the raw
array.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from backend.data_types import image_to_uint8
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("IMAGE", "ANNOTATION_SOURCE")
FORMATS: dict[str, FormatSpec] = {
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG"),
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF"),
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
}
def save(path: Path, value: np.ndarray, format_name: str, **_opts) -> None:
arr = np.asarray(value)
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!r} is not supported for IMAGE.")

182
backend/exporters/line.py Normal file
View File

@@ -0,0 +1,182 @@
"""
Exporter for LINE values (1-D profiles as LineData or bare ndarrays).
PNG / TIFF render a plot image via Pillow; CSV / JSON / NPZ save the raw
(x, y, unit) arrays. The plot renderer is self-contained (no matplotlib
dependency) and handles SI-prefix axis labels.
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
import numpy as np
from backend.data_types import LineData, _PREFIXABLE_UNITS, _SI_PREFIXES
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("LINE",)
FORMATS: dict[str, FormatSpec] = {
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG plot"),
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF plot"),
"CSV": FormatSpec(ext=".csv", round_trip=True, label="CSV"),
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
}
def save(path: Path, value, format_name: str, *, plot_title: str = "", **_opts) -> None:
line = value if isinstance(value, LineData) else LineData(data=np.asarray(value).ravel())
y = np.asarray(line.data, dtype=np.float64).ravel()
if line.x_axis is not None:
x = np.asarray(line.x_axis, dtype=np.float64).ravel()[: len(y)]
else:
x = np.arange(len(y), dtype=np.float64)
if format_name in ("PNG", "TIFF"):
_save_line_plot(path, x, y, line.x_unit, line.y_unit, plot_title, format_name)
return
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!r} is not supported for LINE.")
def _save_line_plot(
path: Path,
x: np.ndarray,
y: np.ndarray,
x_unit: str,
y_unit: str,
title: str,
format_name: str,
) -> None:
"""Render a simple PNG/TIFF line plot with SI-prefixed axes.
Intentionally self-contained (Pillow only, no matplotlib) so that builds
stay lean. Layout is fixed 1200×750 with 5×5 grid and a single blue line.
"""
from PIL import Image, ImageDraw, ImageFont
w, h = 1200, 750
bg = (255, 255, 255)
line_color = (79, 142, 247) # #4f8ef7
grid_color = (200, 200, 200)
text_color = (60, 60, 60)
margin = {"left": 80, "right": 30, "top": 50, "bottom": 60}
img = Image.new("RGB", (w, h), bg)
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("DejaVuSans.ttf", 14)
font_small = ImageFont.truetype("DejaVuSans.ttf", 11)
font_title = ImageFont.truetype("DejaVuSans.ttf", 16)
except (OSError, IOError):
font = ImageFont.load_default()
font_small = font
font_title = font
pw = w - margin["left"] - margin["right"]
ph = h - margin["top"] - margin["bottom"]
def _si_scale(unit: str, vmin: float, vmax: float) -> tuple[float, str]:
"""Pick the best SI prefix for an axis range. Returns (divisor, prefixed_unit)."""
unit = (unit or "").strip()
if not unit or unit not in _PREFIXABLE_UNITS:
return 1.0, unit if unit else ""
peak = max(abs(vmin), abs(vmax))
if peak == 0:
return 1.0, unit
for scale, prefix in _SI_PREFIXES:
if peak / scale >= 1.0:
return scale, f"{prefix}{unit}"
return _SI_PREFIXES[-1][0], f"{_SI_PREFIXES[-1][1]}{unit}"
xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
x_scale, x_label = _si_scale(x_unit, xmin, xmax)
y_scale, y_label = _si_scale(y_unit, ymin, ymax)
if not x_label:
x_label = "x"
if not y_label:
y_label = "y"
x = x / x_scale
y = y / y_scale
xmin, xmax = xmin / x_scale, xmax / x_scale
ymin, ymax = ymin / y_scale, ymax / y_scale
if ymax == ymin:
ymin, ymax = ymin - 1, ymax + 1
if xmax == xmin:
xmax = xmin + 1
ypad = (ymax - ymin) * 0.05
ymin -= ypad
ymax += ypad
def to_px(xv: float, yv: float) -> tuple[float, float]:
px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw
py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph
return px, py
for i in range(6):
gy = ymin + (ymax - ymin) * i / 5
_, py = to_px(xmin, gy)
draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1)
label = f"{gy:.4g}"
draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm")
gx = xmin + (xmax - xmin) * i / 5
px, _ = to_px(gx, ymin)
draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1)
label = f"{gx:.4g}"
draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt")
n = len(y)
step = max(1, n // pw)
xs, ys = x[::step], y[::step]
pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))]
if len(pts) > 1:
draw.line(pts, fill=line_color, width=2)
draw.rectangle(
[margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph],
outline=(100, 100, 100), width=1,
)
draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb")
y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0))
y_draw = ImageDraw.Draw(y_label_img)
y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm")
y_label_img = y_label_img.rotate(90, expand=True)
img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img)
if title and title.strip():
draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt")
ext = ".png" if format_name == "PNG" else ".tiff"
img.save(str(path.with_suffix(ext)))

60
backend/exporters/mesh.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Exporter for MESH_MODEL values (Wavefront OBJ, ASCII STL).
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from backend.data_types import MeshModel
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("MESH_MODEL",)
FORMATS: dict[str, FormatSpec] = {
"OBJ": FormatSpec(ext=".obj", round_trip=True, label="Wavefront OBJ"),
"STL": FormatSpec(ext=".stl", round_trip=True, label="STL (ASCII)"),
}
def save(path: Path, value: MeshModel, format_name: str, **_opts) -> None:
if format_name == "OBJ":
_save_obj(path, value)
return
if format_name == "STL":
_save_stl(path, value)
return
raise ValueError(f"Format {format_name!r} is not supported for MESH_MODEL.")
def _save_obj(path: Path, mesh: MeshModel) -> None:
lines: list[str] = []
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(path: Path, mesh: MeshModel) -> None:
def normal(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> np.ndarray:
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 tono"]
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 tono")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")

View File

@@ -0,0 +1,28 @@
"""
Exporter for FLOAT scalars (also handles Python int and numpy scalar types).
"""
from __future__ import annotations
import json
from pathlib import Path
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("FLOAT",)
FORMATS: dict[str, FormatSpec] = {
"TXT": FormatSpec(ext=".txt", round_trip=True, label="Text"),
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
}
def save(path: Path, value: float, format_name: str, **_opts) -> None:
numeric = float(value)
if format_name == "TXT":
path.write_text(f"{numeric}\n", encoding="utf-8")
return
if format_name == "JSON":
path.write_text(json.dumps({"value": numeric}, indent=2), encoding="utf-8")
return
raise ValueError(f"Format {format_name!r} is not supported for scalar values.")

View File

@@ -0,0 +1,44 @@
"""
Exporter for RECORD_TABLE and DATA_TABLE values.
Both types are list-of-dict; the Save node currently accepts plain lists in
this slot too, which is preserved here. CSV auto-derives its column set from
the first row's keys (and any additional keys that appear later), matching
the prior behavior.
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("RECORD_TABLE", "DATA_TABLE")
FORMATS: dict[str, FormatSpec] = {
"CSV": FormatSpec(ext=".csv", round_trip=True, label="CSV"),
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
}
def save(path: Path, value: list, format_name: str, **_opts) -> None:
rows = list(value)
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!r} is not supported for table inputs.")

View File

@@ -20,19 +20,42 @@ def load(path: Path) -> list[DataField]:
fields = [] fields = []
for ch in channels.values(): for ch in channels.values():
data = np.array(ch.data, dtype=np.float64).reshape(ch.yres, ch.xres) # gwyfile.objects.GwyDataField exposes .data as an already-2D ndarray
# (no xres/yres attributes — those were removed in gwyfile 0.3+).
data = np.asarray(ch.data, dtype=np.float64)
if data.ndim != 2:
# Defensive: if a future gwyfile version yields a flat buffer, the
# dimensions live in the serialized object's xres/yres keys.
xres = int(ch.get("xres", data.size))
yres = int(ch.get("yres", 1))
data = data.reshape(yres, xres)
fields.append(DataField( fields.append(DataField(
data=data, data=data,
xreal=float(ch.xreal), xreal=float(ch.xreal),
yreal=float(ch.yreal), yreal=float(ch.yreal),
xoff=float(getattr(ch, "xoff", 0.0)), xoff=float(getattr(ch, "xoff", 0.0)),
yoff=float(getattr(ch, "yoff", 0.0)), yoff=float(getattr(ch, "yoff", 0.0)),
si_unit_xy="m", si_unit_xy=_unit_str(getattr(ch, "si_unit_xy", None)) or "m",
si_unit_z="m", si_unit_z=_unit_str(getattr(ch, "si_unit_z", None)) or "m",
)) ))
return fields return fields
def _unit_str(si_unit: object) -> str:
"""Extract the unit string from a GwySIUnit without importing gwyfile.
Loaded GwySIUnit objects behave like dicts with a ``unitstr`` key.
"""
if si_unit is None:
return ""
if hasattr(si_unit, "unitstr"):
return str(getattr(si_unit, "unitstr") or "")
try:
return str(si_unit["unitstr"] or "")
except (KeyError, TypeError):
return ""
def channel_names(path: Path) -> list[str]: def channel_names(path: Path) -> list[str]:
import gwyfile import gwyfile
try: try:

View File

@@ -1,26 +1,44 @@
from __future__ import annotations from __future__ import annotations
import csv
import json
from pathlib import Path
import numpy as np
import tempfile import tempfile
from pathlib import Path
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
from backend.data_types import ( from backend.exporters import (
DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8, available_formats,
_SI_PREFIXES, _PREFIXABLE_UNITS, get_exporter,
resolve_path,
type_name_for_value,
) )
DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads" DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads"
def _choices_by_source_type() -> dict[str, list[str]]:
"""Build the format dropdown's source-type map from the exporter registry.
Centralising this here means adding a new exporter module (or a new format
inside an existing one) automatically surfaces in the UI — no parallel
list to keep in sync.
"""
return {
"DATA_FIELD": available_formats("DATA_FIELD"),
"IMAGE": available_formats("IMAGE"),
"ANNOTATION_SOURCE": available_formats("ANNOTATION_SOURCE"),
"LINE": available_formats("LINE"),
"RECORD_TABLE": available_formats("RECORD_TABLE"),
"DATA_TABLE": available_formats("DATA_TABLE"),
"FLOAT": available_formats("FLOAT"),
"MESH_MODEL": available_formats("MESH_MODEL"),
}
@register_node(display_name="Save") @register_node(display_name="Save")
class Save: class Save:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
choices = _choices_by_source_type()
return { return {
"required": { "required": {
"filename": ("STRING", { "filename": ("STRING", {
@@ -41,17 +59,8 @@ class Save:
], ],
}), }),
"format": ("STRING", { "format": ("STRING", {
"default": "TIFF", "default": choices["DATA_FIELD"][0] if choices["DATA_FIELD"] else "",
"choices_by_source_type": { "choices_by_source_type": choices,
"DATA_FIELD": ["TIFF", "PNG", "NPZ"],
"IMAGE": ["PNG", "TIFF", "NPZ"],
"ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"],
"LINE": ["PNG", "TIFF", "CSV", "NPZ", "JSON"],
"RECORD_TABLE": ["CSV", "JSON"],
"DATA_TABLE": ["CSV", "JSON"],
"FLOAT": ["TXT", "JSON"],
"MESH_MODEL": ["OBJ", "STL"],
},
"source_type_input": "value", "source_type_input": "value",
}), }),
}, },
@@ -71,10 +80,12 @@ 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, and 3D meshes." "Save a single graph value to disk. Supports fields, images, lines, tables, scalars, "
"and 3D meshes. Use 'GWY' or 'TIFF (data)' for DataFields you want to re-open later "
"with their physical units preserved."
) )
KEYWORDS = ("export", "write", "download", "png", "tiff", "csv", "json", "npz", "obj", "stl") KEYWORDS = ("export", "write", "download", "png", "tiff", "csv", "json", "npz", "obj", "stl", "gwy")
def save( def save(
self, self,
@@ -83,295 +94,11 @@ class Save:
value, value,
plot_title: str = "", plot_title: str = "",
): ):
path = self._resolve_save_path(filename, format) type_name = type_name_for_value(value)
module, spec = get_exporter(type_name, format)
if isinstance(value, MeshModel): path = resolve_path(filename, spec, DOWNLOAD_DIR)
self._save_mesh(path, value, format) module.save(path, value, format, plot_title=plot_title)
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, title=plot_title)
else:
self._save_image_or_array(path, value, format)
elif isinstance(value, LineData):
self._save_line(path, value, format, title=plot_title)
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__}")
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 _resolve_save_path(self, filename: str, format_name: 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 ""
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:
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 _save_datafield(self, path: Path, field: DataField, format_name: str):
if format_name == "TIFF":
import tifffile
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
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, title: 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 in ("PNG", "TIFF"):
self._save_line_plot(path, x, y, line.x_unit, line.y_unit, title, format_name)
return
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_line_plot(
self,
path: Path,
x: np.ndarray,
y: np.ndarray,
x_unit: str,
y_unit: str,
title: str,
format_name: str,
):
from PIL import Image, ImageDraw, ImageFont
w, h = 1200, 750
bg = (255, 255, 255)
line_color = (79, 142, 247) # #4f8ef7
grid_color = (200, 200, 200)
text_color = (60, 60, 60)
margin = {"left": 80, "right": 30, "top": 50, "bottom": 60}
img = Image.new("RGB", (w, h), bg)
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("DejaVuSans.ttf", 14)
font_small = ImageFont.truetype("DejaVuSans.ttf", 11)
font_title = ImageFont.truetype("DejaVuSans.ttf", 16)
except (OSError, IOError):
font = ImageFont.load_default()
font_small = font
font_title = font
pw = w - margin["left"] - margin["right"]
ph = h - margin["top"] - margin["bottom"]
def _si_scale(unit: str, vmin: float, vmax: float) -> tuple[float, str]:
"""Pick the best SI prefix for an axis range. Returns (divisor, prefixed_unit)."""
unit = (unit or "").strip()
if not unit or unit not in _PREFIXABLE_UNITS:
return 1.0, unit if unit else ""
peak = max(abs(vmin), abs(vmax))
if peak == 0:
return 1.0, unit
for scale, prefix in _SI_PREFIXES:
if peak / scale >= 1.0:
return scale, f"{prefix}{unit}"
return _SI_PREFIXES[-1][0], f"{_SI_PREFIXES[-1][1]}{unit}"
xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
x_scale, x_label = _si_scale(x_unit, xmin, xmax)
y_scale, y_label = _si_scale(y_unit, ymin, ymax)
if not x_label:
x_label = "x"
if not y_label:
y_label = "y"
# Scale data into prefixed units
x = x / x_scale
y = y / y_scale
xmin, xmax = xmin / x_scale, xmax / x_scale
ymin, ymax = ymin / y_scale, ymax / y_scale
if ymax == ymin:
ymin, ymax = ymin - 1, ymax + 1
if xmax == xmin:
xmax = xmin + 1
# Add 5% padding to y range
ypad = (ymax - ymin) * 0.05
ymin -= ypad
ymax += ypad
def to_px(xv: float, yv: float) -> tuple[float, float]:
px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw
py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph
return px, py
# Grid lines (5 horizontal, 5 vertical)
for i in range(6):
gy = ymin + (ymax - ymin) * i / 5
_, py = to_px(xmin, gy)
draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1)
label = f"{gy:.4g}"
draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm")
gx = xmin + (xmax - xmin) * i / 5
px, _ = to_px(gx, ymin)
draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1)
label = f"{gx:.4g}"
draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt")
# Plot line
n = len(y)
step = max(1, n // pw)
xs, ys = x[::step], y[::step]
pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))]
if len(pts) > 1:
draw.line(pts, fill=line_color, width=2)
# Border
draw.rectangle(
[margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph],
outline=(100, 100, 100), width=1,
)
draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb")
# Vertical y label — draw rotated
y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0))
y_draw = ImageDraw.Draw(y_label_img)
y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm")
y_label_img = y_label_img.rotate(90, expand=True)
img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img)
# Title
if title and title.strip():
draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt")
ext = ".png" if format_name == "PNG" else ".tiff"
img.save(str(path.with_suffix(ext)))
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 tono"]
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 tono")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")

View File

@@ -0,0 +1,300 @@
"""
Tests for the exporter registry and the round-trippable DataField formats.
The Save node's format-specific behavior is covered in test_save_generic
(tests/node_tests/save.py). This module focuses on:
1. Registry contract — every exporter module satisfies the protocol.
2. Dispatch — type_name_for_value classifies values correctly and
get_exporter returns a matching module.
3. Round-trip — GWY and TIFF (data) preserve xreal/yreal/units/data.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import numpy as np
from backend.data_types import (
DataField,
DataTable,
ImageData,
LineData,
MeshModel,
RecordTable,
)
def test_exporter_registry_contract():
"""Every registered exporter module must expose the required attributes."""
from backend.exporters import _REGISTRY
from backend.exporters._base import FormatSpec
assert _REGISTRY, "Registry must not be empty"
seen_modules = {mod for (mod, _) in _REGISTRY.values()}
for module in seen_modules:
assert hasattr(module, "accepted_types")
assert hasattr(module, "FORMATS")
assert hasattr(module, "save")
assert isinstance(module.accepted_types, tuple)
assert all(isinstance(t, str) and t.isupper() for t in module.accepted_types)
assert isinstance(module.FORMATS, dict)
for name, spec in module.FORMATS.items():
assert isinstance(name, str) and name
assert isinstance(spec, FormatSpec)
assert spec.ext.startswith(".")
def test_type_name_for_value_classification():
from backend.exporters import type_name_for_value
assert type_name_for_value(DataField(data=np.zeros((4, 4)))) == "DATA_FIELD"
assert type_name_for_value(np.zeros((4, 4))) == "IMAGE"
assert type_name_for_value(np.zeros((4, 4, 3), dtype=np.uint8)) == "IMAGE"
assert type_name_for_value(ImageData(np.zeros((4, 4), dtype=np.uint8))) == "IMAGE"
assert type_name_for_value(np.zeros(8)) == "LINE"
assert type_name_for_value(LineData(data=np.zeros(8))) == "LINE"
assert type_name_for_value(RecordTable([{"a": 1}])) == "RECORD_TABLE"
assert type_name_for_value(DataTable([{"a": 1}])) == "DATA_TABLE"
assert type_name_for_value(1.25) == "FLOAT"
assert type_name_for_value(np.float64(0.5)) == "FLOAT"
mesh = MeshModel(
vertices=np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
)
assert type_name_for_value(mesh) == "MESH_MODEL"
try:
type_name_for_value(object())
assert False, "Expected ValueError for unsupported type"
except ValueError:
pass
def test_get_exporter_known_and_unknown():
from backend.exporters import get_exporter
mod, spec = get_exporter("DATA_FIELD", "GWY")
assert spec.ext == ".gwy"
assert spec.round_trip is True
mod, spec = get_exporter("DATA_FIELD", "TIFF")
assert spec.ext == ".tiff"
# Legacy preview path — not round-trippable.
assert spec.round_trip is False
mod, spec = get_exporter("DATA_FIELD", "TIFF (data)")
assert spec.round_trip is True
try:
get_exporter("DATA_FIELD", "DOES_NOT_EXIST")
assert False, "Expected ValueError for unknown format"
except ValueError:
pass
try:
get_exporter("FLOAT", "GWY")
assert False, "Expected ValueError for type/format mismatch"
except ValueError:
pass
def test_available_formats_includes_new_datafield_formats():
from backend.exporters import available_formats
formats = available_formats("DATA_FIELD")
assert "TIFF" in formats
assert "TIFF (data)" in formats
assert "GWY" in formats
assert "PNG" in formats
assert "NPZ" in formats
assert "HDF5" in formats
assert "HDF5 (Ergo)" in formats
def test_datafield_gwy_round_trip():
"""Writing a DataField to .gwy and reloading via the importer preserves everything."""
from backend.importers import gwy as gwy_importer
from backend.nodes.save import Save
rng = np.random.default_rng(7)
data = rng.standard_normal((32, 48)).astype(np.float64) * 1e-9
field = DataField(
data=data,
xreal=3.2e-6,
yreal=2.4e-6,
xoff=1.1e-7,
yoff=-5.5e-7,
si_unit_xy="m",
si_unit_z="m",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(filename=str(path), format="GWY", value=field)
out_path = path.with_suffix(".gwy")
assert out_path.exists()
reloaded = gwy_importer.load(out_path)
assert len(reloaded) == 1
rf = reloaded[0]
assert rf.data.shape == field.data.shape
assert np.allclose(rf.data, field.data)
assert np.isclose(rf.xreal, field.xreal)
assert np.isclose(rf.yreal, field.yreal)
assert np.isclose(rf.xoff, field.xoff)
assert np.isclose(rf.yoff, field.yoff)
assert rf.si_unit_xy == "m"
assert rf.si_unit_z == "m"
# channel_names() should return the stem we used as the title
names = gwy_importer.channel_names(out_path)
assert names == ["topo"]
def test_datafield_tiff_data_round_trip():
"""TIFF (data) writes float64 pixels + JSON metadata; we verify both."""
import tifffile
from backend.nodes.save import Save
rng = np.random.default_rng(11)
data = rng.standard_normal((24, 36)).astype(np.float64) * 1e-8
field = DataField(
data=data,
xreal=5e-6,
yreal=3e-6,
xoff=0.0,
yoff=0.0,
si_unit_xy="m",
si_unit_z="V",
colormap="viridis",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "field"
Save().save(filename=str(path), format="TIFF (data)", value=field)
out_path = path.with_suffix(".tiff")
assert out_path.exists()
with tifffile.TiffFile(out_path) as tif:
arr = tif.asarray()
desc = tif.pages[0].tags["ImageDescription"].value
assert arr.dtype == np.float64
assert arr.shape == field.data.shape
assert np.allclose(arr, field.data)
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"
def test_datafield_hdf5_generic_round_trip():
"""HDF5 (generic) writes /data + attrs that our hdf5 importer reads back."""
from backend.importers import hdf5 as hdf5_importer
from backend.nodes.save import Save
rng = np.random.default_rng(23)
data = rng.standard_normal((20, 28)).astype(np.float64) * 1e-7
field = DataField(
data=data,
xreal=4.8e-6,
yreal=3.2e-6,
xoff=1.5e-7,
yoff=-2.5e-7,
si_unit_xy="m",
si_unit_z="V",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(filename=str(path), format="HDF5", value=field)
out_path = path.with_suffix(".h5")
assert out_path.exists()
reloaded = hdf5_importer.load(out_path)
assert len(reloaded) == 1
rf = reloaded[0]
assert rf.data.shape == field.data.shape
assert np.allclose(rf.data, field.data)
assert np.isclose(rf.xreal, field.xreal)
assert np.isclose(rf.yreal, field.yreal)
assert np.isclose(rf.xoff, field.xoff)
assert np.isclose(rf.yoff, field.yoff)
assert rf.si_unit_xy == "m"
assert rf.si_unit_z == "V"
def test_datafield_hdf5_ergo_round_trip():
"""HDF5 (Ergo) writes the Asylum sidecar layout and round-trips via ergo_hdf5."""
import h5py
from backend.importers import ergo_hdf5 as ergo_importer
from backend.nodes.save import Save
rng = np.random.default_rng(29)
data = rng.standard_normal((16, 24)).astype(np.float64) * 1e-9
field = DataField(
data=data,
xreal=2.5e-6,
yreal=1.8e-6,
xoff=0.5e-7,
yoff=-1.1e-7,
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=field)
out_path = path.with_suffix(".h5")
assert out_path.exists()
# Sanity-check the layout: the dataset lives under
# Image/DataSet/Resolution 0/Frame 0/<title>/Image, and the sidecar
# group under Image/DataSetInfo/Global/Channels/<title>/ImageDims.
with h5py.File(str(out_path), "r") as f:
assert "Image/DataSet/Resolution 0/Frame 0/topo/Image" in f
dims = f["Image/DataSetInfo/Global/Channels/topo/ImageDims"]
scaling = np.asarray(dims.attrs["DimScaling"])
assert scaling.shape == (2, 2)
# DimScaling is Y-first: [[y_start, y_end], [x_start, x_end]]
assert np.isclose(scaling[1, 1] - scaling[1, 0], field.xreal)
assert np.isclose(scaling[0, 1] - scaling[0, 0], field.yreal)
reloaded = ergo_importer.load(out_path)
assert len(reloaded) == 1
rf = reloaded[0]
assert rf.data.shape == field.data.shape
assert np.allclose(rf.data, field.data)
assert np.isclose(rf.xreal, field.xreal)
assert np.isclose(rf.yreal, field.yreal)
assert np.isclose(rf.xoff, field.xoff)
assert np.isclose(rf.yoff, field.yoff)
assert rf.si_unit_xy == "m"
assert rf.si_unit_z == "N"
def test_tiff_preview_is_still_rgb_uint8():
"""The legacy TIFF format for DATA_FIELD must keep producing 8-bit RGB."""
import tifffile
from backend.nodes.save import Save
field = DataField(
data=np.array([[0.0, 1.0], [2.0, 3.0]], dtype=np.float64),
xreal=1e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "preview"
Save().save(filename=str(path), format="TIFF", value=field)
arr = tifffile.imread(str(path.with_suffix(".tiff")))
assert arr.dtype == np.uint8
assert arr.shape == (2, 2, 3)