""" 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//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