Compare commits
10 Commits
561501259b
...
1d98ccf190
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d98ccf190 | |||
| c38c2dc29a | |||
| 08aff81f02 | |||
| 0f9b500c34 | |||
| c6096b53a8 | |||
| b8d5c11ee9 | |||
| 2d9e1a1ecf | |||
| 591186bc14 | |||
| ce10edd9cd | |||
| d4ca88f108 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
128
backend/exporters/__init__.py
Normal file
128
backend/exporters/__init__.py
Normal 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",
|
||||
]
|
||||
60
backend/exporters/_base.py
Normal file
60
backend/exporters/_base.py
Normal 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"]
|
||||
365
backend/exporters/datafield.py
Normal file
365
backend/exporters/datafield.py
Normal 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
129
backend/exporters/image.py
Normal 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
182
backend/exporters/line.py
Normal 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
60
backend/exporters/mesh.py
Normal 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")
|
||||
28
backend/exporters/scalar.py
Normal file
28
backend/exporters/scalar.py
Normal 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.")
|
||||
44
backend/exporters/table.py
Normal file
44
backend/exporters/table.py
Normal 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.")
|
||||
@@ -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:
|
||||
|
||||
@@ -14,8 +14,8 @@ from typing import Any
|
||||
MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"Input": [
|
||||
"Image",
|
||||
"Folder",
|
||||
"ImageDemo",
|
||||
"Folder",
|
||||
"SyntheticSurface",
|
||||
"Note",
|
||||
"TextNote",
|
||||
@@ -33,7 +33,6 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"ValueIO",
|
||||
"PrintTable",
|
||||
"Save",
|
||||
"SaveImage",
|
||||
"Shade",
|
||||
"PresentationOps",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__}")
|
||||
@@ -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 |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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
92
frontend/src/theme.ts
Normal 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);
|
||||
}
|
||||
@@ -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
22
scripts/dev.sh
Executable 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
568
tests/node_tests/exporters.py
Normal file
568
tests/node_tests/exporters.py
Normal 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)
|
||||
@@ -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,)
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
|
||||
Reference in New Issue
Block a user