Compare commits

...

10 Commits

Author SHA1 Message Date
1d98ccf190 fix naming on layer 1
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-06 23:01:55 -07:00
c38c2dc29a combine save and save layers 2026-04-05 14:12:34 -07:00
08aff81f02 dimensioned export (gwy, HDF5) 2026-04-05 13:28:26 -07:00
0f9b500c34 fix image demo carousel 2026-04-05 00:02:17 -07:00
c6096b53a8 loading bar for file uploads 2026-04-04 23:26:30 -07:00
b8d5c11ee9 simplify tests 2026-04-04 23:14:17 -07:00
2d9e1a1ecf prevent dragging out of inputs 2026-04-04 22:48:16 -07:00
591186bc14 rework colors 2026-04-04 22:43:32 -07:00
ce10edd9cd add light mode and auto theme selector 2026-04-04 22:33:56 -07:00
d4ca88f108 fix first time experience 2026-04-04 21:48:08 -07:00
30 changed files with 2381 additions and 827 deletions

View File

@@ -20,8 +20,7 @@ pip install -e ".[server,dev]"
npm install
# Running the servers
npm run backend # terminal 1 — Python server at http://127.0.0.1:8188
npm run dev # terminal 2 — Vite dev server, open the URL it prints
npm run dev:all # one terminal — starts the Python backend and the Vite dev server together
```
## Self-hosting

View File

@@ -492,7 +492,7 @@ class ExecutionEngine:
return
if cls in (Image, ImageDemo) and on_preview:
preview = self._render_load_node_preview(result, inputs or {})
preview = self._render_load_node_preview(cls, result, inputs or {})
if preview:
on_preview(node_id, preview)
return
@@ -539,17 +539,23 @@ class ExecutionEngine:
def _render_load_node_preview(
self,
cls: type,
result: tuple,
inputs: dict[str, Any],
) -> dict | None:
from backend.data_types import DataField, encode_preview, render_datafield_preview
from backend.nodes.helpers import list_channels
from backend.nodes.helpers import list_channels, DEMO_DIR
from backend.nodes.image_demo import ImageDemo
fields = [value for value in result if isinstance(value, DataField)]
if not fields:
return None
selected_path = str(inputs.get("path") or inputs.get("filename") or inputs.get("name") or "").strip()
# ImageDemo passes only the bare demo filename; resolve against DEMO_DIR
# so list_channels() can find the file and return real channel names.
if cls is ImageDemo and selected_path:
selected_path = str(DEMO_DIR / selected_path)
channel_names: list[str] = []
if selected_path:
try:

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,365 @@
"""
Exporter for DATA_FIELD values (single layer or multi-layer stacks).
Format choices:
* **TIFF** — 8-bit RGB colormap preview. *Not* round-trippable and single-layer
only; connect multiple channels and pick "TIFF (data)" for a stack.
* **TIFF (data)** — float64 pixels with tono metadata JSON-embedded in the
TIFF ImageDescription tag. Round-trips and supports multi-page stacks: one
IFD per layer, the first page's description carries a ``{"tono": {...},
"layers": [...]}`` document.
* **PNG** — 8-bit RGB colormap preview. Single-layer only.
* **NPZ** — for a single layer, writes a plain ``field=...`` key. For a stack,
each layer gets its own key derived from its display name (identifier-safe,
deduplicated).
* **GWY** — Gwyddion native format via the ``gwyfile`` package. A multi-layer
save writes one channel per layer (``/0/data``, ``/1/data``, …), each with
its own title, producing a true multi-channel .gwy file.
* **HDF5** — generic HDF5 with one ``data`` dataset per layer and physical
dimensions as dataset attrs. Round-trips via our generic ``hdf5`` importer,
which picks up every 2-D numeric dataset.
* **HDF5 (Ergo)** — Asylum Research / Ergo layout, one dataset per layer under
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` plus a matching sidecar
group ``Image/DataSetInfo/Global/Channels/<title>/ImageDims``. Round-trips
via our ``ergo_hdf5`` importer and opens in Ergo / Igor.
Mixed layer stacks (DataField + Image) are supported for TIFF (data) and NPZ
only; the physics-carrying formats (GWY, HDF5, HDF5 Ergo) require every layer
to be a DataField and raise a clear error otherwise.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Sequence
import numpy as np
from backend.data_types import DataField, datafield_to_uint8, image_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)"),
}
# Formats that only make sense for a single layer. When extra layers are
# connected, the Save node raises before we get here, but we keep the check
# defensive so the protocol is enforced at the exporter boundary too.
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"TIFF", "PNG"})
def save(
path: Path,
value: DataField,
format_name: str,
*,
extra_layers: Sequence[Any] | None = None,
layer_names: Sequence[str] | None = None,
**_opts,
) -> None:
extras = list(extra_layers or [])
layers: list[Any] = [value, *extras]
names = _resolve_layer_names(layers, layer_names, default_primary=path.stem or "field")
if extras and format_name in _SINGLE_LAYER_ONLY:
raise ValueError(
f"{format_name} only supports a single layer. Use 'TIFF (data)', "
f"'NPZ', 'GWY', or an HDF5 format for multi-layer saves."
)
if format_name == "TIFF":
_save_tiff_preview(path, value)
return
if format_name == "TIFF (data)":
_save_tiff_data(path, layers, names)
return
if format_name == "PNG":
_save_png_preview(path, value)
return
if format_name == "NPZ":
_save_npz(path, layers, names)
return
if format_name == "GWY":
_save_gwy(path, _require_all_datafields(layers, "GWY"), names)
return
if format_name == "HDF5":
_save_hdf5_generic(path, _require_all_datafields(layers, "HDF5"), names)
return
if format_name == "HDF5 (Ergo)":
_save_hdf5_ergo(path, _require_all_datafields(layers, "HDF5 (Ergo)"), names)
return
raise ValueError(f"Format {format_name!r} is not supported for DATA_FIELD.")
# ---------------------------------------------------------------------------
# Layer helpers
# ---------------------------------------------------------------------------
def _resolve_layer_names(
layers: Sequence[Any],
raw_names: Sequence[str] | None,
*,
default_primary: str,
) -> list[str]:
"""Fill in layer names, falling back to defaults for blank/missing entries.
The primary layer (index 0) defaults to ``default_primary`` (usually the
file stem), and each extra layer defaults to ``layer_N+1`` (1-indexed for
humans: "layer 2", "layer 3", …).
"""
raw_names = list(raw_names or [])
out: list[str] = []
for i in range(len(layers)):
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
if raw:
out.append(raw)
elif i == 0:
out.append(default_primary)
else:
out.append(f"layer_{i + 1}")
return out
def _require_all_datafields(layers: Sequence[Any], format_label: str) -> list[DataField]:
"""Return the list cast to DataFields, raising if any layer is not one."""
out: list[DataField] = []
for i, layer in enumerate(layers):
if not isinstance(layer, DataField):
raise ValueError(
f"{format_label} only supports DataField layers; layer {i + 1} "
f"is a {type(layer).__name__}. Use TIFF (data) or NPZ for mixed stacks."
)
out.append(layer)
return out
def _safe_identifier(name: str, index: int) -> str:
"""Turn a free-form layer name into a safe identifier (used as an NPZ key)."""
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
if not key:
key = f"layer_{index + 1}"
if key[0].isdigit():
key = f"layer_{key}"
return key
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
used: set[str] = set()
result: list[str] = []
for k in raw_keys:
candidate = k
suffix = 2
while candidate in used:
candidate = f"{k}_{suffix}"
suffix += 1
used.add(candidate)
result.append(candidate)
return result
def _layer_to_float_array(layer: Any) -> np.ndarray:
"""Coerce a layer into a float array for TIFF (data). Images are promoted."""
if isinstance(layer, DataField):
return np.ascontiguousarray(layer.data, dtype=np.float64)
if isinstance(layer, np.ndarray):
# Images are left as-is so multi-channel RGB pages survive the write.
return np.ascontiguousarray(layer)
raise ValueError(f"Unsupported layer type for TIFF (data): {type(layer).__name__}")
def _layer_to_npz_array(layer: Any) -> np.ndarray:
if isinstance(layer, DataField):
return np.asarray(layer.data)
if isinstance(layer, np.ndarray):
return np.asarray(layer)
raise ValueError(f"Unsupported layer type for NPZ: {type(layer).__name__}")
def _datafield_meta(field: DataField) -> dict:
"""Build the JSON-serializable physics metadata dict for a DataField."""
return {
"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",
}
# ---------------------------------------------------------------------------
# Per-format writers
# ---------------------------------------------------------------------------
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, layers: Sequence[Any], names: Sequence[str]) -> None:
"""Write the raw pixels as a multi-page TIFF with tono metadata.
The ImageDescription tag on the first page carries a JSON document of
shape ``{"tono": {"version": 1, "layers": [{...}, {...}]}}``. Each entry in
``layers`` gives the per-layer physics (xreal/yreal/xoff/yoff/units/domain)
and its display name so a future multi-layer importer can reconstruct the
whole stack. Non-DataField layers (plain images) get a minimal entry with
just the name and dtype — they're pixels, not physics.
"""
import tifffile
per_layer_meta: list[dict] = []
for layer, layer_name in zip(layers, names):
if isinstance(layer, DataField):
entry = {"name": layer_name, "kind": "data_field", **_datafield_meta(layer)}
else:
arr = np.asarray(layer)
entry = {"name": layer_name, "kind": "image", "dtype": str(arr.dtype), "shape": list(arr.shape)}
per_layer_meta.append(entry)
description = json.dumps(
{"tono": {"version": 1, "layers": per_layer_meta}},
separators=(",", ":"),
)
with tifffile.TiffWriter(str(path)) as tif:
for i, (layer, layer_name) in enumerate(zip(layers, names)):
arr = _layer_to_float_array(layer)
# Full metadata document lives on the first page; subsequent pages
# carry only their display name so readers that walk IFDs see
# something meaningful per channel.
page_desc = description if i == 0 else layer_name
tif.write(arr, description=page_desc)
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, layers: Sequence[Any], names: Sequence[str]) -> None:
if len(layers) == 1:
# Single-layer: keep the historical `field` key so nothing that reads
# existing tono .npz outputs breaks.
np.savez(str(path), field=_layer_to_npz_array(layers[0]))
return
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
keys = _dedupe_keys(raw_keys)
arrays = {key: _layer_to_npz_array(layer) for key, layer in zip(keys, layers)}
np.savez(str(path), **arrays)
def _save_gwy(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
"""Write an N-channel .gwy file via the gwyfile package."""
from gwyfile.objects import GwyContainer, GwyDataField, GwySIUnit
container_data: dict[str, Any] = {}
for i, (field, title) in enumerate(zip(fields, names)):
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 "")),
)
container_data[f"/{i}/data"] = gwy_field
container_data[f"/{i}/data/title"] = title
GwyContainer(container_data).tofile(str(path))
def _save_hdf5_generic(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
"""Write one HDF5 dataset per layer with physical dims as dataset attrs.
Single-layer saves use ``/data`` for backward compatibility with the
tests that read the original layout; multi-layer saves use one
top-level dataset per channel, keyed by the safe-identifier form of its
name and deduplicated against collisions.
"""
import h5py
with h5py.File(str(path), "w") as f:
if len(fields) == 1:
_write_hdf5_dataset(f, "data", fields[0])
return
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
keys = _dedupe_keys(raw_keys)
for key, field in zip(keys, fields):
_write_hdf5_dataset(f, key, field)
def _write_hdf5_dataset(h5file: Any, name: str, field: DataField) -> None:
arr = np.ascontiguousarray(field.data, dtype=np.float64)
ds = h5file.create_dataset(name, 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, fields: list[DataField], names: Sequence[str]) -> None:
"""Write an Asylum Research / Ergo-compatible HDF5 file (N channels).
Each channel gets its own dataset at
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` with a matching
sidecar group ``Image/DataSetInfo/Global/Channels/<title>/ImageDims``
carrying ``DimScaling`` / ``DimUnits`` / ``DataUnits``. The channel
names are the dedupe-safe form of each layer name. Opens in Ergo / Igor
and round-trips through :mod:`backend.importers.ergo_hdf5`.
"""
import h5py
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
titles = _dedupe_keys(raw_keys)
with h5py.File(str(path), "w") as f:
for field, title in zip(fields, titles):
arr = np.ascontiguousarray(field.data, dtype=np.float64)
ds = f.create_dataset(
f"Image/DataSet/Resolution 0/Frame 0/{title}/Image",
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)
xy_unit = str(field.si_unit_xy or "m")
z_unit = str(field.si_unit_z or "")
ds.attrs["si_unit_xy"] = xy_unit
ds.attrs["si_unit_z"] = z_unit
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 Y-first to match the importer (ergo_hdf5.py:110-113).
dim_scaling = np.array(
[[y_start, y_end], [x_start, x_end]],
dtype=np.float64,
)
dim_units = np.array([xy_unit, xy_unit], dtype=h5py.string_dtype())
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

129
backend/exporters/image.py Normal file
View File

@@ -0,0 +1,129 @@
"""
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.
Multi-layer stacks are supported for TIFF (multi-page uint8) and NPZ (one
named array per layer). PNG is single-layer only and raises if extra layers
are connected.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Sequence
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)"),
}
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"PNG"})
def save(
path: Path,
value: np.ndarray,
format_name: str,
*,
extra_layers: Sequence[Any] | None = None,
layer_names: Sequence[str] | None = None,
**_opts,
) -> None:
extras = list(extra_layers or [])
layers: list[Any] = [value, *extras]
names = _resolve_layer_names(len(layers), layer_names, default_primary=path.stem or "image")
if extras and format_name in _SINGLE_LAYER_ONLY:
raise ValueError(
f"{format_name} only supports a single layer. Use 'TIFF' or 'NPZ' "
f"for multi-layer image saves."
)
if format_name == "PNG":
from PIL import Image
Image.fromarray(image_to_uint8(np.asarray(value))).save(str(path))
return
if format_name == "TIFF":
_save_tiff(path, layers, names)
return
if format_name == "NPZ":
_save_npz(path, layers, names)
return
raise ValueError(f"Format {format_name!r} is not supported for IMAGE.")
def _save_tiff(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
import tifffile
if len(layers) == 1:
tifffile.imwrite(str(path), image_to_uint8(np.asarray(layers[0])))
return
with tifffile.TiffWriter(str(path)) as tif:
for layer, layer_name in zip(layers, names):
tif.write(image_to_uint8(np.asarray(layer)), description=layer_name)
def _save_npz(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
if len(layers) == 1:
# Preserve the single-layer key used by the legacy test suite.
np.savez(str(path), image=np.asarray(layers[0]))
return
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
keys = _dedupe_keys(raw_keys)
arrays = {key: np.asarray(layer) for key, layer in zip(keys, layers)}
np.savez(str(path), **arrays)
def _resolve_layer_names(
count: int,
raw_names: Sequence[str] | None,
*,
default_primary: str,
) -> list[str]:
raw_names = list(raw_names or [])
out: list[str] = []
for i in range(count):
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
if raw:
out.append(raw)
elif i == 0:
out.append(default_primary)
else:
out.append(f"layer_{i + 1}")
return out
def _safe_identifier(name: str, index: int) -> str:
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
if not key:
key = f"layer_{index + 1}"
if key[0].isdigit():
key = f"layer_{key}"
return key
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
used: set[str] = set()
result: list[str] = []
for k in raw_keys:
candidate = k
suffix = 2
while candidate in used:
candidate = f"{k}_{suffix}"
suffix += 1
used.add(candidate)
result.append(candidate)
return result

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 = []
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(
data=data,
xreal=float(ch.xreal),
yreal=float(ch.yreal),
xoff=float(getattr(ch, "xoff", 0.0)),
yoff=float(getattr(ch, "yoff", 0.0)),
si_unit_xy="m",
si_unit_z="m",
si_unit_xy=_unit_str(getattr(ch, "si_unit_xy", None)) or "m",
si_unit_z=_unit_str(getattr(ch, "si_unit_z", None)) or "m",
))
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]:
import gwyfile
try:

View File

@@ -14,8 +14,8 @@ from typing import Any
MENU_LAYOUT: dict[str, list[str]] = {
"Input": [
"Image",
"ImageDemo",
"Folder",
"ImageDemo",
"SyntheticSurface",
"Note",
"TextNote",
@@ -33,7 +33,6 @@ MENU_LAYOUT: dict[str, list[str]] = {
"ValueIO",
"PrintTable",
"Save",
"SaveImage",
"Shade",
"PresentationOps",
],

View File

@@ -1,26 +1,88 @@
from __future__ import annotations
import csv
import json
from pathlib import Path
import numpy as np
import tempfile
from pathlib import Path
from typing import Any
from backend.node_registry import register_node
from backend.execution_context import emit_warning, emit_file_download
from backend.data_types import (
DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8,
_SI_PREFIXES, _PREFIXABLE_UNITS,
from backend.exporters import (
available_formats,
get_exporter,
resolve_path,
type_name_for_value,
)
from backend.nodes.helpers import _MAX_SAVE_FIELDS
DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads"
# Source types that expand into a layer stack (i.e., the Save node grows
# extra field_N inputs). Any other type (FLOAT, LINE, MESH, …) is a single
# value; no stacking UI is shown.
_STACKABLE_SOURCE_TYPES: tuple[str, ...] = ("DATA_FIELD", "IMAGE", "ANNOTATION_SOURCE")
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")
class Save:
@classmethod
def INPUT_TYPES(cls):
choices = _choices_by_source_type()
optional: dict[str, Any] = {
"plot_title": ("STRING", {
"default": "",
"placeholder": "plot title (optional)",
"label": "title",
"show_when_source_type": {"value": ["LINE"]},
}),
# Name widget for the primary (value) layer. Only surfaces once
# the stack grows beyond one layer, so single-value saves stay
# clutter-free.
"primary_name": ("STRING", {
"default": "",
"placeholder": "name",
"show_when_input_visible": "field_0",
"inline_with_input": "layer_1",
"hide_label": True,
}),
}
# Extra layer sockets for stackable source types. The frontend
# progressive-reveal block keys off `field_N` and only shows slot N
# once slot N-1 is connected; we further gate every slot on `value`
# being a stackable source type via `show_when_source_type`.
for i in range(_MAX_SAVE_FIELDS):
optional[f"field_{i}"] = ("DATA_FIELD", {
"label": f"layer {i + 2}", # primary is layer 1
"accepted_types": ["IMAGE", "ANNOTATION_SOURCE"],
"show_when_source_type": {"value": list(_STACKABLE_SOURCE_TYPES)},
})
optional[f"layer_name_{i}"] = ("STRING", {
"default": "",
"placeholder": "name",
"show_when_input_visible": f"field_{i}",
"inline_with_input": f"field_{i}",
"hide_label": True,
})
return {
"required": {
"filename": ("STRING", {
@@ -29,7 +91,7 @@ class Save:
"placement": "top",
}),
"value": ("DATA_FIELD", {
"label": "value",
"label": "layer 1",
"accepted_types": [
"IMAGE",
"ANNOTATION_SOURCE",
@@ -41,28 +103,12 @@ class Save:
],
}),
"format": ("STRING", {
"default": "TIFF",
"choices_by_source_type": {
"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"],
},
"default": choices["DATA_FIELD"][0] if choices["DATA_FIELD"] else "",
"choices_by_source_type": choices,
"source_type_input": "value",
}),
},
"optional": {
"plot_title": ("STRING", {
"default": "",
"placeholder": "plot title (optional)",
"label": "title",
"show_when_source_type": {"value": ["LINE"]},
}),
},
"optional": optional,
}
OUTPUTS = ()
@@ -71,10 +117,15 @@ class Save:
OUTPUT_NODE = True
MANUAL_TRIGGER = True
DESCRIPTION = (
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes."
"Save one or more channels."
"Use 'GWY','TIFF (data)', or 'HDF5' when you need to re-open the result with its "
"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", "hdf5", "layers", "stack", "channels",
)
def save(
self,
@@ -82,296 +133,62 @@ class Save:
format: str,
value,
plot_title: str = "",
primary_name: str = "",
**kwargs,
):
path = self._resolve_save_path(filename, format)
type_name = type_name_for_value(value)
module, spec = get_exporter(type_name, format)
path = resolve_path(filename, spec, DOWNLOAD_DIR)
if isinstance(value, MeshModel):
self._save_mesh(path, value, format)
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__}")
extra_layers, layer_names = self._collect_extra_layers(
type_name, primary_name, kwargs,
)
module.save(
path,
value,
format,
plot_title=plot_title,
extra_layers=extra_layers,
layer_names=layer_names,
)
emit_warning(f"Saved to {path.name}")
emit_file_download(str(path))
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(
def _collect_extra_layers(
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
type_name: str,
primary_name: str,
kwargs: dict[str, Any],
) -> tuple[list[Any], list[str]]:
"""Pull field_N + layer_name_N from kwargs into parallel lists.
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}
Only applies when the primary value is a stackable source type; for
anything else (LINE, FLOAT, MESH_MODEL, tables) any stray field_N
kwargs are ignored — the frontend hides those sockets in that case
and the backend treats it as a single-value save.
"""
if type_name not in _STACKABLE_SOURCE_TYPES:
return [], []
img = Image.new("RGB", (w, h), bg)
draw = ImageDraw.Draw(img)
extras: list[Any] = []
extra_names: list[str] = []
# Preserve the on-node order: iterate field_0, field_1, …, stopping at
# the first hole. An unconnected slot in the middle would be a UI bug,
# but bailing early keeps the saved stack matching what the user sees.
for i in range(_MAX_SAVE_FIELDS):
layer = kwargs.get(f"field_{i}")
if layer is None:
break
extras.append(layer)
extra_names.append(str(kwargs.get(f"layer_name_{i}", "") or "").strip())
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
if not extras:
return [], []
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")
# Full names list starts with the primary's name (empty → exporter
# substitutes path.stem) and then each extra in order.
names = [str(primary_name or "").strip(), *extra_names]
return extras, names

View File

@@ -1,185 +0,0 @@
from __future__ import annotations
import re
import numpy as np
from pathlib import Path
from backend.node_registry import register_node
from backend.execution_context import emit_warning, emit_file_download
from backend.data_types import DataField, image_to_uint8
from backend.nodes.helpers import _MAX_SAVE_FIELDS
@register_node(display_name="Save Layers")
class SaveImage:
@classmethod
def INPUT_TYPES(cls):
optional = {
"directory": ("DIRECTORY", {"label": "directory"}),
}
for i in range(_MAX_SAVE_FIELDS):
optional[f"field_{i}"] = ("DATA_FIELD", {
"label": f"layer {i + 1}",
"accepted_types": ["IMAGE", "ANNOTATION_SOURCE"],
})
optional[f"layer_name_{i}"] = ("STRING", {
"default": "",
"placeholder": "name",
"show_when_input_visible": f"field_{i}",
"inline_with_input": f"field_{i}",
"hide_label": True,
})
return {
"required": {
"filename": ("STRING", {
"default": "",
"placeholder": "filename",
"placement": "top",
}),
"directory_path": ("STRING", {
"default": "",
"label": "directory",
"placeholder": "directory (optional, desktop only)",
"placement": "top",
"hide_when_input_connected": "directory",
"top_socket_input": "directory",
}),
"format": (["TIFF", "NPZ"],),
},
"optional": optional,
}
OUTPUTS = ()
FUNCTION = "save"
OUTPUT_NODE = True
MANUAL_TRIGGER = True
DESCRIPTION = (
"Save one or more image/field layers to a single file. "
"Each layer input accepts either a DATA_FIELD or an IMAGE, including annotated images. "
"Optionally drive the output directory from a folder/path node, while keeping the filename widget for the file name. "
"A new slot appears as each one is filled, with a matching per-layer name field. "
"Use this for composing multi-channel stacks. TIFF writes multi-page data and stores layer names as page descriptions; "
"NPZ writes named arrays using those layer names as keys. "
"Click Save to write (does not auto-run)."
)
KEYWORDS = ("export", "write", "multipage", "stack", "tiff", "npz", "channels")
def save(
self,
filename: str,
directory_path: str = "",
format: str = "TIFF",
directory: str | None = None,
**kwargs,
):
layers = []
layer_names = []
for i in range(_MAX_SAVE_FIELDS):
layer = kwargs.get(f"field_{i}")
if layer is not None:
layers.append(layer)
layer_names.append(self._resolve_layer_name(kwargs.get(f"layer_name_{i}"), i))
if not layers:
raise ValueError("No layers connected — connect at least one DATA_FIELD or IMAGE input.")
path = self._resolve_save_path(filename, format, directory, directory_path)
if format == "TIFF":
self._save_tiff(path, layers, layer_names)
else:
self._save_npz(path, layers, layer_names)
emit_warning(f"Saved {len(layers)} layer(s) to {path.name}")
emit_file_download(str(path))
return ()
def _save_tiff(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
import tifffile
with tifffile.TiffWriter(str(path)) as tif:
for layer, layer_name in zip(layers, layer_names):
tif.write(self._layer_array_for_tiff(layer), description=layer_name)
def _save_npz(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
arrays = {}
used_keys = set()
for i, (layer, layer_name) in enumerate(zip(layers, layer_names)):
arrays[self._unique_npz_key(layer_name, used_keys, i)] = self._layer_array_for_npz(layer)
np.savez(str(path), **arrays)
def _resolve_layer_name(self, raw_name: object, index: int) -> str:
text = str(raw_name).strip() if raw_name is not None else ""
return text or f"layer_{index}"
def _resolve_save_path(
self,
filename: str,
format: str,
directory: str | None,
directory_path: str = "",
) -> Path:
ext = ".tiff" if format == "TIFF" else ".npz"
raw_filename = str(filename).strip() if filename is not None else ""
raw_directory = str(directory).strip() if directory is not None else ""
if not raw_directory:
raw_directory = str(directory_path).strip() if directory_path is not None else ""
if raw_directory:
dir_path = Path(raw_directory).expanduser()
if dir_path.exists() and not dir_path.is_dir():
raise ValueError("Directory input expects a folder path, not a file path.")
if not dir_path.exists():
if dir_path.suffix:
raise ValueError("Directory input expects a folder path, not a file path.")
dir_path.mkdir(parents=True, exist_ok=True)
filename_part = Path(raw_filename).name if raw_filename else ""
if not filename_part:
raise ValueError("No output filename selected — enter a file name when using a directory input.")
path = dir_path / filename_part
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:
from backend.nodes.save import DOWNLOAD_DIR
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 _unique_npz_key(self, raw_name: str, used_keys: set[str], index: int) -> str:
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(raw_name).strip()).strip("_")
if not key:
key = f"layer_{index}"
if key[0].isdigit():
key = f"layer_{key}"
candidate = key
suffix = 2
while candidate in used_keys:
candidate = f"{key}_{suffix}"
suffix += 1
used_keys.add(candidate)
return candidate
def _layer_array_for_tiff(self, layer: DataField | np.ndarray) -> np.ndarray:
if isinstance(layer, DataField):
return np.asarray(layer.data, dtype=np.float32)
if isinstance(layer, np.ndarray):
return image_to_uint8(layer)
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
def _layer_array_for_npz(self, layer: DataField | np.ndarray) -> np.ndarray:
if isinstance(layer, DataField):
return np.asarray(layer.data)
if isinstance(layer, np.ndarray):
return np.asarray(layer)
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")

View File

@@ -35,6 +35,14 @@ pip install -e ".[server,dev,desktop]"
Two servers run in development: the Vite frontend dev server and the Python backend. Vite proxies all API and WebSocket requests to the backend, so you only open the Vite URL in your browser.
The easiest way is the combined runner, which starts both with prefixed, colour-coded output and tears everything down on Ctrl-C:
```bash
npm run dev:all
```
Or run them in separate terminals if you prefer independent logs:
```bash
# Terminal 1 — Python backend (http://127.0.0.1:8188)
npm run backend
@@ -157,8 +165,9 @@ TONO_APPDATA=/my/data/dir python desktop.py
| Command | Description |
|---|---|
| `npm run dev` | Start Vite dev server + Python backend |
| `npm run backend` | Start Python backend only |
| `npm run dev:all` | Start the Python backend and the Vite dev server together |
| `npm run dev` | Start the Vite dev server only |
| `npm run backend` | Start the Python backend only |
| `npm run build` | Build frontend to `frontend/dist/` |
| `npm run preview` | Preview the production frontend build |
| `npm run desktop` | Build frontend + launch desktop app |

View File

@@ -29,6 +29,13 @@ import {
parseNodeClipboardPayload,
} from './nodeClipboard';
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
import {
cycleTheme,
getStoredTheme,
resolveTheme,
subscribeTheme,
type Theme,
} from './theme';
import {
serializeExecutionGraph,
getAutoRunnableNodes,
@@ -162,7 +169,7 @@ function restoreGroupEdges(edges: any[], groupId: string) {
function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<TonoEdge>([]);
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
const [status, setStatus] = useState<{ text: string; level: string; progress?: number | null }>({ text: 'Connecting…', level: 'info' });
const [contextMenu, setContextMenu] = useState<any>(null);
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
const [executingNodeId, setExecutingNodeId] = useState<string | null>(null);
@@ -171,6 +178,15 @@ function Flow() {
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuClosing, setMenuClosing] = useState(false);
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme());
const resolvedTheme = resolveTheme(theme);
useEffect(() => {
return subscribeTheme((next) => setThemeState(next));
}, []);
const onCycleTheme = useCallback(() => {
const next = cycleTheme();
setThemeState(next);
}, []);
const closeMenu = useCallback(() => {
if (!menuOpen || menuClosing) return;
setMenuClosing(true);
@@ -967,9 +983,17 @@ function Flow() {
setStatus({
text: `Uploading ${entry.file.name}`,
level: 'info',
progress: 0,
});
const uploaded = await api.uploadFile(entry.file, { relativePath: entry.relativePath });
const uploaded = await api.uploadFile(entry.file, {
relativePath: entry.relativePath,
onProgress: (pct) => setStatus({
text: `Uploading ${entry.file.name}${Math.round(pct * 100)}%`,
level: 'info',
progress: pct,
}),
});
return uploaded.path;
}, []);
@@ -1036,11 +1060,27 @@ function Flow() {
}
}
const total = toUpload.size;
let index = 0;
for (const uri of toUpload) {
const file = pending.get(uri)!;
const relativePath = uri.replace(/^session:\/\/uploads\//, '');
await api.uploadFile(file, { relativePath });
const fileIndex = index;
setStatus({
text: `Uploading ${file.name} (${fileIndex + 1}/${total})…`,
level: 'info',
progress: fileIndex / total,
});
await api.uploadFile(file, {
relativePath,
onProgress: (pct) => setStatus({
text: `Uploading ${file.name} (${fileIndex + 1}/${total})… ${Math.round(pct * 100)}%`,
level: 'info',
progress: (fileIndex + pct) / total,
}),
});
pending.delete(uri);
index++;
}
}, []);
@@ -1441,32 +1481,56 @@ function Flow() {
initializeDynamicNodes(hydrated.nodes);
}, [initializeDynamicNodes, setNodes, setEdges]);
const frameWorkflowViewport = useCallback(() => {
const flowEl = document.querySelector('.react-flow') as HTMLElement | null;
if (!flowEl) return;
const width = flowEl.offsetWidth;
const height = flowEl.offsetHeight;
if (width <= 0 || height <= 0) return;
const allNodes = (reactFlow.getNodes() as TonoNode[]);
if (allNodes.length === 0) return;
const bounds = getRenderedNodeBounds(allNodes);
if (!bounds) return;
const vp = getViewportForBounds(bounds, width, height, 0.5, 1, 0.1);
reactFlow.setViewport(vp, { duration: 300 });
}, [reactFlow]);
const scheduleFrameWorkflowViewport = useCallback(() => {
// Two rAFs so ReactFlow has rendered and measured the freshly-applied nodes
// before we read their DOM dimensions.
requestAnimationFrame(() => requestAnimationFrame(() => frameWorkflowViewport()));
}, [frameWorkflowViewport]);
const applyMaybePackedWorkflow = useCallback(async (data: any) => {
if (data.packed && data.packedFiles) {
setStatus({ text: 'Unpacking files…', level: 'info' });
try {
const { workflow, restoredPaths } = await unpackWorkflow(data);
applyWorkflowData(workflow, { preservedPaths: restoredPaths });
scheduleFrameWorkflowViewport();
// Auto-run after packed workflow loads so all previews populate
requestAnimationFrame(() => requestAnimationFrame(() => scheduleAutoRun()));
} catch {
// Unpack failed (e.g. stale session) — load the workflow without file restoration
const { packedFiles: _, packed: __, ...cleanWorkflow } = data;
applyWorkflowData(cleanWorkflow);
scheduleFrameWorkflowViewport();
setStatus({ text: 'Workflow loaded but packed files could not be restored. Re-browse your input files.', level: 'error' });
return;
}
} else {
applyWorkflowData(data);
scheduleFrameWorkflowViewport();
}
}, [applyWorkflowData, scheduleAutoRun]);
}, [applyWorkflowData, scheduleAutoRun, scheduleFrameWorkflowViewport]);
const loadDefaultWorkflow = useCallback(async () => {
if (defaultWorkflowLoadAttemptedRef.current) return;
defaultWorkflowLoadAttemptedRef.current = true;
// Only auto-load the example workflow on first visit
if (localStorage.getItem('tono_visited')) return;
// First-visit gating is handled by the caller (see the mount useEffect
// below) so we avoid a race with /help-docs, which also flips the
// tono_visited flag and often resolves before api.getNodes().
const graphHasContent = () => {
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
@@ -1507,16 +1571,20 @@ function Flow() {
// ── Load node definitions ───────────────────────────────────────────
useEffect(() => {
// Capture first-visit state once at mount so the /help-docs fetch (which
// sets tono_visited as a side effect) cannot race with the default-workflow
// gate below. Both decisions must see the same value.
const isFirstVisit = !localStorage.getItem('tono_visited');
api.getNodes().then((defs) => {
nodeDefsRef.current = defs;
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
loadDefaultWorkflow();
if (isFirstVisit) loadDefaultWorkflow();
}).catch((err) => {
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
});
// Load any .md files from frontend/public/ as help tabs
const isFirstVisit = !localStorage.getItem('tono_visited');
fetch('/help-docs')
.then((r) => r.ok ? r.json() : [])
.then((docs: any[]) => {
@@ -1732,9 +1800,15 @@ function Flow() {
input.onchange = async (e: Event) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
if (!file) return;
setStatus({ text: 'Uploading plugin…', level: 'info' });
setStatus({ text: 'Uploading plugin…', level: 'info', progress: 0 });
try {
await api.uploadPlugin(file);
await api.uploadPlugin(file, {
onProgress: (pct) => setStatus({
text: `Uploading plugin… ${Math.round(pct * 100)}%`,
level: 'info',
progress: pct,
}),
});
// Node list refresh is handled by the nodes_updated WebSocket message.
} catch (err: any) {
setStatus({ text: err.message, level: 'error' });
@@ -2239,10 +2313,15 @@ function Flow() {
return () => document.removeEventListener('pointerdown', handler);
}, [menuOpen]);
// Auto-dismiss status toast after 5 seconds with close animation
// Auto-dismiss status toast after 5 seconds with close animation.
// Uploads in progress (progress < 1) pause the timer so the bar stays visible.
const [toastClosing, setToastClosing] = useState(false);
useEffect(() => {
if (!status.text) return;
if (status.progress != null && status.progress < 1) {
setToastClosing(false);
return;
}
setToastClosing(false);
const fadeTimer = setTimeout(() => setToastClosing(true), 4700);
const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000);
@@ -2492,6 +2571,13 @@ function Flow() {
<button className="btn" onClick={() => { openDocByFilename('getting-started.md'); closeMenu(); }} title="Getting started guide">
? Help
</button>
<button
className="btn"
onClick={onCycleTheme}
title={`Theme: ${theme} (click to cycle auto → light → dark)`}
>
{theme === 'auto' ? '◐' : theme === 'light' ? '☀' : '☾'} Theme: {theme}
</button>
<a className="btn" href="https://github.com/VIPQualityPost/tono/issues" target="_blank" rel="noopener noreferrer" onClick={closeMenu} title="Report a bug or request a feature">
Feedback
</a>
@@ -2512,7 +2598,17 @@ function Flow() {
{/* Status toast */}
{(status.text || toastClosing) && (
<div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>{status.text}</div>
<div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>
<div className="status-toast-text">{status.text}</div>
{status.progress != null && (
<div className="status-toast-progress">
<div
className="status-toast-progress-fill"
style={{ width: `${Math.max(0, Math.min(1, status.progress)) * 100}%` }}
/>
</div>
)}
</div>
)}
{/* React Flow canvas */}
@@ -2538,7 +2634,7 @@ function Flow() {
isValidConnection={isValidConnection}
nodeTypes={NODE_TYPES}
onPaneContextMenu={onPaneContextMenu}
colorMode="dark"
colorMode={resolvedTheme}
panOnDrag={[1]}
panOnScroll
panOnScrollSpeed={1.5}

View File

@@ -215,6 +215,7 @@ function GroupNode({ id, data }: { id: string; data: NodeData }) {
id={input.handleId}
className="typed-handle"
style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }}
isConnectableStart={false}
/>
<span className="io-label">{formatUiLabel(input.label || input.name)}</span>
</>
@@ -1109,11 +1110,24 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
for (const [name, spec] of Object.entries(optional)) {
const [type, opts] = getSpecTypeAndOptions(spec as InputSpec);
if (isProgressive && isDataSocketSpec(spec as InputSpec)) {
// Progressive: show this slot only if it's the first or the previous is connected
// Progressive: show this slot only if it's the first or the previous
// is connected. If the socket also carries `show_when_source_type`,
// the gating input must be connected to a matching source type before
// any slot in the chain is revealed — this lets Save's layer stack
// stay hidden until `value` is a DataField or Image.
const match = name.match(/^field_(\d+)$/);
if (match) {
const idx = parseInt(match[1], 10);
if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) {
const sourceTypeRules = opts?.show_when_source_type;
let sourceTypeOk = true;
if (sourceTypeRules && typeof sourceTypeRules === 'object') {
const gateInput = Object.keys(sourceTypeRules)[0];
const allowed = Array.isArray(sourceTypeRules[gateInput]) ? sourceTypeRules[gateInput] : [];
const actualSourceType = connectedSourceTypes?.[gateInput] ?? null;
sourceTypeOk = actualSourceType != null && allowed.includes(actualSourceType);
}
const progressiveOk = idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`));
if (sourceTypeOk && progressiveOk) {
dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) });
visibleInputNames.add(name);
}
@@ -1239,6 +1253,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
id={`input::${socketName}::${socketType}`}
className="typed-handle"
style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }}
isConnectableStart={false}
/>
)}
{(
@@ -1278,6 +1293,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
id={`input::${inp.name}::${inp.type}`}
className="typed-handle"
style={{ background: TYPE_COLORS[inp.type as string] || 'var(--fallback-type)' }}
isConnectableStart={false}
/>
<span className="io-label">{inp.label || inp.name}</span>
{inlineWidgetsByInput.has(inp.name) && (
@@ -1351,6 +1367,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
id={`input::${socketName}::${socketType}`}
className="typed-handle"
style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }}
isConnectableStart={false}
/>
)}
{(w.socketType && connectedInputs?.has(w.name))

View File

@@ -56,6 +56,34 @@ async function sessionFetch(input: string, init?: RequestInit) {
return fetch(input, withSessionHeaders(init));
}
/**
* XHR wrapper used for file uploads. Unlike fetch(), XHR exposes upload
* progress events, which the toast UI uses to draw a progress bar.
*/
function xhrRequest(
method: string,
url: string,
body: XMLHttpRequestBodyInit | null,
{
headers = {},
onProgress,
}: { headers?: Record<string, string>; onProgress?: (fraction: number) => void } = {},
): Promise<{ status: number; text: string }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
if (onProgress && xhr.upload) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
}
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText });
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(body);
});
}
export async function getNodes() {
const r = await sessionFetch('/nodes');
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
@@ -84,30 +112,40 @@ export async function createUploadFolder(relativePath: string) {
return r.json();
}
export async function uploadFile(file: File, { relativePath = '' } = {}) {
export async function uploadFile(
file: File,
{
relativePath = '',
onProgress,
}: { relativePath?: string; onProgress?: (fraction: number) => void } = {},
) {
const fd = new FormData();
if (relativePath) fd.append('relative_path', relativePath);
fd.append('file', file);
const r = await sessionFetch('/upload', { method: 'POST', body: fd });
if (!r.ok) {
const text = await r.text();
throw new Error(`Upload failed (${r.status}): ${text}`);
const { status, text } = await xhrRequest('POST', '/upload', fd, {
headers: { 'X-Argonode-Session': getSessionId() },
onProgress,
});
if (status < 200 || status >= 300) {
throw new Error(`Upload failed (${status}): ${text}`);
}
return r.json();
try { return JSON.parse(text); } catch { return {}; }
}
export async function uploadPlugin(file: File) {
export async function uploadPlugin(
file: File,
{ onProgress }: { onProgress?: (fraction: number) => void } = {},
) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/upload-plugin', { method: 'POST', body: fd });
if (r.status === 404) {
const { status, text } = await xhrRequest('POST', '/upload-plugin', fd, { onProgress });
if (status === 404) {
throw new Error('Plugin upload is not available in this build.');
}
if (!r.ok) {
const text = await r.text();
throw new Error(text || `Upload failed (${r.status})`);
if (status < 200 || status >= 300) {
throw new Error(text || `Upload failed (${status})`);
}
return r.json();
try { return JSON.parse(text); } catch { return {}; }
}
export async function getChannels(filepath: string) {

View File

@@ -11,34 +11,34 @@ export const DATA_TYPES = new Set([
export const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
export const TYPE_COLORS: Record<string, string> = {
DATA_FIELD: '#3a7abf',
IMAGE: '#00ff08a0',
LINE: '#ffbe5c',
RECORD_TABLE: '#35e2fd',
DATA_TABLE: '#ff7474',
COORD: '#e91ed1',
COORDPAIR: '#5cb861',
FLOAT: '#ab3197',
INT: '#ffffff',
ANNOTATION_SOURCE: '#06b6d4',
COLORMAP: '#f472b6',
MESH_MODEL: '#14b8a6',
FONT: '#fb7185',
FILE_PATH: '#f59e0b',
DIRECTORY: '#f97316',
DATA_FIELD: '#7d8bdc',
IMAGE: '#69cc6c',
LINE: '#ffb300',
RECORD_TABLE: '#cf6868',
DATA_TABLE: '#cbcd67',
COORD: '#bb65c2',
COORDPAIR: '#bababa',
FLOAT: '#76bcd4',
INT: '#cf8e8e',
ANNOTATION_SOURCE: '#79cab6',
COLORMAP: '#905454',
MESH_MODEL: '#6e659e',
FONT: '#cccf7f',
FILE_PATH: '#b87f7f',
DIRECTORY: '#90d294',
};
export const CAT_COLORS: Record<string, string> = {
Input: '#37474f',
Display: '#212121',
Overlay: '#0f766e',
Geometry: '#0d9488',
Filter: '#1a237e',
Spectral: '#4c1d95',
'Level & Correct': '#1b5e20',
Measure: '#4a148c',
Mask: '#7c2d12',
Grains: '#bf360c',
Input: '#2c4b31',
Display: '#5f4e35',
Overlay: '#214844',
Geometry: '#3c2a46',
Filter: '#34375a',
Spectral: '#5f4938',
'Level & Correct': '#553636',
Measure: '#382f43',
Mask: '#4d3c2a',
Grains: '#5a4703',
};
export const SOCKET_COMPATIBILITY: Record<string, Set<string>> = {

View File

@@ -1,6 +1,9 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { initTheme } from './theme';
import './styles.css';
initTheme();
createRoot(document.getElementById('root')!).render(<App />);

View File

@@ -1,4 +1,10 @@
/* ── Theme tokens ──────────────────────────────────────────────────── */
/*
* The default :root block defines the dark palette. A light override
* lives in :root[data-theme="light"] below. The active theme is selected
* at runtime by theme.ts, which sets data-theme on <html> to either
* "light" or "dark" (auto mode resolves via prefers-color-scheme).
*/
:root {
/* Backgrounds */
--bg-app: #1a1a2e;
@@ -22,6 +28,7 @@
--text-primary: #e0e0e0;
--text-bright: #e2e8f0;
--text-heading: #ffffff;
--text-node-title: #ffffff;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-faint: #475569;
@@ -45,8 +52,13 @@
--danger: #e94560;
--danger-hover: #ff6b81;
--danger-locked: #e91e63;
--danger-outline: #ef4444;
--danger-outline-glow: rgba(239, 68, 68, 0.35);
--error-text: #ef9a9a;
--error-bg: rgba(183, 28, 28, 0.2);
--error-text-2: #fca5a5;
--error-bg-2: rgba(239, 68, 68, 0.12);
--error-border-2: rgba(239, 68, 68, 0.3);
/* Warning */
--warning: #fbbf24;
@@ -120,6 +132,212 @@
/* Dynamic-lookup fallbacks */
--fallback-type: #999;
--fallback-cat: #333;
/* Group node extras */
--bg-group-title: #334155;
--bg-overlay-input: rgba(15, 23, 42, 0.7);
--text-watermark: rgba(148, 163, 184, 0.58);
/* Help panel */
--bg-help-tabs: #0a0f1a;
--bg-help-tab: #0f172a;
--bg-help-tab-hover: #162032;
--bg-help-tab-active: #1e293b;
--bg-help-panel: #1e293b;
--bg-help-textarea: #0d1624;
--border-help-tab-add:#334155;
--link-color: #ff9800;
--link-hover: #ffb74d;
/* Canvas overlay chips (badges, pills, floating widgets) */
--overlay-chip-bg: rgba(15, 23, 42, 0.86);
--overlay-chip-bg-strong: rgba(15, 23, 42, 0.9);
--overlay-chip-bg-hover: rgba(30, 41, 59, 0.94);
--overlay-chip-border: rgba(148, 163, 184, 0.42);
--overlay-chip-border-hover: rgba(125, 211, 252, 0.55);
--overlay-chip-shadow: rgba(2, 6, 23, 0.28);
/* Node-title help button (sits on category-coloured title bar) */
--node-help-btn-bg: rgba(255, 255, 255, 0.12);
--node-help-btn-bg-hover: rgba(255, 255, 255, 0.28);
--node-help-btn-border: rgba(255, 255, 255, 0.25);
--node-help-btn-border-hover:rgba(255, 255, 255, 0.5);
--node-help-btn-text: rgba(255, 255, 255, 0.75);
/* Text note content overlays (on top of coloured text notes) */
--note-code-bg: rgba(0, 0, 0, 0.3);
--note-textarea-bg: rgba(0, 0, 0, 0.25);
--note-hr: rgba(255, 255, 255, 0.1);
--note-quote-border: rgba(255, 255, 255, 0.2);
--note-active-ring: rgba(255, 255, 255, 0.7);
}
/* Light palette — applied when <html data-theme="light"> */
:root[data-theme="light"] {
/* Backgrounds — warm palette: eggshell canvas, tan node bodies */
--bg-app: #e8e0c6;
--bg-toolbar: #ede5cc;
--bg-canvas: #f0e9d4; /* eggshell */
--bg-surface: #e4d6b4; /* tan (node body) */
--bg-deep: #d4c29e; /* deeper tan (widget wells) */
--bg-panel: #e0d7bc;
--bg-backdrop: rgba(60, 50, 20, 0.35);
--bg-overlay-dim: rgba(60, 50, 20, 0.18);
/* Borders — warm tan-gray to blend with the warm surfaces */
--border-default: #b8ab87;
--border-strong: #2563eb;
--border-toolbar: #b8ab87;
--border-subtle: rgba(120, 105, 70, 0.28);
--border-table: rgba(120, 105, 70, 0.55);
--border-title: rgba(60, 50, 20, 0.2);
/* Text */
--text-primary: #2a2318;
--text-bright: #1a1710;
--text-heading: #1a1710; /* for text on tan/eggshell surfaces */
--text-node-title: #ffffff; /* stays white — sits on colored title bars */
--text-secondary: #5a4e36;
--text-muted: #726544;
--text-faint: #9c8f6a;
--text-disabled: #9c8f6a;
--text-table: #3a301c;
--text-value: #3e2e10;
--text-value-unit: rgba(62, 46, 16, 0.78);
/* Accent */
--accent: #2563eb;
--accent-bg: #dbeafe;
--accent-hover: #bfdbfe;
--accent-pressed: #93c5fd;
--accent-light: #1d4ed8;
--accent-lighter: #0284c7;
--accent-lightest: #0369a1;
--accent-deep: #eff6ff;
--accent-deep-text:#1e3a8a;
/* Danger */
--danger: #dc2626;
--danger-hover: #ef4444;
--danger-locked: #be123c;
--danger-outline: #dc2626;
--danger-outline-glow: rgba(220, 38, 38, 0.35);
--error-text: #991b1b;
--error-bg: rgba(220, 38, 38, 0.12);
--error-text-2: #b91c1c;
--error-bg-2: rgba(220, 38, 38, 0.1);
--error-border-2: rgba(220, 38, 38, 0.35);
/* Warning */
--warning: #b45309;
--warning-bg: rgba(251, 191, 36, 0.2);
--warning-border: rgba(180, 83, 9, 0.35);
/* Selection */
--selection: #2563eb;
--selection-glow: rgba(37, 99, 235, 0.3);
--selection-edge: rgba(37, 99, 235, 0.55);
/* Marker / cursor */
--marker: #ca8a04;
--marker-active: #eab308;
--marker-border: #1a1710;
--marker-shadow: rgba(60, 50, 20, 0.4);
--marker-shadow-light: rgba(60, 50, 20, 0.25);
/* Plot */
--plot-line: #ea580c;
/* Linked state */
--linked-border: rgba(190, 24, 93, 0.55);
--linked-bg: rgba(251, 232, 220, 0.85);
--linked-text: #9d174d;
/* Value display */
--value-label: #3e2e10;
--value-border: rgba(62, 46, 16, 0.45);
--value-grad-top: rgba(120, 85, 30, 0.08);
--value-grad-bot: rgba(80, 60, 20, 0.14);
--value-grad-a: rgba(160, 120, 50, 0.08);
--value-grad-b: rgba(200, 160, 80, 0.05);
--value-shadow-in: rgba(255, 248, 220, 0.6);
--value-shadow: rgba(80, 60, 20, 0.12);
/* Node title meta */
--meta-bg: rgba(60, 50, 20, 0.1);
--meta-text: rgba(255, 255, 255, 0.92);
/* Mask paint cursor */
--mask-cursor-border: rgba(60, 50, 20, 0.9);
--mask-cursor-bg: rgba(60, 50, 20, 0.08);
--mask-cursor-ring: rgba(220, 38, 38, 0.85);
--mask-cursor-shadow: rgba(184, 171, 135, 0.4);
/* Shadows */
--shadow-heavy: rgba(60, 50, 20, 0.22);
/* Gallery */
--gallery-name-border: rgba(184, 171, 135, 0.8);
--gallery-name-bg: rgba(240, 233, 212, 0.92);
/* Benchmark */
--benchmark-border: rgba(120, 105, 70, 0.32);
--benchmark-bg: rgba(245, 238, 219, 0.94);
/* Markup toolbar */
--markup-btn-border: rgba(120, 105, 70, 0.38);
--markup-btn-bg: rgba(245, 238, 219, 0.94);
/* Table */
--table-stripe: rgba(120, 105, 70, 0.15);
/* Crop */
--crop-inset: rgba(60, 50, 20, 0.28);
/* Shape default */
--shape-default: #dc2626;
/* Dynamic-lookup fallbacks */
--fallback-type: #9c8f6a;
--fallback-cat: #b8ab87;
/* Group node extras */
--bg-group-title: #b8ab87;
--bg-overlay-input: rgba(240, 233, 212, 0.85);
--text-watermark: rgba(90, 78, 54, 0.55);
/* Help panel */
--bg-help-tabs: #d4c29e;
--bg-help-tab: #e0d3ad;
--bg-help-tab-hover: #d4c29e;
--bg-help-tab-active: #f0e9d4;
--bg-help-panel: #f0e9d4;
--bg-help-textarea: #e8dcc0;
--border-help-tab-add:#b8ab87;
--link-color: #c2410c;
--link-hover: #9a3412;
/* Canvas overlay chips */
--overlay-chip-bg: rgba(240, 233, 212, 0.94);
--overlay-chip-bg-strong: rgba(240, 233, 212, 0.97);
--overlay-chip-bg-hover: rgba(228, 214, 180, 0.96);
--overlay-chip-border: rgba(120, 105, 70, 0.42);
--overlay-chip-border-hover: rgba(194, 65, 12, 0.55);
--overlay-chip-shadow: rgba(60, 50, 20, 0.16);
/* Node-title help button (still sits on a coloured title bar) */
--node-help-btn-bg: rgba(255, 255, 255, 0.35);
--node-help-btn-bg-hover: rgba(255, 255, 255, 0.55);
--node-help-btn-border: rgba(255, 255, 255, 0.5);
--node-help-btn-border-hover:rgba(255, 255, 255, 0.75);
--node-help-btn-text: rgba(255, 255, 255, 0.9);
/* Text note content overlays */
--note-code-bg: rgba(60, 50, 20, 0.1);
--note-textarea-bg: rgba(60, 50, 20, 0.05);
--note-hr: rgba(60, 50, 20, 0.14);
--note-quote-border: rgba(60, 50, 20, 0.22);
--note-active-ring: rgba(60, 50, 20, 0.6);
}
/* ── Reset & base ──────────────────────────────────────────────────── */
@@ -279,8 +497,12 @@ html, body, #root {
background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar);
max-width: 60%;
min-width: 240px;
text-align: center;
animation: toast-in 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 5px;
}
.status-toast.closing {
animation: toast-out 0.3s ease-in forwards;
@@ -288,6 +510,19 @@ html, body, #root {
.status-toast.info { color: var(--accent-light); }
.status-toast.error { color: var(--error-text); background: var(--error-bg); }
.status-toast-progress {
height: 3px;
width: 100%;
background: rgba(127, 127, 127, 0.22);
border-radius: 2px;
overflow: hidden;
}
.status-toast-progress-fill {
height: 100%;
background: var(--accent-light);
transition: width 0.12s linear;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
@@ -343,7 +578,7 @@ html, body, #root {
}
.group-node-title {
background: #334155;
background: var(--bg-group-title);
}
.group-node-title .node-title-main {
@@ -384,7 +619,7 @@ html, body, #root {
padding: 2px 6px;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 4px;
background: rgba(15, 23, 42, 0.72);
background: var(--bg-overlay-input);
color: var(--text-heading);
font: inherit;
}
@@ -398,7 +633,7 @@ html, body, #root {
.group-toggle {
border: 0;
background: rgba(15, 23, 42, 0.65);
background: var(--bg-overlay-input);
color: var(--text-heading);
border-radius: 4px;
padding: 2px 8px;
@@ -447,7 +682,7 @@ html, body, #root {
position: absolute;
top: 10px;
left: 12px;
color: rgba(148, 163, 184, 0.58);
color: var(--text-watermark);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: lowercase;
@@ -497,7 +732,7 @@ html, body, #root {
gap: 8px;
font-weight: 600;
font-size: 12px;
color: var(--text-heading);
color: var(--text-node-title);
border-radius: 5px 5px 0 0;
border-bottom: 1px solid var(--border-title);
}
@@ -537,9 +772,9 @@ html, body, #root {
width: 15px;
height: 15px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.75);
background: var(--node-help-btn-bg);
border: 1px solid var(--node-help-btn-border);
color: var(--node-help-btn-text);
font-size: 9px;
font-weight: 700;
line-height: 1;
@@ -553,8 +788,8 @@ html, body, #root {
}
.node-help-btn:hover {
background: rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.5);
background: var(--node-help-btn-bg-hover);
border-color: var(--node-help-btn-border-hover);
}
/* ── Node help panel ─────────────────────────────────────── */
@@ -564,15 +799,15 @@ html, body, #root {
flex-wrap: wrap;
gap: 2px;
padding: 6px 8px 0;
background: #0a0f1a;
border-bottom: 1px solid #1e293b;
background: var(--bg-help-tabs);
border-bottom: 1px solid var(--bg-help-tab-active);
flex-shrink: 0;
}
.node-help-fold-btn {
background: none;
border: none;
color: #64748b;
color: var(--text-muted);
font-size: 9px;
padding: 0 4px;
cursor: pointer;
@@ -581,7 +816,7 @@ html, body, #root {
align-self: center;
transition: color 0.12s;
}
.node-help-fold-btn:hover { color: #f1f5f9; }
.node-help-fold-btn:hover { color: var(--text-heading); }
.node-help-tab {
display: flex;
@@ -589,23 +824,23 @@ html, body, #root {
gap: 4px;
padding: 4px 8px;
border-radius: 5px 5px 0 0;
background: #0f172a;
border: 1px solid #1e293b;
background: var(--bg-help-tab);
border: 1px solid var(--bg-help-tab-active);
border-bottom: none;
cursor: pointer;
font-size: 11px;
color: #64748b;
color: var(--text-muted);
transition: color 0.12s, background 0.12s;
user-select: none;
max-width: 160px;
}
.node-help-tab:hover { color: #94a3b8; background: #162032; }
.node-help-tab:hover { color: var(--text-secondary); background: var(--bg-help-tab-hover); }
.node-help-tab.active {
color: #f1f5f9;
background: #1e293b;
border-color: #334155;
color: var(--text-heading);
background: var(--bg-help-tab-active);
border-color: var(--border-default);
}
.node-help-tab-label {
@@ -630,8 +865,8 @@ html, body, #root {
.node-help-tab-add {
background: none;
border: 1px dashed #334155;
color: #475569;
border: 1px dashed var(--border-help-tab-add);
color: var(--text-faint);
font-size: 13px;
line-height: 1;
width: 22px;
@@ -645,7 +880,7 @@ html, body, #root {
flex-shrink: 0;
transition: color 0.12s, border-color 0.12s;
}
.node-help-tab-add:hover { color: #f1f5f9; border-color: #64748b; }
.node-help-tab-add:hover { color: var(--text-heading); border-color: var(--text-muted); }
.node-help-panel {
position: fixed;
@@ -653,10 +888,10 @@ html, body, #root {
right: 20px;
width: 620px;
max-height: calc(100vh - 32px);
background: #1e293b;
border: 1px solid #334155;
background: var(--bg-help-panel);
border: 1px solid var(--border-default);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
box-shadow: 0 8px 32px var(--shadow-heavy);
display: flex;
flex-direction: column;
z-index: 9999;
@@ -676,33 +911,33 @@ html, body, #root {
align-items: center;
gap: 8px;
padding: 5px 10px;
border-bottom: 1px solid #1e293b;
border-bottom: 1px solid var(--bg-help-tab-active);
flex-shrink: 0;
}
.node-help-journal-toggle {
background: #0f172a;
border: 1px solid #334155;
color: #94a3b8;
background: var(--bg-help-tab);
border: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.node-help-journal-toggle:hover { color: #f1f5f9; border-color: #475569; }
.node-help-journal-toggle:hover { color: var(--text-heading); border-color: var(--text-faint); }
.node-help-journal-hint {
font-size: 10px;
color: #475569;
color: var(--text-faint);
}
.node-help-journal-textarea {
flex: 1;
background: #0d1624;
background: var(--bg-help-textarea);
border: none;
outline: none;
color: #e2e8f0;
color: var(--text-bright);
font-family: monospace;
font-size: 12px;
line-height: 1.6;
@@ -718,7 +953,7 @@ html, body, #root {
}
.node-help-journal-placeholder {
color: #475569;
color: var(--text-faint);
font-style: italic;
}
@@ -727,14 +962,14 @@ html, body, #root {
overflow-y: auto;
flex: 1;
font-size: 12.5px;
color: #cbd5e1;
color: var(--text-table);
line-height: 1.65;
}
.node-help-panel-body h1,
.node-help-panel-body h2,
.node-help-panel-body h3 {
color: #f1f5f9;
color: var(--text-heading);
margin: 14px 0 5px;
font-weight: 600;
}
@@ -754,23 +989,23 @@ html, body, #root {
.node-help-panel-body th,
.node-help-panel-body td {
border: 1px solid #334155;
border: 1px solid var(--border-default);
padding: 4px 8px;
text-align: left;
}
.node-help-panel-body th {
background: #0f172a;
color: #94a3b8;
background: var(--bg-help-tab);
color: var(--text-secondary);
font-weight: 600;
}
.node-help-panel-body code {
background: #0f172a;
background: var(--bg-help-tab);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
color: #7dd3fc;
color: var(--accent-lighter);
}
.node-help-panel-body ul,
@@ -781,12 +1016,12 @@ html, body, #root {
.node-help-panel-body li { margin: 2px 0; }
.node-help-panel-body em { color: #94a3b8; }
.node-help-panel-body em { color: var(--text-secondary); }
.node-help-panel-body strong { color: #e2e8f0; }
.node-help-panel-body strong { color: var(--text-bright); }
.node-help-panel-body a { color: #ff9800; }
.node-help-panel-body a:hover { color: #ffb74d; }
.node-help-panel-body a { color: var(--link-color); }
.node-help-panel-body a:hover { color: var(--link-hover); }
/* ── Help panel TOC + content layout ──────────────────────────────── */
@@ -806,10 +1041,10 @@ html, body, #root {
width: 160px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid #1e293b;
border-right: 1px solid var(--bg-help-tab-active);
padding: 8px 0;
font-size: 11px;
background: #0f172a;
background: var(--bg-help-tab);
}
.help-toc-root { padding: 0; }
@@ -833,7 +1068,7 @@ html, body, #root {
.help-toc-arrow {
background: none;
border: none;
color: #475569;
color: var(--text-faint);
font-size: 7px;
padding: 0;
width: 12px;
@@ -843,7 +1078,7 @@ html, body, #root {
line-height: 1;
transition: color 0.12s;
}
.help-toc-arrow:hover { color: #94a3b8; }
.help-toc-arrow:hover { color: var(--text-secondary); }
.help-toc-arrow-spacer {
display: inline-block;
@@ -852,7 +1087,7 @@ html, body, #root {
}
.help-toc-link {
color: #94a3b8;
color: var(--text-secondary);
text-decoration: none;
padding: 2px 6px 2px 0;
display: block;
@@ -862,7 +1097,7 @@ html, body, #root {
white-space: nowrap;
transition: color 0.12s;
}
.help-toc-link:hover { color: #f1f5f9; }
.help-toc-link:hover { color: var(--text-heading); }
.node-body {
padding: 4px 0;
@@ -886,18 +1121,18 @@ html, body, #root {
}
.custom-node.node-error {
outline: 2px solid #ef4444;
outline: 2px solid var(--danger-outline);
outline-offset: -1px;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.35);
box-shadow: 0 0 8px var(--danger-outline-glow);
}
.node-error-message {
padding: 3px 10px;
font-size: 10px;
color: #fca5a5;
background: rgba(239, 68, 68, 0.12);
border-top: 1px solid rgba(239, 68, 68, 0.3);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
color: var(--error-text-2);
background: var(--error-bg-2);
border-top: 1px solid var(--error-border-2);
border-bottom: 1px solid var(--error-border-2);
}
.node-value-display {
@@ -1415,6 +1650,15 @@ html, body, #root {
border-radius: 6px;
}
:root[data-theme="light"] .angle-overlay {
--angle-line-color: #c2410c;
--angle-arc-color: #ea580c;
--angle-end-handle-color: #c2410c;
--angle-mid-handle-color: #9a3412;
--angle-badge-text-color: #9a3412;
--angle-badge-border-color: #c2410c;
}
.angle-image {
width: 100%;
display: block;
@@ -1480,13 +1724,13 @@ html, body, #root {
transform: translate(-50%, -50%);
padding: 3px 7px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.9);
background: var(--overlay-chip-bg-strong);
border: 1px solid var(--angle-badge-border-color);
color: var(--angle-badge-text-color);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.35);
box-shadow: 0 2px 8px var(--overlay-chip-shadow);
cursor: grab;
user-select: none;
z-index: 1;
@@ -1720,21 +1964,21 @@ html, body, #root {
z-index: 2;
min-width: 54px;
padding: 4px 10px;
border: 1px solid rgba(148, 163, 184, 0.42);
border: 1px solid var(--overlay-chip-border);
border-radius: 999px;
background: rgba(15, 23, 42, 0.86);
background: var(--overlay-chip-bg);
color: var(--text-bright);
font-size: 10px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
backdrop-filter: blur(8px);
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.28);
box-shadow: 0 4px 14px var(--overlay-chip-shadow);
}
.surface-view-home:hover {
background: rgba(30, 41, 59, 0.94);
border-color: rgba(125, 211, 252, 0.55);
background: var(--overlay-chip-bg-hover);
border-color: var(--overlay-chip-border-hover);
}
.surface-view-diagnostics {
@@ -1745,8 +1989,8 @@ html, body, #root {
max-width: calc(100% - 84px);
padding: 7px 8px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.82);
color: rgba(226, 232, 240, 0.92);
background: var(--overlay-chip-bg);
color: var(--text-bright);
font-family: "SF Mono", "Fira Code", monospace;
font-size: 9px;
line-height: 1.35;
@@ -1945,7 +2189,7 @@ html, body, #root {
}
.text-note-color-btn:hover { transform: scale(1.25); }
.text-note-color-btn.active {
border-color: rgba(255,255,255,0.7);
border-color: var(--note-active-ring);
}
.text-note-fold-btn {
background: none;
@@ -1987,15 +2231,15 @@ html, body, #root {
.text-note-content code {
font-family: monospace;
font-size: 11px;
background: rgba(0,0,0,0.3);
background: var(--note-code-bg);
border-radius: 3px;
padding: 1px 4px;
}
.text-note-content strong { font-weight: 600; }
.text-note-content em { font-style: italic; opacity: 0.85; }
.text-note-content hr { border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 8px 0; }
.text-note-content hr { border: none; border-top: 1px solid var(--note-hr); margin: 8px 0; }
.text-note-content blockquote {
border-left: 3px solid rgba(255,255,255,0.2);
border-left: 3px solid var(--note-quote-border);
margin: 4px 0;
padding-left: 10px;
opacity: 0.8;
@@ -2003,7 +2247,7 @@ html, body, #root {
.text-note-placeholder { color: var(--text-faint); font-style: italic; }
.text-note-textarea {
flex: 1;
background: rgba(0,0,0,0.25);
background: var(--note-textarea-bg);
border: none;
outline: none;
color: inherit;

92
frontend/src/theme.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* Theme manager. Three user-visible modes:
* - 'light' — force light palette
* - 'dark' — force dark palette
* - 'auto' — follow the OS's prefers-color-scheme (default)
*
* The active palette is selected by setting data-theme on <html> to either
* 'light' or 'dark'. auto mode resolves via matchMedia and re-applies on
* system changes. The user's chosen mode is persisted in localStorage.
*/
export type Theme = 'light' | 'dark' | 'auto';
const STORAGE_KEY = 'tono_theme';
const systemMedia = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: light)')
: null;
export function getStoredTheme(): Theme {
if (typeof localStorage === 'undefined') return 'auto';
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
return 'auto';
}
export function setStoredTheme(theme: Theme): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, theme);
}
/** Resolve a Theme (possibly 'auto') to a concrete palette. */
export function resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme === 'auto') {
return systemMedia?.matches ? 'light' : 'dark';
}
return theme;
}
/** Write data-theme on <html>, which drives the CSS overrides. */
export function applyTheme(theme: Theme): void {
if (typeof document === 'undefined') return;
const resolved = resolveTheme(theme);
document.documentElement.dataset.theme = resolved;
}
type ThemeListener = (theme: Theme, resolved: 'light' | 'dark') => void;
const listeners = new Set<ThemeListener>();
/**
* Initialise theming on startup. Reads the stored preference, applies it,
* and wires up a listener so that 'auto' mode tracks OS changes at runtime.
* Call once, as early as possible (before first paint) from the entry point.
*/
export function initTheme(): Theme {
const theme = getStoredTheme();
applyTheme(theme);
if (systemMedia) {
systemMedia.addEventListener('change', () => {
if (getStoredTheme() === 'auto') {
applyTheme('auto');
for (const cb of listeners) cb('auto', resolveTheme('auto'));
}
});
}
return theme;
}
/** Change the theme (persist + apply + notify subscribers). */
export function setTheme(theme: Theme): void {
setStoredTheme(theme);
applyTheme(theme);
const resolved = resolveTheme(theme);
for (const cb of listeners) cb(theme, resolved);
}
/** Cycle auto → light → dark → auto. Returns the new value. */
export function cycleTheme(): Theme {
const order: Theme[] = ['auto', 'light', 'dark'];
const current = getStoredTheme();
const next = order[(order.indexOf(current) + 1) % order.length];
setTheme(next);
return next;
}
/** Subscribe to theme changes. Returns an unsubscribe function. */
export function subscribeTheme(cb: ThemeListener): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}

View File

@@ -11,6 +11,7 @@
"clean:build": "node scripts/clean-build-artifacts.mjs",
"clean:native": "node scripts/clean-build-artifacts.mjs --mode=native",
"dev": "npm run clean:dev && npm --prefix frontend run dev",
"dev:all": "bash scripts/dev.sh",
"build": "npm run clean:build && npm --prefix frontend run build",
"preview": "npm --prefix frontend run preview",
"test:frontend": "npm --prefix frontend test",

22
scripts/dev.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Launch the Python backend and the Vite frontend dev server together.
# Press Ctrl-C to stop both.
set -m
cd "$(dirname "$0")/.."
cleanup() {
trap - INT TERM EXIT
for pid in $(jobs -p); do
kill -TERM "-$pid" 2>/dev/null || true
done
wait 2>/dev/null
}
trap cleanup INT TERM EXIT
python -m backend.main &
npm run dev &
while (( $(jobs -pr | wc -l) == 2 )); do
sleep 0.5
done

View File

@@ -2,7 +2,7 @@
Generate test images and their FFT outputs for visual comparison with Gwyddion.
Saves PNG files to tests/output/.
Run: .venv/bin/python -m tests.test_fft_visual
Run from project root: .venv/bin/python scripts/fft_visual.py
"""
import sys
import os
@@ -12,7 +12,7 @@ sys.path.insert(0, ".")
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.nodes.fft_2d import FFT2D
OUT_DIR = os.path.join(os.path.dirname(__file__), "output")
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "output")
os.makedirs(OUT_DIR, exist_ok=True)

View File

@@ -1,14 +0,0 @@
def test_coordinate():
from backend.nodes.coordinate import Coordinate
node = Coordinate()
result = node.process(x=0.3, y=0.7)
assert len(result) == 1
assert result[0] == (0.3, 0.7)
result_zero = node.process(x=0.0, y=0.0)
assert result_zero[0] == (0.0, 0.0)
result_one = node.process(x=1.0, y=1.0)
assert result_one[0] == (1.0, 1.0)

View File

@@ -0,0 +1,568 @@
"""
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)
# 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["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():
"""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_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
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)

View File

@@ -1,10 +0,0 @@
def test_number():
from backend.nodes.number import Number
node = Number()
result = node.process(value=1.25)
assert result == (1.25,)
result_neg = node.process(value=-3.5)
assert result_neg == (-3.5,)

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

View File

@@ -10,7 +10,6 @@ import numpy as np
sys.path.insert(0, ".")
from backend.data_types import DataField
from backend.nodes.fft_2d import FFT2D
from backend.nodes.fft_2d_inverse import FFT2DInverse
def make_field(data, xreal=1e-6, yreal=1e-6):
@@ -247,91 +246,6 @@ def test_log_magnitude_visual_range():
print(" PASS\n")
def test_inverse_fft_reconstructs_from_magnitude_and_phase():
"""Magnitude + phase from FFT2D should reconstruct the original image."""
print("=== Test: Inverse FFT from magnitude + phase ===")
rng = np.random.default_rng(123)
data = rng.standard_normal((64, 96))
field = make_field(data, xreal=2.4e-6, yreal=1.6e-6)
fft_node = FFT2D()
ifft_node = FFT2DInverse()
_, magnitude, phase, _ = fft_node.process(field, windowing="none", level="none")
reconstructed, = ifft_node.process(magnitude, representation="magnitude", phase=phase)
max_err = np.max(np.abs(reconstructed.data - field.data))
print(f" Reconstruction max error: {max_err:.3e}")
assert reconstructed.domain == "spatial"
assert reconstructed.data.shape == field.data.shape
assert np.isclose(reconstructed.xreal, field.xreal)
assert np.isclose(reconstructed.yreal, field.yreal)
assert max_err < 1e-9, f"Expected near-exact reconstruction, got {max_err}"
print(" PASS\n")
def test_inverse_fft_reconstructs_from_log_magnitude_and_phase():
"""log(|F|) + phase should also reconstruct after expm1 inversion."""
print("=== Test: Inverse FFT from log magnitude + phase ===")
y, x = np.mgrid[0:72, 0:80] / 80.0
data = (
0.8 * np.sin(2 * np.pi * 6 * x)
+ 0.35 * np.cos(2 * np.pi * 9 * y)
+ 0.15 * np.sin(2 * np.pi * (4 * x + 3 * y))
)
field = make_field(data, xreal=1.6e-6, yreal=1.44e-6)
fft_node = FFT2D()
ifft_node = FFT2DInverse()
log_magnitude, _, phase, _ = fft_node.process(field, windowing="none", level="none")
reconstructed, = ifft_node.process(log_magnitude, representation="log_magnitude", phase=phase)
rms_err = np.sqrt(np.mean((reconstructed.data - field.data) ** 2))
print(f" Reconstruction RMS error: {rms_err:.3e}")
assert rms_err < 1e-9, f"Expected near-exact reconstruction, got {rms_err}"
print(" PASS\n")
def test_inverse_fft_reconstructs_from_psdf_and_phase():
"""PSDF + phase should reconstruct after undoing PSDF scaling."""
print("=== Test: Inverse FFT from PSDF + phase ===")
rng = np.random.default_rng(321)
data = rng.standard_normal((48, 64))
field = make_field(data, xreal=3.2e-6, yreal=2.4e-6)
fft_node = FFT2D()
ifft_node = FFT2DInverse()
_, _, phase, psdf = fft_node.process(field, windowing="none", level="none")
reconstructed, = ifft_node.process(psdf, representation="psdf", phase=phase)
max_err = np.max(np.abs(reconstructed.data - field.data))
print(f" Reconstruction max error: {max_err:.3e}")
assert reconstructed.si_unit_z == field.si_unit_z
assert max_err < 1e-8, f"Expected near-exact reconstruction, got {max_err}"
print(" PASS\n")
def test_inverse_fft_zero_phase_mode_returns_valid_image():
"""Spectrum-only inversion should return a finite spatial image with the right shape."""
print("=== Test: Inverse FFT zero-phase mode ===")
data = np.sin(2 * np.pi * 5 * np.mgrid[0:64, 0:64][1] / 64.0)
field = make_field(data, xreal=1e-6, yreal=1e-6)
fft_node = FFT2D()
ifft_node = FFT2DInverse()
_, magnitude, _, _ = fft_node.process(field, windowing="none", level="none")
reconstructed, = ifft_node.process(magnitude, representation="magnitude")
print(f" Output shape: {reconstructed.data.shape}")
assert reconstructed.domain == "spatial"
assert reconstructed.data.shape == field.data.shape
assert np.all(np.isfinite(reconstructed.data))
print(" PASS\n")
if __name__ == "__main__":
test_dc_removal()
test_single_frequency()
@@ -341,8 +255,4 @@ if __name__ == "__main__":
test_plane_subtraction()
test_non_square()
test_log_magnitude_visual_range()
test_inverse_fft_reconstructs_from_magnitude_and_phase()
test_inverse_fft_reconstructs_from_log_magnitude_and_phase()
test_inverse_fft_reconstructs_from_psdf_and_phase()
test_inverse_fft_zero_phase_mode_returns_valid_image()
print("All tests passed!")