Compare commits

..

22 Commits

Author SHA1 Message Date
d4c5cf4670 add a few more nodes
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-05-18 20:55:46 -07:00
92ede31867 align node menu
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-16 22:43:12 -07:00
d35cdd6971 fix perspective correction 2026-04-16 22:41:56 -07:00
a4c8d2b01c clean up node menu 2026-04-16 21:37:03 -07:00
924b29757f add favorites 2026-04-16 19:13:32 -07:00
ad48a40edc fix node menu ordering 2026-04-16 01:21:14 -07:00
c7e7531206 fix multi-profile 2026-04-16 01:14:57 -07:00
2d66eaef02 work on straighten path 2026-04-16 00:52:49 -07:00
9fbd305854 add masking to stats 2026-04-16 00:06:15 -07:00
31422e76db add rect masking 2026-04-15 23:58:34 -07:00
349142f0e6 update docs and tests 2026-04-15 23:21:08 -07:00
0bf001c24b add radial profile 2026-04-15 23:01:47 -07:00
1d98ccf190 fix naming on layer 1
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-06 23:01:55 -07:00
c38c2dc29a combine save and save layers 2026-04-05 14:12:34 -07:00
08aff81f02 dimensioned export (gwy, HDF5) 2026-04-05 13:28:26 -07:00
0f9b500c34 fix image demo carousel 2026-04-05 00:02:17 -07:00
c6096b53a8 loading bar for file uploads 2026-04-04 23:26:30 -07:00
b8d5c11ee9 simplify tests 2026-04-04 23:14:17 -07:00
2d9e1a1ecf prevent dragging out of inputs 2026-04-04 22:48:16 -07:00
591186bc14 rework colors 2026-04-04 22:43:32 -07:00
ce10edd9cd add light mode and auto theme selector 2026-04-04 22:33:56 -07:00
d4ca88f108 fix first time experience 2026-04-04 21:48:08 -07:00
80 changed files with 5284 additions and 992 deletions

View File

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

View File

@@ -492,7 +492,7 @@ class ExecutionEngine:
return return
if cls in (Image, ImageDemo) and on_preview: 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: if preview:
on_preview(node_id, preview) on_preview(node_id, preview)
return return
@@ -539,17 +539,23 @@ class ExecutionEngine:
def _render_load_node_preview( def _render_load_node_preview(
self, self,
cls: type,
result: tuple, result: tuple,
inputs: dict[str, Any], inputs: dict[str, Any],
) -> dict | None: ) -> dict | None:
from backend.data_types import DataField, encode_preview, render_datafield_preview 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)] fields = [value for value in result if isinstance(value, DataField)]
if not fields: if not fields:
return None return None
selected_path = str(inputs.get("path") or inputs.get("filename") or inputs.get("name") or "").strip() 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] = [] channel_names: list[str] = []
if selected_path: if selected_path:
try: try:

View File

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

View File

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

View File

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

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

@@ -0,0 +1,129 @@
"""
Exporter for IMAGE values (numpy arrays, ImageData annotation sources).
Images are raw pixel arrays — no physical calibration by design — so none of
the formats here round-trip dimensions. PNG/TIFF convert to uint8 via the
same image_to_uint8 helper the preview pipeline uses; NPZ preserves the raw
array.
Multi-layer stacks are supported for TIFF (multi-page uint8) and NPZ (one
named array per layer). PNG is single-layer only and raises if extra layers
are connected.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Sequence
import numpy as np
from backend.data_types import image_to_uint8
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("IMAGE", "ANNOTATION_SOURCE")
FORMATS: dict[str, FormatSpec] = {
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG"),
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF"),
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
}
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"PNG"})
def save(
path: Path,
value: np.ndarray,
format_name: str,
*,
extra_layers: Sequence[Any] | None = None,
layer_names: Sequence[str] | None = None,
**_opts,
) -> None:
extras = list(extra_layers or [])
layers: list[Any] = [value, *extras]
names = _resolve_layer_names(len(layers), layer_names, default_primary=path.stem or "image")
if extras and format_name in _SINGLE_LAYER_ONLY:
raise ValueError(
f"{format_name} only supports a single layer. Use 'TIFF' or 'NPZ' "
f"for multi-layer image saves."
)
if format_name == "PNG":
from PIL import Image
Image.fromarray(image_to_uint8(np.asarray(value))).save(str(path))
return
if format_name == "TIFF":
_save_tiff(path, layers, names)
return
if format_name == "NPZ":
_save_npz(path, layers, names)
return
raise ValueError(f"Format {format_name!r} is not supported for IMAGE.")
def _save_tiff(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
import tifffile
if len(layers) == 1:
tifffile.imwrite(str(path), image_to_uint8(np.asarray(layers[0])))
return
with tifffile.TiffWriter(str(path)) as tif:
for layer, layer_name in zip(layers, names):
tif.write(image_to_uint8(np.asarray(layer)), description=layer_name)
def _save_npz(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
if len(layers) == 1:
# Preserve the single-layer key used by the legacy test suite.
np.savez(str(path), image=np.asarray(layers[0]))
return
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
keys = _dedupe_keys(raw_keys)
arrays = {key: np.asarray(layer) for key, layer in zip(keys, layers)}
np.savez(str(path), **arrays)
def _resolve_layer_names(
count: int,
raw_names: Sequence[str] | None,
*,
default_primary: str,
) -> list[str]:
raw_names = list(raw_names or [])
out: list[str] = []
for i in range(count):
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
if raw:
out.append(raw)
elif i == 0:
out.append(default_primary)
else:
out.append(f"layer_{i + 1}")
return out
def _safe_identifier(name: str, index: int) -> str:
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
if not key:
key = f"layer_{index + 1}"
if key[0].isdigit():
key = f"layer_{key}"
return key
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
used: set[str] = set()
result: list[str] = []
for k in raw_keys:
candidate = k
suffix = 2
while candidate in used:
candidate = f"{k}_{suffix}"
suffix += 1
used.add(candidate)
result.append(candidate)
return result

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

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

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

@@ -0,0 +1,60 @@
"""
Exporter for MESH_MODEL values (Wavefront OBJ, ASCII STL).
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from backend.data_types import MeshModel
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("MESH_MODEL",)
FORMATS: dict[str, FormatSpec] = {
"OBJ": FormatSpec(ext=".obj", round_trip=True, label="Wavefront OBJ"),
"STL": FormatSpec(ext=".stl", round_trip=True, label="STL (ASCII)"),
}
def save(path: Path, value: MeshModel, format_name: str, **_opts) -> None:
if format_name == "OBJ":
_save_obj(path, value)
return
if format_name == "STL":
_save_stl(path, value)
return
raise ValueError(f"Format {format_name!r} is not supported for MESH_MODEL.")
def _save_obj(path: Path, mesh: MeshModel) -> None:
lines: list[str] = []
for vertex in mesh.vertices:
lines.append(f"v {vertex[0]} {vertex[1]} {vertex[2]}")
for face in mesh.faces:
lines.append(f"f {int(face[0]) + 1} {int(face[1]) + 1} {int(face[2]) + 1}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _save_stl(path: Path, mesh: MeshModel) -> None:
def normal(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> np.ndarray:
n = np.cross(b - a, c - a)
length = float(np.linalg.norm(n))
return n / length if length > 0 else np.array([0.0, 1.0, 0.0], dtype=np.float32)
lines = ["solid tono"]
vertices = np.asarray(mesh.vertices, dtype=np.float32)
for face in np.asarray(mesh.faces, dtype=np.int32):
a, b, c = vertices[int(face[0])], vertices[int(face[1])], vertices[int(face[2])]
n = normal(a, b, c)
lines.append(f" facet normal {n[0]} {n[1]} {n[2]}")
lines.append(" outer loop")
lines.append(f" vertex {a[0]} {a[1]} {a[2]}")
lines.append(f" vertex {b[0]} {b[1]} {b[2]}")
lines.append(f" vertex {c[0]} {c[1]} {c[2]}")
lines.append(" endloop")
lines.append(" endfacet")
lines.append("endsolid tono")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")

View File

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

View File

@@ -0,0 +1,44 @@
"""
Exporter for RECORD_TABLE and DATA_TABLE values.
Both types are list-of-dict; the Save node currently accepts plain lists in
this slot too, which is preserved here. CSV auto-derives its column set from
the first row's keys (and any additional keys that appear later), matching
the prior behavior.
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
from backend.exporters._base import FormatSpec
accepted_types: tuple[str, ...] = ("RECORD_TABLE", "DATA_TABLE")
FORMATS: dict[str, FormatSpec] = {
"CSV": FormatSpec(ext=".csv", round_trip=True, label="CSV"),
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
}
def save(path: Path, value: list, format_name: str, **_opts) -> None:
rows = list(value)
if format_name == "JSON":
path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
return
if format_name == "CSV":
columns: list[str] = []
for row in rows:
if isinstance(row, dict):
for key in row.keys():
if key not in columns:
columns.append(str(key))
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=columns)
writer.writeheader()
for row in rows:
writer.writerow(row if isinstance(row, dict) else {"value": row})
return
raise ValueError(f"Format {format_name!r} is not supported for table inputs.")

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
"""Arc Revolve — subtract a cylindrical arc background."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
def _arc_kernel(radius: int) -> np.ndarray:
"""Build a 1D arc kernel: z = 1 - sqrt(1 - (i/radius)^2)."""
half = min(radius, 4096)
i = np.arange(-half, half + 1, dtype=np.float64)
t = np.clip((i / radius) ** 2, 0.0, 1.0)
return 1.0 - np.sqrt(1.0 - t)
def _arc_revolve_1d(data: np.ndarray, radius: int) -> np.ndarray:
"""Compute arc-revolve background for each row independently."""
yres, xres = data.shape
kernel = _arc_kernel(radius)
half = len(kernel) // 2
bg = np.empty_like(data)
for row in range(yres):
line = data[row].copy()
# Suppress deep outliers before fitting
window = min(half, xres // 2)
if window > 0:
from scipy.ndimage import uniform_filter1d
local_mean = uniform_filter1d(line, size=2 * window + 1, mode='nearest')
local_sq = uniform_filter1d(line ** 2, size=2 * window + 1, mode='nearest')
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
line = np.maximum(line, threshold)
# For each pixel, find the lowest position the arc can sit
padded = np.pad(line, half, mode='edge')
row_bg = np.full(xres, np.inf)
for k in range(len(kernel)):
shifted = padded[k:k + xres] - kernel[k]
row_bg = np.minimum(row_bg, shifted)
bg[row] = row_bg
return bg
@register_node(display_name="Arc Revolve")
class ArcRevolve:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"radius": ("INT", {"default": 20, "min": 1, "max": 1000, "step": 1}),
"direction": (["horizontal", "vertical", "both"], {"default": "horizontal"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
('DATA_FIELD', 'background'),
)
FUNCTION = "process"
DESCRIPTION = (
"Subtract a cylindrical arc background. A circular arc of the given "
"radius is rolled under each row (or column), and the envelope it "
"traces out is subtracted as the background."
)
KEYWORDS = ("arc", "revolve", "cylindrical", "background", "level")
def process(self, field: DataField, radius: int = 20,
direction: str = "horizontal") -> tuple:
data = np.asarray(field.data, dtype=np.float64)
if direction == "horizontal":
bg = _arc_revolve_1d(data, radius)
elif direction == "vertical":
bg = _arc_revolve_1d(data.T, radius).T
else:
bg_h = _arc_revolve_1d(data, radius)
bg_v = _arc_revolve_1d(data.T, radius).T
bg = np.minimum(bg_h, bg_v)
return (field.replace(data=data - bg), field.replace(data=bg))

View File

@@ -3,6 +3,7 @@ import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_overlay from backend.execution_context import emit_overlay
from backend.data_types import DataField, datafield_to_uint8, encode_preview from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.nodes.helpers import coerce_physical_square
@register_node(display_name="Crop / Resize") @register_node(display_name="Crop / Resize")
@@ -19,6 +20,7 @@ class CropResizeField:
"target_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}), "target_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"target_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}), "target_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
"interpolation": (["bilinear", "nearest", "bicubic"],), "interpolation": (["bilinear", "nearest", "bicubic"],),
"square": ("BOOLEAN", {"default": False}),
}, },
"optional": { "optional": {
"corner_a": ("COORD",), "corner_a": ("COORD",),
@@ -34,7 +36,8 @@ class CropResizeField:
DESCRIPTION = ( DESCRIPTION = (
"Crop a DATA_FIELD with a draggable rectangle defined by two corners, then optionally resize it. " "Crop a DATA_FIELD with a draggable rectangle defined by two corners, then optionally resize it. "
"Incoming COORD inputs can lock either corner. Cropping updates physical extents and offsets; " "Incoming COORD inputs can lock either corner. Cropping updates physical extents and offsets; "
"resizing preserves the cropped physical size." "resizing preserves the cropped physical size. Enable 'square' to constrain the crop region to a "
"physical square (longer side shrinks to match shorter)."
) )
KEYWORDS = ("resize", "rescale", "trim", "bilinear", "bicubic", "nearest") KEYWORDS = ("resize", "rescale", "trim", "bilinear", "bicubic", "nearest")
@@ -49,6 +52,7 @@ class CropResizeField:
target_width: int, target_width: int,
target_height: int, target_height: int,
interpolation: str, interpolation: str,
square: bool = False,
corner_a=None, corner_a=None,
corner_b=None, corner_b=None,
) -> tuple: ) -> tuple:
@@ -62,21 +66,29 @@ class CropResizeField:
x2 = float(np.clip(x2, 0.0, 1.0)) x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0)) y2 = float(np.clip(y2, 0.0, 1.0))
emit_overlay({
"kind": "crop_box",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
left = min(x1, x2) left = min(x1, x2)
right = max(x1, x2) right = max(x1, x2)
top = min(y1, y2) top = min(y1, y2)
bottom = max(y1, y2) bottom = max(y1, y2)
if square:
left, top, right, bottom = coerce_physical_square(
left, top, right, bottom, field.xreal, field.yreal,
)
emit_overlay({
"kind": "crop_box",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": left,
"y1": top,
"x2": right,
"y2": bottom,
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
if right <= left or bottom <= top: if right <= left or bottom <= top:
raise ValueError("Crop region must have non-zero width and height.") raise ValueError("Crop region must have non-zero width and height.")

View File

@@ -319,6 +319,20 @@ def bool_to_mask(binary: np.ndarray) -> np.ndarray:
return np.asarray(binary, dtype=np.uint8) * 255 return np.asarray(binary, dtype=np.uint8) * 255
def coerce_physical_square(
left: float, top: float, right: float, bottom: float,
xreal: float, yreal: float,
) -> tuple[float, float, float, float]:
"""Shrink the longer physical side so the rectangle is a physical square,
anchored at (left, top)."""
side_phys = min((right - left) * xreal, (bottom - top) * yreal)
if xreal > 0:
right = left + side_phys / xreal
if yreal > 0:
bottom = top + side_phys / yreal
return left, top, right, bottom
def normalize_mask( def normalize_mask(
mask: np.ndarray | None, shape: tuple[int, int], mask: np.ndarray | None, shape: tuple[int, int],
) -> np.ndarray | None: ) -> np.ndarray | None:

View File

@@ -0,0 +1,75 @@
"""Level Rotate — level by physically rotating the data plane."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import map_coordinates
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Level Rotate")
class LevelRotate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Level by physically rotating the data plane. Fits a best-fit plane, "
"converts its slopes to tilt angles, then rotates the surface by "
"those angles using interpolation rather than algebraic subtraction."
)
KEYWORDS = ("rotate", "tilt", "level", "plane")
def process(self, field: DataField) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
# Fit plane: z = a + bx*x + by*y (x,y in pixel coords)
yy, xx = np.mgrid[:yres, :xres].astype(np.float64)
A = np.column_stack([np.ones(yres * xres), xx.ravel(), yy.ravel()])
coeffs, _, _, _ = np.linalg.lstsq(A, data.ravel(), rcond=None)
_, bx, by = coeffs
# Convert pixel slopes to tilt angles
alpha_x = np.arctan(bx)
alpha_y = np.arctan(by)
# Build rotation: for each output pixel, find where it came from
cx = (xres - 1) / 2.0
cy = (yres - 1) / 2.0
cos_x = np.cos(alpha_x)
cos_y = np.cos(alpha_y)
# Source coordinates after removing tilt
src_x = xx.copy()
src_y = yy.copy()
src_z = data.copy()
# Rotate about x-axis (corrects y-tilt)
dy = yy - cy
src_y_rot = cy + dy * cos_y
src_z = src_z - dy * np.sin(alpha_y)
# Rotate about y-axis (corrects x-tilt)
dx = xx - cx
src_x_rot = cx + dx * cos_x
src_z = src_z - dx * np.sin(alpha_x)
# Resample with the adjusted z values
result = map_coordinates(src_z, [src_y_rot, src_x_rot], order=1,
mode='nearest')
return (field.replace(data=result),)

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import numpy as np
from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
from backend.node_registry import register_node
from backend.nodes.helpers import bool_to_mask, coerce_physical_square
@register_node(display_name="Rectangular Mask")
class RectangularMask:
_CUSTOM_PREVIEW = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"x1": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y1": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"x2": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y2": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"square": ("BOOLEAN", {"default": False}),
"invert": ("BOOLEAN", {"default": False}),
},
"optional": {
"corner_a": ("COORD",),
"corner_b": ("COORD",),
},
}
OUTPUTS = (
('IMAGE', 'mask'),
)
FUNCTION = "process"
DESCRIPTION = (
"Create a binary mask covering a rectangular region of a DATA_FIELD, "
"defined by two draggable corners on the preview. Useful for selecting "
"a region of interest without cropping the image. When 'square' is on, "
"the mask is coerced to a physical square (the longer side shrinks to "
"match the shorter, anchored at the top-left corner). When 'invert' is "
"on, the mask covers everything outside the rectangle instead. "
"Incoming COORD inputs can lock either corner."
)
KEYWORDS = ("roi", "region", "rectangle", "square", "box", "selection", "crop mask")
def process(
self,
field: DataField,
x1: float,
y1: float,
x2: float,
y2: float,
square: bool,
invert: bool,
corner_a=None,
corner_b=None,
) -> tuple:
if corner_a is not None:
x1, y1 = float(corner_a[0]), float(corner_a[1])
if corner_b is not None:
x2, y2 = float(corner_b[0]), float(corner_b[1])
x1 = float(np.clip(x1, 0.0, 1.0))
y1 = float(np.clip(y1, 0.0, 1.0))
x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0))
left = min(x1, x2)
right = max(x1, x2)
top = min(y1, y2)
bottom = max(y1, y2)
if square:
left, top, right, bottom = coerce_physical_square(
left, top, right, bottom, field.xreal, field.yreal,
)
emit_overlay({
"kind": "crop_box",
"section_title": "Preview",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"x1": left,
"y1": top,
"x2": right,
"y2": bottom,
"xreal": float(field.xreal),
"yreal": float(field.yreal),
"a_locked": corner_a is not None,
"b_locked": corner_b is not None,
})
px0 = int(np.floor(left * field.xres))
py0 = int(np.floor(top * field.yres))
px1 = int(np.ceil(right * field.xres))
py1 = int(np.ceil(bottom * field.yres))
px0 = min(max(px0, 0), field.xres)
py0 = min(max(py0, 0), field.yres)
px1 = min(max(px1, px0), field.xres)
py1 = min(max(py1, py0), field.yres)
binary = np.zeros((field.yres, field.xres), dtype=bool)
binary[py0:py1, px0:px1] = True
if invert:
binary = ~binary
mask = bool_to_mask(binary)
return (mask,)

View File

@@ -5,7 +5,23 @@ from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import DataField, LineData from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
def _blend_fields(field_a: DataField, field_b: DataField, alpha: float) -> np.ndarray:
"""Render field A with field B overlaid at `alpha` opacity (0=A only, 1=B only)."""
a_rgb = datafield_to_uint8(field_a, field_a.colormap).astype(np.float32)
b_rgb = datafield_to_uint8(field_b, field_b.colormap).astype(np.float32)
wa = 1.0 - alpha
wb = alpha
if b_rgb.shape != a_rgb.shape:
h = min(a_rgb.shape[0], b_rgb.shape[0])
w = min(a_rgb.shape[1], b_rgb.shape[1])
canvas = a_rgb.copy()
canvas[:h, :w] = wa * a_rgb[:h, :w] + wb * b_rgb[:h, :w]
return canvas.astype(np.uint8)
return (wa * a_rgb + wb * b_rgb).astype(np.uint8)
@register_node(display_name="Multiple Profiles") @register_node(display_name="Multiple Profiles")
@@ -19,11 +35,12 @@ class MultipleProfiles:
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}), "row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
"direction": (["horizontal", "vertical"], {"default": "horizontal"}), "direction": (["horizontal", "vertical"], {"default": "horizontal"}),
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}), "mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "slider": True}),
} }
} }
OUTPUTS = ( OUTPUTS = (
('LINE_DATA', 'profile'), ('LINE', 'profile'),
) )
FUNCTION = "process" FUNCTION = "process"
@@ -31,12 +48,14 @@ class MultipleProfiles:
"Extract and compare line profiles from two fields. " "Extract and compare line profiles from two fields. "
"Row=-1 uses the center row/column. Modes: overlay returns field_a's " "Row=-1 uses the center row/column. Modes: overlay returns field_a's "
"profile, mean averages both, difference subtracts b from a. " "profile, mean averages both, difference subtracts b from a. "
"The preview shows field A blended with field B and highlights the "
"row or column being sampled — drag to move the line."
) )
KEYWORDS = ("line profile", "compare", "overlay", "cross section") KEYWORDS = ("line profile", "compare", "overlay", "cross section")
def process(self, field_a: DataField, field_b: DataField, def process(self, field_a: DataField, field_b: DataField,
row: int, direction: str, mode: str) -> tuple: row: int, direction: str, mode: str, blend: float = 0.5) -> tuple:
a = np.asarray(field_a.data, dtype=np.float64) a = np.asarray(field_a.data, dtype=np.float64)
b = np.asarray(field_b.data, dtype=np.float64) b = np.asarray(field_b.data, dtype=np.float64)
@@ -49,6 +68,7 @@ class MultipleProfiles:
pa = pa[:len(pb)] pa = pa[:len(pb)]
dx = field_a.dx dx = field_a.dx
x_unit = field_a.si_unit_xy x_unit = field_a.si_unit_xy
line_axis_max = a.shape[0] - 1
else: else:
if row < 0: if row < 0:
row = a.shape[1] // 2 row = a.shape[1] // 2
@@ -58,6 +78,7 @@ class MultipleProfiles:
pa = pa[:len(pb)] pa = pa[:len(pb)]
dx = field_a.dy dx = field_a.dy
x_unit = field_a.si_unit_xy x_unit = field_a.si_unit_xy
line_axis_max = a.shape[1] - 1
x_axis = np.arange(len(pa)) * dx x_axis = np.arange(len(pa)) * dx
@@ -70,5 +91,15 @@ class MultipleProfiles:
else: else:
result = pa result = pa
alpha = float(np.clip(blend, 0.0, 1.0))
emit_overlay({
"kind": "multi_profile",
"section_title": "Preview",
"image": encode_preview(_blend_fields(field_a, field_b, alpha)),
"row": int(row),
"direction": direction,
"max_index": int(line_axis_max),
})
return (LineData(data=result, x_axis=x_axis, x_unit=x_unit, return (LineData(data=result, x_axis=x_axis, x_unit=x_unit,
y_unit=field_a.si_unit_z),) y_unit=field_a.si_unit_z),)

View File

@@ -6,25 +6,34 @@ import numpy as np
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import DataField from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
@register_node(display_name="Perspective Correction") @register_node(display_name="Perspective Correction")
class PerspectiveCorrection: class PerspectiveCorrection:
_CUSTOM_PREVIEW = True
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"field": ("DATA_FIELD",), "field": ("DATA_FIELD",),
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "top_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "top_left_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "top_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "top_right_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "bottom_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "bottom_left_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "bottom_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
"bottom_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}), "bottom_right_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
} },
"optional": {
"top_left": ("COORD",),
"top_right": ("COORD",),
"bottom_left": ("COORD",),
"bottom_right": ("COORD",),
},
} }
OUTPUTS = ( OUTPUTS = (
@@ -33,9 +42,8 @@ class PerspectiveCorrection:
FUNCTION = "process" FUNCTION = "process"
DESCRIPTION = ( DESCRIPTION = (
"Fix perspective distortion by specifying corner offsets. Each corner " "Fix perspective distortion by dragging corner handles. Each corner "
"can be shifted by a fractional amount (relative to image size) to " "offset defines a distorted quadrilateral that is warped back to "
"define the distorted quadrilateral. The image is then warped back to "
"a rectangle." "a rectangle."
) )
@@ -45,11 +53,23 @@ class PerspectiveCorrection:
top_left_x: float, top_left_y: float, top_left_x: float, top_left_y: float,
top_right_x: float, top_right_y: float, top_right_x: float, top_right_y: float,
bottom_left_x: float, bottom_left_y: float, bottom_left_x: float, bottom_left_y: float,
bottom_right_x: float, bottom_right_y: float) -> tuple: bottom_right_x: float, bottom_right_y: float,
top_left: tuple[float, float] | None = None,
top_right: tuple[float, float] | None = None,
bottom_left: tuple[float, float] | None = None,
bottom_right: tuple[float, float] | None = None) -> tuple:
if top_left is not None:
top_left_x, top_left_y = float(top_left[0]), float(top_left[1])
if top_right is not None:
top_right_x, top_right_y = float(top_right[0]), float(top_right[1])
if bottom_left is not None:
bottom_left_x, bottom_left_y = float(bottom_left[0]), float(bottom_left[1])
if bottom_right is not None:
bottom_right_x, bottom_right_y = float(bottom_right[0]), float(bottom_right[1])
data = np.asarray(field.data, dtype=np.float64) data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape yres, xres = data.shape
# Source corners (distorted) as fractional offsets from ideal corners
src = np.array([ src = np.array([
[top_left_y * yres, top_left_x * xres], [top_left_y * yres, top_left_x * xres],
[top_right_y * yres, top_right_x * xres + (xres - 1)], [top_right_y * yres, top_right_x * xres + (xres - 1)],
@@ -57,7 +77,6 @@ class PerspectiveCorrection:
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)], [(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
], dtype=np.float64) ], dtype=np.float64)
# Destination corners (ideal rectangle)
dst = np.array([ dst = np.array([
[0, 0], [0, 0],
[0, xres - 1], [0, xres - 1],
@@ -65,33 +84,54 @@ class PerspectiveCorrection:
[yres - 1, xres - 1], [yres - 1, xres - 1],
], dtype=np.float64) ], dtype=np.float64)
# Solve for perspective transform matrix (3x3)
H = _solve_perspective(src, dst) H = _solve_perspective(src, dst)
# Apply inverse warp
yy, xx = np.mgrid[:yres, :xres] yy, xx = np.mgrid[:yres, :xres]
coords = np.stack([yy.ravel(), xx.ravel(), np.ones(yres * xres)]) coords = np.stack([xx.ravel(), yy.ravel(), np.ones(yres * xres)])
src_coords = H @ coords src_coords = H @ coords
src_coords /= src_coords[2:3, :] src_coords /= src_coords[2:3, :]
sy = src_coords[0].reshape(yres, xres) sx = src_coords[0].reshape(yres, xres)
sx = src_coords[1].reshape(yres, xres) sy = src_coords[1].reshape(yres, xres)
result = map_coordinates(data, [sy, sx], order=1, mode='nearest') result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
return (field.replace(data=result),) corrected = field.replace(data=result)
source_rgb = datafield_to_uint8(field, field.colormap)
corrected_rgb = datafield_to_uint8(corrected, corrected.colormap)
corners = [
{"x": float(top_left_x), "y": float(top_left_y)},
{"x": float(top_right_x), "y": float(top_right_y)},
{"x": float(bottom_left_x), "y": float(bottom_left_y)},
{"x": float(bottom_right_x), "y": float(bottom_right_y)},
]
emit_overlay({
"kind": "perspective",
"section_title": "Perspective",
"image": encode_preview(source_rgb),
"corrected_image": encode_preview(corrected_rgb),
"corners": corners,
})
return (corrected,)
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray: def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp).""" """Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp).
Coordinates are (col, row) — the matrix is applied to [col, row, 1] vectors.
"""
n = len(src) n = len(src)
A = np.zeros((2 * n, 8)) A = np.zeros((2 * n, 8))
b = np.zeros(2 * n) b = np.zeros(2 * n)
for i in range(n): for i in range(n):
dy, dx = dst[i] dr, dc = dst[i] # dest row, col
sy, sx = src[i] sr, sc = src[i] # src row, col
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy] A[2 * i] = [dc, dr, 1, 0, 0, 0, -sc * dc, -sc * dr]
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy] A[2 * i + 1] = [0, 0, 0, dc, dr, 1, -sr * dc, -sr * dr]
b[2 * i] = sx b[2 * i] = sc
b[2 * i + 1] = sy b[2 * i + 1] = sr
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None) h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
H = np.array([[h[0], h[1], h[2]], H = np.array([[h[0], h[1], h[2]],
[h[3], h[4], h[5]], [h[3], h[4], h[5]],

View File

@@ -2,8 +2,14 @@ from __future__ import annotations
import numpy as np import numpy as np
from backend.data_types import (
DataField,
LineData,
encode_preview,
render_datafield_preview,
)
from backend.execution_context import emit_overlay
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import DataField, LineData
@register_node(display_name="Radial Profile") @register_node(display_name="Radial Profile")
@@ -13,9 +19,11 @@ class RadialProfile:
return { return {
"required": { "required": {
"field": ("DATA_FIELD",), "field": ("DATA_FIELD",),
"cx": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"cy": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"n_bins": ("INT", {"default": 128, "min": 4, "max": 4096, "step": 1}), "n_bins": ("INT", {"default": 128, "min": 4, "max": 4096, "step": 1}),
"cx": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"cy": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"ex": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"ey": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
} }
} }
@@ -25,21 +33,38 @@ class RadialProfile:
FUNCTION = "process" FUNCTION = "process"
DESCRIPTION = ( DESCRIPTION = (
"Compute the azimuthally averaged radial profile from a centre point. " "Compute an azimuthally averaged profile around a centre point. "
"cx/cy give the centre as a fraction of the field width/height (0.5 = centre). " "At each radius, every pixel in the full 360° ring is averaged together, "
"Output x-axis is radius in physical xy units. " "so the profile is direction-independent — there is no clockwise/counter-clockwise "
"traversal and no start/end point along the ring. "
"Drag the centre marker on the preview to reposition the profile, "
"or drag either end marker (both just set the outer radius) to change the extent. "
"Output x-axis is radius in physical xy units."
) )
KEYWORDS = ("azimuthal average", "ring average", "circular", "isotropic") KEYWORDS = ("azimuthal average", "ring average", "circular", "isotropic")
def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple: def process(
self,
field: DataField,
n_bins: int,
cx: float,
cy: float,
ex: float,
ey: float,
) -> tuple:
yres, xres = field.data.shape yres, xres = field.data.shape
# Centre in physical coordinates (matches Gwyddion: xc = cx*xreal + xoff) cx = float(np.clip(cx, 0.0, 1.0))
cy = float(np.clip(cy, 0.0, 1.0))
ex = float(np.clip(ex, 0.0, 1.0))
ey = float(np.clip(ey, 0.0, 1.0))
xc_phys = cx * field.xreal + field.xoff xc_phys = cx * field.xreal + field.xoff
yc_phys = cy * field.yreal + field.yoff yc_phys = cy * field.yreal + field.yoff
xe_phys = ex * field.xreal + field.xoff
ye_phys = ey * field.yreal + field.yoff
# Pixel-centre physical coordinates
xs = (np.arange(xres) + 0.5) * field.dx + field.xoff xs = (np.arange(xres) + 0.5) * field.dx + field.xoff
ys = (np.arange(yres) + 0.5) * field.dy + field.yoff ys = (np.arange(yres) + 0.5) * field.dy + field.yoff
gx, gy = np.meshgrid(xs, ys) gx, gy = np.meshgrid(xs, ys)
@@ -47,20 +72,19 @@ class RadialProfile:
r = np.hypot(gx - xc_phys, gy - yc_phys).ravel() r = np.hypot(gx - xc_phys, gy - yc_phys).ravel()
values = field.data.ravel() values = field.data.ravel()
# Maximum radius — farthest pixel from centre r_max = float(np.hypot(xe_phys - xc_phys, ye_phys - yc_phys))
r_max = float(r.max()) if r_max <= 0.0:
if r_max == 0.0:
r_max = max(field.dx, field.dy) r_max = max(field.dx, field.dy)
# Bin by radius — matches Gwyddion's lineres-bin approach
bin_edges = np.linspace(0.0, r_max, n_bins + 1) bin_edges = np.linspace(0.0, r_max, n_bins + 1)
mask = r <= r_max
idx = np.clip( idx = np.clip(
np.floor(n_bins * r / r_max).astype(np.intp), 0, n_bins - 1 np.floor(n_bins * r[mask] / r_max).astype(np.intp), 0, n_bins - 1
) )
sums = np.zeros(n_bins) sums = np.zeros(n_bins)
counts = np.zeros(n_bins, dtype=np.intp) counts = np.zeros(n_bins, dtype=np.intp)
np.add.at(sums, idx, values) np.add.at(sums, idx, values[mask])
np.add.at(counts, idx, 1) np.add.at(counts, idx, 1)
with np.errstate(invalid="ignore"): with np.errstate(invalid="ignore"):
@@ -68,6 +92,16 @@ class RadialProfile:
centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
emit_overlay({
"kind": "radial_profile",
"section_title": "Radial Profile",
"image": encode_preview(render_datafield_preview(field, field.colormap)),
"cx": cx,
"cy": cy,
"ex": ex,
"ey": ey,
})
return (LineData( return (LineData(
data=profile, data=profile,
x_axis=centers, x_axis=centers,

View File

@@ -1,26 +1,88 @@
from __future__ import annotations from __future__ import annotations
import csv
import json
from pathlib import Path
import numpy as np
import tempfile import tempfile
from pathlib import Path
from typing import Any
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.execution_context import emit_warning, emit_file_download from backend.execution_context import emit_warning, emit_file_download
from backend.data_types import ( from backend.exporters import (
DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8, available_formats,
_SI_PREFIXES, _PREFIXABLE_UNITS, get_exporter,
resolve_path,
type_name_for_value,
) )
from backend.nodes.helpers import _MAX_SAVE_FIELDS
DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads" 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") @register_node(display_name="Save")
class Save: class Save:
@classmethod @classmethod
def INPUT_TYPES(cls): 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 { return {
"required": { "required": {
"filename": ("STRING", { "filename": ("STRING", {
@@ -29,7 +91,7 @@ class Save:
"placement": "top", "placement": "top",
}), }),
"value": ("DATA_FIELD", { "value": ("DATA_FIELD", {
"label": "value", "label": "layer 1",
"accepted_types": [ "accepted_types": [
"IMAGE", "IMAGE",
"ANNOTATION_SOURCE", "ANNOTATION_SOURCE",
@@ -41,28 +103,12 @@ class Save:
], ],
}), }),
"format": ("STRING", { "format": ("STRING", {
"default": "TIFF", "default": choices["DATA_FIELD"][0] if choices["DATA_FIELD"] else "",
"choices_by_source_type": { "choices_by_source_type": choices,
"DATA_FIELD": ["TIFF", "PNG", "NPZ"],
"IMAGE": ["PNG", "TIFF", "NPZ"],
"ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"],
"LINE": ["PNG", "TIFF", "CSV", "NPZ", "JSON"],
"RECORD_TABLE": ["CSV", "JSON"],
"DATA_TABLE": ["CSV", "JSON"],
"FLOAT": ["TXT", "JSON"],
"MESH_MODEL": ["OBJ", "STL"],
},
"source_type_input": "value", "source_type_input": "value",
}), }),
}, },
"optional": { "optional": optional,
"plot_title": ("STRING", {
"default": "",
"placeholder": "plot title (optional)",
"label": "title",
"show_when_source_type": {"value": ["LINE"]},
}),
},
} }
OUTPUTS = () OUTPUTS = ()
@@ -71,10 +117,15 @@ class Save:
OUTPUT_NODE = True OUTPUT_NODE = True
MANUAL_TRIGGER = True MANUAL_TRIGGER = True
DESCRIPTION = ( DESCRIPTION = (
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes." "Save 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( def save(
self, self,
@@ -82,296 +133,62 @@ class Save:
format: str, format: str,
value, value,
plot_title: str = "", 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): extra_layers, layer_names = self._collect_extra_layers(
self._save_mesh(path, value, format) type_name, primary_name, kwargs,
elif isinstance(value, DataField): )
self._save_datafield(path, value, format)
elif isinstance(value, np.ndarray): module.save(
if value.ndim == 1: path,
self._save_line(path, LineData(data=value), format, title=plot_title) value,
else: format,
self._save_image_or_array(path, value, format) plot_title=plot_title,
elif isinstance(value, LineData): extra_layers=extra_layers,
self._save_line(path, value, format, title=plot_title) layer_names=layer_names,
elif isinstance(value, list): )
self._save_table(path, value, format)
elif isinstance(value, (int, float, np.floating, np.integer)):
self._save_scalar(path, float(value), format)
else:
raise ValueError(f"Save does not support input type: {type(value).__name__}")
emit_warning(f"Saved to {path.name}") emit_warning(f"Saved to {path.name}")
emit_file_download(str(path)) emit_file_download(str(path))
return () return ()
def _resolve_save_path(self, filename: str, format_name: str) -> Path: def _collect_extra_layers(
ext_map = {
"PNG": ".png",
"TIFF": ".tiff",
"NPZ": ".npz",
"CSV": ".csv",
"JSON": ".json",
"OBJ": ".obj",
"STL": ".stl",
"TXT": ".txt",
}
ext = ext_map[format_name]
raw_filename = str(filename).strip() if filename is not None else ""
if not raw_filename:
raise ValueError("No output filename selected — enter a file name.")
candidate = Path(raw_filename).expanduser()
if candidate.is_absolute():
candidate.parent.mkdir(parents=True, exist_ok=True)
path = candidate
else:
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
path = DOWNLOAD_DIR / candidate.name
if path.suffix.lower() != ext:
path = path.with_suffix(ext)
return path
def _save_datafield(self, path: Path, field: DataField, format_name: str):
if format_name == "TIFF":
import tifffile
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
return
if format_name == "NPZ":
np.savez(str(path), field=np.asarray(field.data))
return
if format_name == "PNG":
from PIL import Image
Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path))
return
raise ValueError(f"Format {format_name} is not supported for DATA_FIELD.")
def _save_image_or_array(self, path: Path, image: np.ndarray, format_name: str):
arr = np.asarray(image)
if format_name == "PNG":
from PIL import Image
Image.fromarray(image_to_uint8(arr)).save(str(path))
return
if format_name == "TIFF":
import tifffile
tifffile.imwrite(str(path), image_to_uint8(arr))
return
if format_name == "NPZ":
np.savez(str(path), image=arr)
return
raise ValueError(f"Format {format_name} is not supported for IMAGE.")
def _save_line(self, path: Path, line: LineData, format_name: str, title: str = ""):
y = np.asarray(line.data, dtype=np.float64).ravel()
x = np.asarray(line.x_axis, dtype=np.float64).ravel()[: len(y)] if line.x_axis is not None else np.arange(len(y), dtype=np.float64)
if format_name in ("PNG", "TIFF"):
self._save_line_plot(path, x, y, line.x_unit, line.y_unit, title, format_name)
return
if format_name == "CSV":
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["x", "y", "x_unit", "y_unit"])
for xv, yv in zip(x, y):
writer.writerow([xv, yv, line.x_unit, line.y_unit])
return
if format_name == "NPZ":
np.savez(str(path), x=x, y=y)
return
if format_name == "JSON":
path.write_text(json.dumps({
"x": x.tolist(),
"y": y.tolist(),
"x_unit": line.x_unit,
"y_unit": line.y_unit,
}, indent=2), encoding="utf-8")
return
raise ValueError(f"Format {format_name} is not supported for LINE.")
def _save_line_plot(
self, self,
path: Path, type_name: str,
x: np.ndarray, primary_name: str,
y: np.ndarray, kwargs: dict[str, Any],
x_unit: str, ) -> tuple[list[Any], list[str]]:
y_unit: str, """Pull field_N + layer_name_N from kwargs into parallel lists.
title: str,
format_name: str,
):
from PIL import Image, ImageDraw, ImageFont
w, h = 1200, 750 Only applies when the primary value is a stackable source type; for
bg = (255, 255, 255) anything else (LINE, FLOAT, MESH_MODEL, tables) any stray field_N
line_color = (79, 142, 247) # #4f8ef7 kwargs are ignored — the frontend hides those sockets in that case
grid_color = (200, 200, 200) and the backend treats it as a single-value save.
text_color = (60, 60, 60) """
margin = {"left": 80, "right": 30, "top": 50, "bottom": 60} if type_name not in _STACKABLE_SOURCE_TYPES:
return [], []
img = Image.new("RGB", (w, h), bg) extras: list[Any] = []
draw = ImageDraw.Draw(img) 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: if not extras:
font = ImageFont.truetype("DejaVuSans.ttf", 14) return [], []
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"] # Full names list starts with the primary's name (empty → exporter
ph = h - margin["top"] - margin["bottom"] # substitutes path.stem) and then each extra in order.
names = [str(primary_name or "").strip(), *extra_names]
def _si_scale(unit: str, vmin: float, vmax: float) -> tuple[float, str]: return extras, names
"""Pick the best SI prefix for an axis range. Returns (divisor, prefixed_unit)."""
unit = (unit or "").strip()
if not unit or unit not in _PREFIXABLE_UNITS:
return 1.0, unit if unit else ""
peak = max(abs(vmin), abs(vmax))
if peak == 0:
return 1.0, unit
for scale, prefix in _SI_PREFIXES:
if peak / scale >= 1.0:
return scale, f"{prefix}{unit}"
return _SI_PREFIXES[-1][0], f"{_SI_PREFIXES[-1][1]}{unit}"
xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
x_scale, x_label = _si_scale(x_unit, xmin, xmax)
y_scale, y_label = _si_scale(y_unit, ymin, ymax)
if not x_label:
x_label = "x"
if not y_label:
y_label = "y"
# Scale data into prefixed units
x = x / x_scale
y = y / y_scale
xmin, xmax = xmin / x_scale, xmax / x_scale
ymin, ymax = ymin / y_scale, ymax / y_scale
if ymax == ymin:
ymin, ymax = ymin - 1, ymax + 1
if xmax == xmin:
xmax = xmin + 1
# Add 5% padding to y range
ypad = (ymax - ymin) * 0.05
ymin -= ypad
ymax += ypad
def to_px(xv: float, yv: float) -> tuple[float, float]:
px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw
py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph
return px, py
# Grid lines (5 horizontal, 5 vertical)
for i in range(6):
gy = ymin + (ymax - ymin) * i / 5
_, py = to_px(xmin, gy)
draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1)
label = f"{gy:.4g}"
draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm")
gx = xmin + (xmax - xmin) * i / 5
px, _ = to_px(gx, ymin)
draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1)
label = f"{gx:.4g}"
draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt")
# Plot line
n = len(y)
step = max(1, n // pw)
xs, ys = x[::step], y[::step]
pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))]
if len(pts) > 1:
draw.line(pts, fill=line_color, width=2)
# Border
draw.rectangle(
[margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph],
outline=(100, 100, 100), width=1,
)
draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb")
# Vertical y label — draw rotated
y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0))
y_draw = ImageDraw.Draw(y_label_img)
y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm")
y_label_img = y_label_img.rotate(90, expand=True)
img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img)
# Title
if title and title.strip():
draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt")
ext = ".png" if format_name == "PNG" else ".tiff"
img.save(str(path.with_suffix(ext)))
def _save_table(self, path: Path, rows: list, format_name: str):
if format_name == "JSON":
path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
return
if format_name == "CSV":
columns: list[str] = []
for row in rows:
if isinstance(row, dict):
for key in row.keys():
if key not in columns:
columns.append(str(key))
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=columns)
writer.writeheader()
for row in rows:
writer.writerow(row if isinstance(row, dict) else {"value": row})
return
raise ValueError(f"Format {format_name} is not supported for table inputs.")
def _save_scalar(self, path: Path, value: float, format_name: str):
if format_name == "TXT":
path.write_text(f"{value}\n", encoding="utf-8")
return
if format_name == "JSON":
path.write_text(json.dumps({"value": value}, indent=2), encoding="utf-8")
return
raise ValueError(f"Format {format_name} is not supported for scalar values.")
def _save_mesh(self, path: Path, mesh: MeshModel, format_name: str):
if format_name == "OBJ":
self._save_obj(path, mesh)
return
if format_name == "STL":
self._save_stl(path, mesh)
return
raise ValueError(f"Format {format_name} is not supported for MESH_MODEL.")
def _save_obj(self, path: Path, mesh: MeshModel):
lines = []
for vertex in mesh.vertices:
lines.append(f"v {vertex[0]} {vertex[1]} {vertex[2]}")
for face in mesh.faces:
lines.append(f"f {int(face[0]) + 1} {int(face[1]) + 1} {int(face[2]) + 1}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _save_stl(self, path: Path, mesh: MeshModel):
def normal(a, b, c):
n = np.cross(b - a, c - a)
length = float(np.linalg.norm(n))
return n / length if length > 0 else np.array([0.0, 1.0, 0.0], dtype=np.float32)
lines = ["solid tono"]
vertices = np.asarray(mesh.vertices, dtype=np.float32)
for face in np.asarray(mesh.faces, dtype=np.int32):
a, b, c = vertices[int(face[0])], vertices[int(face[1])], vertices[int(face[2])]
n = normal(a, b, c)
lines.append(f" facet normal {n[0]} {n[1]} {n[2]}")
lines.append(" outer loop")
lines.append(f" vertex {a[0]} {a[1]} {a[2]}")
lines.append(f" vertex {b[0]} {b[1]} {b[2]}")
lines.append(f" vertex {c[0]} {c[1]} {c[2]}")
lines.append(" endloop")
lines.append(" endfacet")
lines.append("endsolid tono")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")

View File

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

View File

@@ -0,0 +1,74 @@
"""Sphere Revolve — subtract a spherical cap background."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import uniform_filter
from backend.node_registry import register_node
from backend.data_types import DataField
def _sphere_kernel(radius: int) -> np.ndarray:
"""Build a 2D spherical cap kernel."""
half = min(radius, 512)
i = np.arange(-half, half + 1, dtype=np.float64)
ii, jj = np.meshgrid(i, i)
r2 = (ii ** 2 + jj ** 2) / (radius ** 2)
r2 = np.clip(r2, 0.0, 1.0)
return 1.0 - np.sqrt(1.0 - r2)
@register_node(display_name="Sphere Revolve")
class SphereRevolve:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"radius": ("INT", {"default": 20, "min": 1, "max": 500, "step": 1}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
('DATA_FIELD', 'background'),
)
FUNCTION = "process"
DESCRIPTION = (
"Subtract a spherical cap background. A sphere of the given radius "
"is rolled under the surface, and the envelope it traces is "
"subtracted as the background."
)
KEYWORDS = ("sphere", "revolve", "spherical", "background", "level")
def process(self, field: DataField, radius: int = 20) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape
kernel = _sphere_kernel(radius)
half = kernel.shape[0] // 2
# Suppress deep outliers
window = max(1, half // 2)
local_mean = uniform_filter(data, size=2 * window + 1, mode='nearest')
local_sq = uniform_filter(data ** 2, size=2 * window + 1, mode='nearest')
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
clipped = np.maximum(data, threshold)
padded = np.pad(clipped, half, mode='edge')
bg = np.full_like(data, np.inf)
ks = kernel.shape[0]
for di in range(ks):
for dj in range(ks):
k_val = kernel[di, dj]
if k_val >= 1.0:
continue
shifted = padded[di:di + yres, dj:dj + xres] - k_val
bg = np.minimum(bg, shifted)
return (field.replace(data=data - bg), field.replace(data=bg))

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable from backend.data_types import DataField, RecordTable
from backend.nodes.helpers import mask_to_bool
@register_node(display_name="Statistics") @register_node(display_name="Statistics")
@@ -11,7 +12,10 @@ class Statistics:
return { return {
"required": { "required": {
"field": ("DATA_FIELD",), "field": ("DATA_FIELD",),
} },
"optional": {
"mask": ("IMAGE",),
},
} }
OUTPUTS = ( OUTPUTS = (
@@ -21,13 +25,24 @@ class Statistics:
DESCRIPTION = ( DESCRIPTION = (
"Compute basic surface statistics: min, max, mean, RMS roughness, median, " "Compute basic surface statistics: min, max, mean, RMS roughness, median, "
"and skewness." "and skewness. When a mask is provided, only pixels inside the mask are "
"included."
) )
KEYWORDS = ("mean", "rms", "min", "max", "skewness", "kurtosis", "median", "roughness") KEYWORDS = ("mean", "rms", "min", "max", "skewness", "kurtosis", "median", "roughness")
def process(self, field: DataField) -> tuple: def process(self, field: DataField, mask: np.ndarray | None = None) -> tuple:
d = field.data d = field.data
if mask is not None:
selector = mask_to_bool(mask)
if selector.shape != d.shape:
raise ValueError(
f"Mask shape {selector.shape} does not match field shape {d.shape}"
)
d = d[selector]
if d.size == 0:
raise ValueError("Mask selects no pixels")
mean = float(d.mean()) mean = float(d.mean())
rms = float(np.sqrt(np.mean((d - mean) ** 2))) rms = float(np.sqrt(np.mean((d - mean) ** 2)))
skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0 skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0

View File

@@ -3,10 +3,12 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from scipy.interpolate import CubicSpline
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import DataField from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview
from backend.execution_context import emit_overlay
@register_node(display_name="Straighten Path") @register_node(display_name="Straighten Path")
@@ -16,8 +18,8 @@ class StraightenPath:
return { return {
"required": { "required": {
"field": ("DATA_FIELD",), "field": ("DATA_FIELD",),
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}), "points_x": ("STRING", {"default": "0.25, 0.5, 0.75", "hidden": True}),
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}), "points_y": ("STRING", {"default": "0.5, 0.3, 0.5", "hidden": True}),
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}), "thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}), "n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
} }
@@ -25,14 +27,15 @@ class StraightenPath:
OUTPUTS = ( OUTPUTS = (
('DATA_FIELD', 'straightened'), ('DATA_FIELD', 'straightened'),
('LINE', 'profile'),
) )
FUNCTION = "process" FUNCTION = "process"
DESCRIPTION = ( DESCRIPTION = (
"Extract a cross-section along an arbitrary curved path defined by " "Extract a cross-section along an arbitrary curved path defined by "
"control points. Points are given as fractional coordinates (0-1). " "control points. The path is a natural cubic spline through the "
"The path is interpolated with cubic splines, and data is sampled " "points. Drag the points on the preview to reshape the path; the "
"along it with configurable thickness. " "shaded band shows the sampling thickness. "
) )
KEYWORDS = ("unbend", "unroll", "spline", "curved profile", "extract path") KEYWORDS = ("unbend", "unroll", "spline", "curved profile", "extract path")
@@ -42,36 +45,46 @@ class StraightenPath:
data = np.asarray(field.data, dtype=np.float64) data = np.asarray(field.data, dtype=np.float64)
yres, xres = data.shape yres, xres = data.shape
# Parse control points fx = [float(v.strip()) for v in points_x.split(",") if v.strip()]
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()] fy = [float(v.strip()) for v in points_y.split(",") if v.strip()]
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()] n_pts = min(len(fx), len(fy))
fx, fy = fx[:n_pts], fy[:n_pts]
if len(px) < 2 or len(py) < 2: emit_overlay({
# Need at least 2 points "kind": "straighten_path",
return (field,) "section_title": "Path",
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
"points": [{"x": float(fx[i]), "y": float(fy[i])} for i in range(n_pts)],
"thickness": int(thickness),
"xres": int(xres),
"yres": int(yres),
})
n_pts = min(len(px), len(py)) if n_pts < 2:
px, py = px[:n_pts], py[:n_pts] empty_line = LineData(
data=np.zeros(0, dtype=np.float64),
x_axis=np.zeros(0, dtype=np.float64),
x_unit=field.si_unit_xy,
y_unit=field.si_unit_z,
)
return (field, empty_line)
px = [f * (xres - 1) for f in fx]
py = [f * (yres - 1) for f in fy]
# Parameterize path and interpolate
t_ctrl = np.linspace(0, 1, n_pts) t_ctrl = np.linspace(0, 1, n_pts)
t_sample = np.linspace(0, 1, n_samples) t_sample = np.linspace(0, 1, n_samples)
if n_pts >= 3:
# Simple cubic interpolation via numpy cx = CubicSpline(t_ctrl, px, bc_type="natural")(t_sample)
if n_pts >= 4: cy = CubicSpline(t_ctrl, py, bc_type="natural")(t_sample)
from numpy.polynomial.polynomial import Polynomial
cx = np.interp(t_sample, t_ctrl, px)
cy = np.interp(t_sample, t_ctrl, py)
else: else:
cx = np.interp(t_sample, t_ctrl, px) cx = np.interp(t_sample, t_ctrl, px)
cy = np.interp(t_sample, t_ctrl, py) cy = np.interp(t_sample, t_ctrl, py)
# Sample along path with thickness
if thickness <= 1: if thickness <= 1:
values = map_coordinates(data, [cy, cx], order=1, mode='nearest') values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
result = values.reshape(1, -1) result = values.reshape(1, -1)
else: else:
# Compute normals
dcx = np.gradient(cx) dcx = np.gradient(cx)
dcy = np.gradient(cy) dcy = np.gradient(cy)
length = np.sqrt(dcx**2 + dcy**2) length = np.sqrt(dcx**2 + dcy**2)
@@ -86,12 +99,22 @@ class StraightenPath:
sy = cy + off * ny sy = cy + off * ny
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest') result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
# Physical dimensions
total_length = 0.0 total_length = 0.0
for i in range(1, len(cx)): for i in range(1, len(cx)):
dx_phys = (cx[i] - cx[i - 1]) * field.dx dx_phys = (cx[i] - cx[i - 1]) * field.dx
dy_phys = (cy[i] - cy[i - 1]) * field.dy dy_phys = (cy[i] - cy[i - 1]) * field.dy
total_length += np.sqrt(dx_phys**2 + dy_phys**2) total_length += np.sqrt(dx_phys**2 + dy_phys**2)
return (field.replace(data=result, xreal=total_length, center_values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
yreal=thickness * max(field.dx, field.dy)),) profile = LineData(
data=center_values,
x_axis=np.linspace(0.0, total_length, n_samples),
x_unit=field.si_unit_xy,
y_unit=field.si_unit_z,
)
straightened = field.replace(
data=result, xreal=total_length,
yreal=thickness * max(field.dx, field.dy),
)
return (straightened, profile)

88
backend/nodes/unrotate.py Normal file
View File

@@ -0,0 +1,88 @@
"""Unrotate — auto-detect and correct in-plane scan rotation."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import rotate as ndimage_rotate
from backend.node_registry import register_node
from backend.data_types import DataField
def _slope_angle_histogram(data: np.ndarray, n_bins: int = 3600) -> np.ndarray:
"""Compute histogram of local slope angles over [0, 2*pi)."""
dy = np.diff(data, axis=0)[:, :-1]
dx = np.diff(data, axis=1)[:-1, :]
angles = np.arctan2(dy, dx) % (2 * np.pi)
hist, _ = np.histogram(angles.ravel(), bins=n_bins, range=(0, 2 * np.pi))
return hist.astype(np.float64)
def _find_dominant_angle(hist: np.ndarray, symmetry: int) -> float:
"""Find the rotation correction angle for a given symmetry order.
Folds the histogram into one symmetry sector, finds the peak, and
returns the offset to the nearest axis.
"""
n_bins = len(hist)
sector = n_bins // symmetry
folded = np.zeros(sector, dtype=np.float64)
for k in range(symmetry):
start = k * sector
end = start + sector
if end <= n_bins:
folded += hist[start:end]
peak_bin = int(np.argmax(folded))
bin_angle = (2 * np.pi / symmetry) / sector
# The angle of the peak
peak_angle = peak_bin * bin_angle
# The nearest axis is at multiples of pi/symmetry
axis_spacing = np.pi / symmetry
nearest_axis = round(peak_angle / axis_spacing) * axis_spacing
correction = nearest_axis - peak_angle
return float(correction)
@register_node(display_name="Unrotate")
class Unrotate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"symmetry": (["2-fold", "3-fold", "4-fold", "6-fold"], {"default": "4-fold"}),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = (
"Auto-detect and correct in-plane scan rotation. Computes a slope "
"angle histogram, finds the dominant feature direction for the given "
"symmetry, and rotates the image to align features with the axes."
)
KEYWORDS = ("rotation", "alignment", "angle", "symmetry", "crystal")
def process(self, field: DataField, symmetry: str = "4-fold") -> tuple:
data = np.asarray(field.data, dtype=np.float64)
sym_order = int(symmetry[0])
hist = _slope_angle_histogram(data)
angle_rad = _find_dominant_angle(hist, sym_order)
angle_deg = float(np.degrees(angle_rad))
if abs(angle_deg) < 0.01:
return (field,)
rotated = ndimage_rotate(data, angle_deg, reshape=False, order=1,
mode='nearest')
return (field.replace(data=rotated),)

View File

@@ -0,0 +1,56 @@
"""Zero Value — shift data so the mean or maximum equals zero."""
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Zero Mean")
class ZeroMean:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = "Shift all values so the mean is exactly zero."
KEYWORDS = ("offset", "center", "level", "mean")
def process(self, field: DataField) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
return (field.replace(data=data - data.mean()),)
@register_node(display_name="Zero Maximum")
class ZeroMaximum:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
}
}
OUTPUTS = (
('DATA_FIELD', 'leveled'),
)
FUNCTION = "process"
DESCRIPTION = "Shift all values so the maximum is exactly zero."
KEYWORDS = ("offset", "level", "maximum")
def process(self, field: DataField) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
return (field.replace(data=data - data.max()),)

View File

@@ -35,6 +35,14 @@ pip install -e ".[server,dev,desktop]"
Two servers run in development: the Vite frontend dev server and the Python backend. Vite proxies all API and WebSocket requests to the backend, so you only open the Vite URL in your browser. 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 ```bash
# Terminal 1 — Python backend (http://127.0.0.1:8188) # Terminal 1 — Python backend (http://127.0.0.1:8188)
npm run backend npm run backend
@@ -157,8 +165,9 @@ TONO_APPDATA=/my/data/dir python desktop.py
| Command | Description | | Command | Description |
|---|---| |---|---|
| `npm run dev` | Start Vite dev server + Python backend | | `npm run dev:all` | Start the Python backend and the Vite dev server together |
| `npm run backend` | Start Python backend only | | `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 build` | Build frontend to `frontend/dist/` |
| `npm run preview` | Preview the production frontend build | | `npm run preview` | Preview the production frontend build |
| `npm run desktop` | Build frontend + launch desktop app | | `npm run desktop` | Build frontend + launch desktop app |

View File

@@ -0,0 +1,98 @@
# Missing Gwyddion Features
Gwyddion 2D image/surface processing features not yet implemented in tono. Excludes force curves, force volume, spectroscopy, volume data, XYZ data, graph operations, and file I/O.
## Leveling / Background Removal
- [x] **Arc Revolve** — Subtract cylindrical arc background fitted by revolving an arc under the data
- [x] **Sphere Revolve** — Subtract spherical cap background
- [x] **Unrotate** — Auto-detect and correct in-plane scan rotation by finding dominant feature directions
- [x] **Level Rotate** — Level by physically rotating the data plane rather than subtracting a polynomial
- [x] **Zero Mean Value** — Shift all values so the mean is exactly zero (pure offset, no plane fit)
- [x] **Zero Maximum Value** — Shift all values so the maximum is exactly zero
## Filtering / Signal Processing
- [ ] **2D CWT** — Continuous Wavelet Transform for scale-space analysis
- [ ] **XY Denoise** — Denoise by combining two orthogonal scans (forward/backward or horizontal/vertical)
- [ ] **Rank Presentation** — Rank transform image for local contrast enhancement
- [ ] **Radial Smoothing** — Smooth data in polar coordinates, averaging along radial or angular direction
- [ ] **Convolve Two Images** — Convolve two separate data channels together
## Line Correction / Scan Artifacts
- [ ] **Step Block Correction** — Correct vertical step offsets between scan lines by block-matching
- [ ] **Good Mean Profile** — Compute a high-quality average scan line from repeated scans
- [ ] **Align Rows (extended methods)** — Modus and Gaussian-weighted row alignment beyond tono's current set
## Correction / Restoration
- [ ] **Fractal Correction** — Fill masked/bad pixels using fractal interpolation (alternative to Laplace)
- [ ] **Correlation Averaging** — Average repeated similar structures using autocorrelation alignment
- [ ] **Coerce** — Force data to match the histogram distribution of another dataset
- [ ] **Periodic Translate** — Translate image data treating the field as periodic (wrap-around shift)
- [ ] **Reorder** — Reorder pixel rows/columns (interleaved to sequential, reverse scan, etc.)
## Statistical Analysis
- [ ] **Transfer Function Fit** — Fit PSF from a known reference image and a measured blurred image
- [ ] **Transfer Function Guess** — Estimate PSF from a single image without a reference
- [ ] **Angle Distribution** — Distribution of surface normal angles (distinct from slope distribution)
## Grain Operations
- [ ] **Otsu Threshold** — Automated grain/mask threshold using Otsu's method
- [ ] **Remove Edge-Touching Grains** — Remove all grains touching the image border from a mask
- [ ] **Grain Selection Shapes** — Create geometric selections (bounding boxes, inscribed discs, etc.) from grain masks
## Mask Operations
- [ ] **Mask Thin** — Morphological thinning to single-pixel-wide skeletons
- [ ] **Mask Distribute** — Copy/distribute a mask to multiple channels simultaneously
- [ ] **Mark With** — Create or modify a mask using arithmetic conditions on other channels
## Basic Operations
- [ ] **Invert Value** — Flip heights (z to -z)
- [ ] **Log Scale Presentation** — Log-scaled presentation layer without modifying source data
- [ ] **Limit Range** — Clamp data values to a specified min/max range
- [ ] **Square Samples** — Resample so pixels are physically square (equal x/y size)
- [ ] **Null Offsets** — Zero out the lateral (XY) origin offsets
## SPM-Specific Modes
- [ ] **MFM Field Simulation** — Simulate magnetic stray field above perpendicular media
- [ ] **MFM Parallel Media** — Simulate MFM signal for in-plane magnetic media
- [ ] **MFM Lift Shift** — Simulate MFM signal change when lift height changes
- [ ] **MFM Lift Estimate** — Estimate lift height difference from data blur
- [ ] **MFM Force Gradient** — Convert MFM raw data to force gradient units
- [ ] **SMM Apply Calibration** — Apply Scanning Microwave Microscopy calibration coefficients
## Synthetic Surface Generators
Tono has one generic Synthetic Surface node. Gwyddion has ~20+ specialized generators:
- [ ] **Fractional Brownian Motion** — fBm rough surfaces with controlled Hurst exponent
- [ ] **Spectral Synthesis** — PSD-specified random rough surfaces
- [ ] **Lattice** — Crystalline lattice surface with defects
- [ ] **Objects** — Randomly placed 3D objects (spheres, pyramids, etc.)
- [ ] **Patterns** — Geometric patterns (staircase, gratings, etc.)
- [ ] **Waves** — Sinusoidal/wave patterns
- [ ] **Noise** — Uncorrelated random noise with configurable distribution
- [ ] **Line Noise** — Synthetic scan-line noise/steps/scars for testing
- [ ] **Fibres** — Random fibre network surfaces
- [ ] **Domain Walls** — Phase-separated domain structures
- [ ] **Columnar Growth** — Columnar thin-film growth simulation
- [ ] **Ball Deposition** — Random ballistic deposition growth
- [ ] **Particle Deposition** — Dynamical particle deposition model
- [ ] **Rod Deposition** — Rod-like particle deposition
- [ ] **Diffusion** — Diffusion-limited aggregation surfaces
- [ ] **Discs** — Random overlapping disc surfaces
- [ ] **CPDE / Turing** — Reaction-diffusion / Turing pattern surfaces
- [ ] **Sand Dunes** — Aeolian sand transport simulation
- [ ] **Annealing Lattice Gas** — Annealed lattice-gas model textures
- [ ] **Phase Separation** — Spinodal decomposition textures
- [ ] **Pileup** — Piled-up ellipsoids or bars
- [ ] **Plateaus** — Stacked random plateau/terrace structures
- [ ] **Film Residue** — Residue left after simulated film removal
- [ ] **Wetting Front** — Propagating wetting front simulation

29
docs/nodes/Arc Revolve.md Normal file
View File

@@ -0,0 +1,29 @@
# Arc Revolve
Subtract a cylindrical arc background. A circular arc of the given radius is rolled under each row (or column), and the envelope it traces is subtracted as the background.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with arc background subtracted |
| background | DATA_FIELD | The estimated arc background |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| radius | INT | 20 | Arc radius in pixels (11000) |
| direction | dropdown | horizontal | Direction to apply the arc: horizontal, vertical, or both |
## Notes
- Larger radii produce smoother backgrounds that follow gentle curvature. Smaller radii track finer features.
- The "both" direction takes the minimum of horizontal and vertical backgrounds.
- Deep outliers are suppressed before fitting so that scratches or pits do not pull the arc down.

View File

@@ -23,9 +23,11 @@ Crop a DATA_FIELD with a draggable rectangle defined by two corners, then option
| target_width | INT | 0 | Output pixel width after resampling (0 = keep cropped width) | | target_width | INT | 0 | Output pixel width after resampling (0 = keep cropped width) |
| target_height | INT | 0 | Output pixel height after resampling (0 = keep cropped height) | | target_height | INT | 0 | Output pixel height after resampling (0 = keep cropped height) |
| interpolation | dropdown | bilinear | Resampling interpolation: bilinear, nearest, or bicubic | | interpolation | dropdown | bilinear | Resampling interpolation: bilinear, nearest, or bicubic |
| square | BOOLEAN | False | If true, the crop region is constrained to a physical square (longer side shrinks to match shorter). The on-preview rectangle also snaps to square while dragging either corner. |
## Notes ## Notes
- The crop region must have non-zero width and height; an error is raised otherwise. - The crop region must have non-zero width and height; an error is raised otherwise.
- If only one of target_width or target_height is set, the other dimension is computed to preserve aspect ratio. - If only one of target_width or target_height is set, the other dimension is computed to preserve aspect ratio.
- Physical extents are scaled proportionally when resampling. - Physical extents are scaled proportionally when resampling.
- With `square` enabled, the side length is chosen in physical units (using the field's `xreal`/`yreal`), so the cropped region looks square on the preview for fields with square pixels.

View File

@@ -0,0 +1,21 @@
# Level Rotate
Level by physically rotating the data plane. Fits a best-fit plane, converts its slopes to tilt angles, then rotates the surface by those angles using interpolation rather than algebraic subtraction.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to level |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with tilt removed by rotation |
## Notes
- Unlike Plane Level (which subtracts a fitted plane), this node rotates the 3D surface to make it horizontal. The distinction matters for steep tilts where subtraction introduces distortion.
- Uses bilinear interpolation to resample rotated z-values.
- Edges are handled with nearest-neighbor extension.

View File

@@ -1,6 +1,6 @@
# Multiple Profiles # Multiple Profiles
Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes. Equivalent to Gwyddion's multiprofile.c module. Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes.
## Inputs ## Inputs
@@ -13,7 +13,7 @@ Extract and compare line profiles from two fields along a chosen row or column.
| Name | Type | Description | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| profile | LINE_DATA | Resulting line profile | | profile | LINE | Resulting line profile |
## Controls ## Controls
@@ -22,6 +22,11 @@ Extract and compare line profiles from two fields along a chosen row or column.
| row | INT | -1 | Row (horizontal) or column (vertical) index to extract; -1 uses the centre row/column (-1-10000) | | row | INT | -1 | Row (horizontal) or column (vertical) index to extract; -1 uses the centre row/column (-1-10000) |
| direction | dropdown | horizontal | Profile direction: horizontal (extract a row) or vertical (extract a column) | | direction | dropdown | horizontal | Profile direction: horizontal (extract a row) or vertical (extract a column) |
| mode | dropdown | overlay | Combination mode: overlay (field_a profile only), mean (average of both), or difference (field_a minus field_b) | | mode | dropdown | overlay | Combination mode: overlay (field_a profile only), mean (average of both), or difference (field_a minus field_b) |
| blend | FLOAT | 0.5 | Opacity of field B in the preview (0 = only A, 1 = only B). Affects the preview image only, not the extracted profile. |
## Interactive preview
The preview shows field A blended with field B and highlights the row or column being sampled. Click or drag on the image to move the line; switch between row and column extraction with the `direction` control.
## Notes ## Notes

View File

@@ -1,12 +1,16 @@
# Perspective Correction # Perspective Correction
Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle. Equivalent to Gwyddion's `correct_perspective.c` module. Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle.
## Inputs ## Inputs
| Name | Type | Required | Description | | Name | Type | Required | Description |
|------|------|----------|-------------| |------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field with perspective distortion | | field | DATA_FIELD | Yes | Input field with perspective distortion |
| top_left | COORD | No | Override top-left corner offset (x, y) |
| top_right | COORD | No | Override top-right corner offset (x, y) |
| bottom_left | COORD | No | Override bottom-left corner offset (x, y) |
| bottom_right | COORD | No | Override bottom-right corner offset (x, y) |
## Outputs ## Outputs
@@ -14,22 +18,15 @@ Fix perspective distortion in a DATA_FIELD via a projective (homography) transfo
|------|------|-------------| |------|------|-------------|
| corrected | DATA_FIELD | Perspective-corrected field | | corrected | DATA_FIELD | Perspective-corrected field |
## Controls ## Interactive preview
| Name | Type | Default | Description | The preview shows the source image with a draggable quadrilateral overlay. Drag any corner handle to adjust the perspective correction. Use the Source/Corrected tabs to switch between the input image (with handles) and the corrected result.
|------|------|---------|-------------|
| top_left_x | FLOAT | 0.0 | Horizontal offset of the top-left corner as a fraction of image width (-0.5-0.5) | Corner positions can also be set by connecting Coordinate nodes to the optional COORD inputs, which override the handle-driven values.
| top_left_y | FLOAT | 0.0 | Vertical offset of the top-left corner as a fraction of image height (-0.5-0.5) |
| top_right_x | FLOAT | 0.0 | Horizontal offset of the top-right corner as a fraction of image width (-0.5-0.5) |
| top_right_y | FLOAT | 0.0 | Vertical offset of the top-right corner as a fraction of image height (-0.5-0.5) |
| bottom_left_x | FLOAT | 0.0 | Horizontal offset of the bottom-left corner as a fraction of image width (-0.5-0.5) |
| bottom_left_y | FLOAT | 0.0 | Vertical offset of the bottom-left corner as a fraction of image height (-0.5-0.5) |
| bottom_right_x | FLOAT | 0.0 | Horizontal offset of the bottom-right corner as a fraction of image width (-0.5-0.5) |
| bottom_right_y | FLOAT | 0.0 | Vertical offset of the bottom-right corner as a fraction of image height (-0.5-0.5) |
## Notes ## Notes
- All offsets are given as fractions of the image dimensions (0.0 = no shift, 0.1 = 10% shift). Positive x shifts right, positive y shifts down. - All offsets are given as fractions of the image dimensions (0.0 = no shift, 0.1 = 10% shift). Positive x shifts right, positive y shifts down.
- The transform uses bilinear interpolation to resample pixel values at non-integer locations. - The transform uses bilinear interpolation to resample pixel values at non-integer locations.
- For trapezoidal distortions (common in tilted AFM scans), typically only two corners need adjustment. - For trapezoidal distortions (common in tilted AFM scans), typically only two corners need adjustment.
- Set all offsets to 0.0 to pass the field through unchanged. - When all offsets are zero (default), the field passes through unchanged.

View File

@@ -1,6 +1,6 @@
# Radial Profile # Radial Profile
Compute the azimuthally averaged radial profile from a centre point. The output x-axis is radius in physical xy units. Equivalent to gwy_data_field_angular_average used by Gwyddion's Radial Profile tool. Compute an **azimuthally averaged** profile around a centre point on a DATA_FIELD. At each radius, every pixel in the full 360° ring around the centre is averaged together, so the profile is direction-independent — there is no clockwise/counter-clockwise traversal and no start or end point along the ring. The output is a single 1-D profile: value vs. radius.
## Inputs ## Inputs
@@ -18,11 +18,13 @@ Compute the azimuthally averaged radial profile from a centre point. The output
| Name | Type | Default | Description | | Name | Type | Default | Description |
|------|------|---------|-------------| |------|------|---------|-------------|
| cx | FLOAT | 0.5 | Centre x position as a fraction of field width (0 = left, 1 = right) |
| cy | FLOAT | 0.5 | Centre y position as a fraction of field height (0 = top, 1 = bottom) |
| n_bins | INT | 128 | Number of radial bins (4-4096) | | n_bins | INT | 128 | Number of radial bins (4-4096) |
## Interactive preview
The dashed circle around the centre shows the outer radius used by the profile. Pixels beyond it are not included in the averaging.
## Notes ## Notes
- Pixels are assigned to radial bins by Euclidean distance; bins near the centre contain fewer pixels and may be noisier. - Pixels are assigned to radial bins by Euclidean distance from the centre; inner bins contain fewer pixels and may be noisier.
- Physical x-axis units come from the field's si_unit_xy; uncalibrated fields produce pixel-unit radii. - Physical x-axis units come from the field's si_unit_xy; uncalibrated fields produce pixel-unit radii.

View File

@@ -0,0 +1,34 @@
# Rectangular Mask
Create a binary mask covering a rectangular region of a DATA_FIELD. Useful when you want to select a region of interest for downstream nodes (statistics, flattening, masking operators) without cropping the image.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
| corner_a | COORD | No | Locks corner A from an external coordinate |
| corner_b | COORD | No | Locks corner B from an external coordinate |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| mask | IMAGE | Binary mask (255 inside the rectangle, 0 outside) matching the input field's pixel resolution |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| square | BOOLEAN | False | If true, the mask is coerced to a physical square — the longer side is shrunk to match the shorter, anchored at the top-left corner |
| invert | BOOLEAN | False | If true, the mask covers everything outside the rectangle instead of inside |
## Interactive preview
The node renders the input field with a draggable rectangle. Drag corner A or B to resize; drag inside the box to move it. Incoming COORD inputs lock the corresponding corner so it can't be moved interactively.
## Notes
- The output mask has the same resolution (xres × yres) as the input field.
- Pixel boundaries are chosen to fully contain the selected rectangle (floor on the low corner, ceil on the high corner).
- With `square` enabled, the side length is chosen in physical units (using `xreal`/`yreal`), so the mask looks square on the preview for fields with square pixels. For non-square pixels it is physically square but may render as a rectangle pixel-wise.

View File

@@ -0,0 +1,28 @@
# Sphere Revolve
Subtract a spherical cap background. A sphere of the given radius is rolled under the surface, and the envelope it traces is subtracted as the background.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with spherical background subtracted |
| background | DATA_FIELD | The estimated spherical background |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| radius | INT | 20 | Sphere radius in pixels (1500) |
## Notes
- Works like Arc Revolve but in two dimensions — suitable for bowl-shaped or dome-shaped backgrounds.
- Larger radii produce smoother backgrounds. Very small radii will track individual features.
- Deep outliers are suppressed before fitting.

View File

@@ -7,6 +7,7 @@ Compute basic surface statistics: min, max, mean, RMS roughness, median, and ske
| Name | Type | Required | Description | | Name | Type | Required | Description |
|------|------|----------|-------------| |------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to analyze | | field | DATA_FIELD | Yes | Input field to analyze |
| mask | IMAGE | No | Optional binary mask — only pixels inside the mask contribute to the statistics |
## Outputs ## Outputs
@@ -20,4 +21,4 @@ None.
## Notes ## Notes
- None. - When a mask is provided, it must match the field's pixel resolution. Only pixels where the mask is non-zero are included in the statistics.

View File

@@ -13,20 +13,25 @@ Extract a cross-section along an arbitrary curved path defined by control points
| Name | Type | Description | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| straightened | DATA_FIELD | Straightened cross-section; width = n_samples, height = thickness | | straightened | DATA_FIELD | Straightened cross-section; width = n_samples, height = thickness |
| profile | LINE | 1-pixel-wide profile sampled along the centerline of the path |
## Controls ## Controls
| Name | Type | Default | Description | | Name | Type | Default | Description |
|------|------|---------|-------------| |------|------|---------|-------------|
| points_x | STRING | "0.25, 0.5, 0.75" | Comma-separated fractional x-coordinates of control points (0.0-1.0) |
| points_y | STRING | "0.5, 0.3, 0.5" | Comma-separated fractional y-coordinates of control points (0.0-1.0) |
| thickness | INT | 1 | Width of the sampled strip perpendicular to the path, in pixels (1-100) | | thickness | INT | 1 | Width of the sampled strip perpendicular to the path, in pixels (1-100) |
| n_samples | INT | 256 | Number of sample points along the path (10-2048) | | n_samples | INT | 256 | Number of sample points along the path (10-2048) |
## Interactive preview
The node renders the input field with the control points and a smooth curve through them. Drag any point to reshape the path. Double-click anywhere on the image to add a new point at that location. Shift-click a point to delete it (a minimum of two points is kept). The shaded band along the curve previews the sampling thickness.
The straightened result is shown in the regular preview section below.
## Notes ## Notes
- Control points are specified as fractions of the image dimensions (0 = left/top edge, 1 = right/bottom edge). At least 2 points are required. - Control points are specified as fractions of the image dimensions (0 = left/top edge, 1 = right/bottom edge). At least 2 points are required.
- Points are connected by linear interpolation; the path is sampled at n_samples evenly spaced positions. - With 3 or more points, the path is a natural cubic spline (C² continuous) passing through each control point, matching the smooth curve drawn on the preview. With exactly 2 points the path is a straight line.
- When thickness > 1, samples are taken along the local normal direction at each path position, producing a 2D strip rather than a single line. - When thickness > 1, samples are taken along the local normal direction at each path position, producing a 2D strip rather than a single line.
- The output xreal equals the physical path length (computed from pixel spacing), and yreal equals thickness times the pixel size. - The output xreal equals the physical path length (computed from pixel spacing), and yreal equals thickness times the pixel size.
- Bilinear interpolation (order=1) is used with nearest-edge boundary handling. - Bilinear interpolation (order=1) is used with nearest-edge boundary handling.

28
docs/nodes/Unrotate.md Normal file
View File

@@ -0,0 +1,28 @@
# Unrotate
Auto-detect and correct in-plane scan rotation. Computes a slope angle histogram, finds the dominant feature direction for the given symmetry, and rotates the image to align features with the axes.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to correct |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with rotation corrected |
## Controls
| Name | Type | Default | Description |
|------|------|---------|-------------|
| symmetry | dropdown | 4-fold | Expected symmetry of the surface features: 2-fold, 3-fold, 4-fold, or 6-fold |
## Notes
- Best suited for crystalline or patterned surfaces where features have a clear preferred direction.
- 4-fold symmetry is the most common choice for cubic crystal surfaces and rectangular gratings.
- If the detected rotation is less than 0.01°, the data is returned unchanged.
- Uses bilinear interpolation; edges are handled with nearest-neighbor extension.

View File

@@ -0,0 +1,20 @@
# Zero Maximum
Shift all values so the maximum is exactly zero. All resulting values will be zero or negative.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to level |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with maximum subtracted |
## Notes
- Equivalent to subtracting the global maximum from every pixel.
- Useful when the highest point should represent the zero reference.

20
docs/nodes/Zero Mean.md Normal file
View File

@@ -0,0 +1,20 @@
# Zero Mean
Shift all values so the mean is exactly zero. A pure offset subtraction — no plane fit or polynomial involved.
## Inputs
| Name | Type | Required | Description |
|------|------|----------|-------------|
| field | DATA_FIELD | Yes | Input field to level |
## Outputs
| Name | Type | Description |
|------|------|-------------|
| leveled | DATA_FIELD | Field with mean subtracted |
## Notes
- Equivalent to subtracting a constant (the mean) from every pixel.
- Does not change relative height differences — only shifts the overall offset.

View File

@@ -29,6 +29,13 @@ import {
parseNodeClipboardPayload, parseNodeClipboardPayload,
} from './nodeClipboard'; } from './nodeClipboard';
import { loadDefaultWorkflowAsset } from './defaultWorkflow'; import { loadDefaultWorkflowAsset } from './defaultWorkflow';
import {
cycleTheme,
getStoredTheme,
resolveTheme,
subscribeTheme,
type Theme,
} from './theme';
import { import {
serializeExecutionGraph, serializeExecutionGraph,
getAutoRunnableNodes, getAutoRunnableNodes,
@@ -162,7 +169,7 @@ function restoreGroupEdges(edges: any[], groupId: string) {
function Flow() { function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]); const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<TonoEdge>([]); 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 [contextMenu, setContextMenu] = useState<any>(null);
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
const [executingNodeId, setExecutingNodeId] = useState<string | null>(null); const [executingNodeId, setExecutingNodeId] = useState<string | null>(null);
@@ -171,6 +178,15 @@ function Flow() {
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [menuClosing, setMenuClosing] = 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(() => { const closeMenu = useCallback(() => {
if (!menuOpen || menuClosing) return; if (!menuOpen || menuClosing) return;
setMenuClosing(true); setMenuClosing(true);
@@ -967,9 +983,17 @@ function Flow() {
setStatus({ setStatus({
text: `Uploading ${entry.file.name}`, text: `Uploading ${entry.file.name}`,
level: 'info', 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; return uploaded.path;
}, []); }, []);
@@ -1036,11 +1060,27 @@ function Flow() {
} }
} }
const total = toUpload.size;
let index = 0;
for (const uri of toUpload) { for (const uri of toUpload) {
const file = pending.get(uri)!; const file = pending.get(uri)!;
const relativePath = uri.replace(/^session:\/\/uploads\//, ''); 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); pending.delete(uri);
index++;
} }
}, []); }, []);
@@ -1441,32 +1481,56 @@ function Flow() {
initializeDynamicNodes(hydrated.nodes); initializeDynamicNodes(hydrated.nodes);
}, [initializeDynamicNodes, setNodes, setEdges]); }, [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) => { const applyMaybePackedWorkflow = useCallback(async (data: any) => {
if (data.packed && data.packedFiles) { if (data.packed && data.packedFiles) {
setStatus({ text: 'Unpacking files…', level: 'info' }); setStatus({ text: 'Unpacking files…', level: 'info' });
try { try {
const { workflow, restoredPaths } = await unpackWorkflow(data); const { workflow, restoredPaths } = await unpackWorkflow(data);
applyWorkflowData(workflow, { preservedPaths: restoredPaths }); applyWorkflowData(workflow, { preservedPaths: restoredPaths });
scheduleFrameWorkflowViewport();
// Auto-run after packed workflow loads so all previews populate // Auto-run after packed workflow loads so all previews populate
requestAnimationFrame(() => requestAnimationFrame(() => scheduleAutoRun())); requestAnimationFrame(() => requestAnimationFrame(() => scheduleAutoRun()));
} catch { } catch {
// Unpack failed (e.g. stale session) — load the workflow without file restoration // Unpack failed (e.g. stale session) — load the workflow without file restoration
const { packedFiles: _, packed: __, ...cleanWorkflow } = data; const { packedFiles: _, packed: __, ...cleanWorkflow } = data;
applyWorkflowData(cleanWorkflow); applyWorkflowData(cleanWorkflow);
scheduleFrameWorkflowViewport();
setStatus({ text: 'Workflow loaded but packed files could not be restored. Re-browse your input files.', level: 'error' }); setStatus({ text: 'Workflow loaded but packed files could not be restored. Re-browse your input files.', level: 'error' });
return; return;
} }
} else { } else {
applyWorkflowData(data); applyWorkflowData(data);
scheduleFrameWorkflowViewport();
} }
}, [applyWorkflowData, scheduleAutoRun]); }, [applyWorkflowData, scheduleAutoRun, scheduleFrameWorkflowViewport]);
const loadDefaultWorkflow = useCallback(async () => { const loadDefaultWorkflow = useCallback(async () => {
if (defaultWorkflowLoadAttemptedRef.current) return; if (defaultWorkflowLoadAttemptedRef.current) return;
defaultWorkflowLoadAttemptedRef.current = true; defaultWorkflowLoadAttemptedRef.current = true;
// Only auto-load the example workflow on first visit // First-visit gating is handled by the caller (see the mount useEffect
if (localStorage.getItem('tono_visited')) return; // 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 graphHasContent = () => {
const currentNodes = (reactFlow.getNodes() as TonoNode[]); const currentNodes = (reactFlow.getNodes() as TonoNode[]);
@@ -1507,16 +1571,20 @@ function Flow() {
// ── Load node definitions ─────────────────────────────────────────── // ── Load node definitions ───────────────────────────────────────────
useEffect(() => { 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) => { api.getNodes().then((defs) => {
nodeDefsRef.current = defs; nodeDefsRef.current = defs;
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' }); setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
loadDefaultWorkflow(); if (isFirstVisit) loadDefaultWorkflow();
}).catch((err) => { }).catch((err) => {
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' }); setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
}); });
// Load any .md files from frontend/public/ as help tabs // Load any .md files from frontend/public/ as help tabs
const isFirstVisit = !localStorage.getItem('tono_visited');
fetch('/help-docs') fetch('/help-docs')
.then((r) => r.ok ? r.json() : []) .then((r) => r.ok ? r.json() : [])
.then((docs: any[]) => { .then((docs: any[]) => {
@@ -1732,9 +1800,15 @@ function Flow() {
input.onchange = async (e: Event) => { input.onchange = async (e: Event) => {
const file = (e.target as HTMLInputElement)?.files?.[0]; const file = (e.target as HTMLInputElement)?.files?.[0];
if (!file) return; if (!file) return;
setStatus({ text: 'Uploading plugin…', level: 'info' }); setStatus({ text: 'Uploading plugin…', level: 'info', progress: 0 });
try { 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. // Node list refresh is handled by the nodes_updated WebSocket message.
} catch (err: any) { } catch (err: any) {
setStatus({ text: err.message, level: 'error' }); setStatus({ text: err.message, level: 'error' });
@@ -2239,10 +2313,15 @@ function Flow() {
return () => document.removeEventListener('pointerdown', handler); return () => document.removeEventListener('pointerdown', handler);
}, [menuOpen]); }, [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); const [toastClosing, setToastClosing] = useState(false);
useEffect(() => { useEffect(() => {
if (!status.text) return; if (!status.text) return;
if (status.progress != null && status.progress < 1) {
setToastClosing(false);
return;
}
setToastClosing(false); setToastClosing(false);
const fadeTimer = setTimeout(() => setToastClosing(true), 4700); const fadeTimer = setTimeout(() => setToastClosing(true), 4700);
const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000); 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"> <button className="btn" onClick={() => { openDocByFilename('getting-started.md'); closeMenu(); }} title="Getting started guide">
? Help ? Help
</button> </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"> <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 Feedback
</a> </a>
@@ -2512,7 +2598,17 @@ function Flow() {
{/* Status toast */} {/* Status toast */}
{(status.text || toastClosing) && ( {(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 */} {/* React Flow canvas */}
@@ -2538,7 +2634,7 @@ function Flow() {
isValidConnection={isValidConnection} isValidConnection={isValidConnection}
nodeTypes={NODE_TYPES} nodeTypes={NODE_TYPES}
onPaneContextMenu={onPaneContextMenu} onPaneContextMenu={onPaneContextMenu}
colorMode="dark" colorMode={resolvedTheme}
panOnDrag={[1]} panOnDrag={[1]}
panOnScroll panOnScroll
panOnScrollSpeed={1.5} panOnScrollSpeed={1.5}

View File

@@ -2,6 +2,10 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { socketSpecAcceptsType } from './constants'; import { socketSpecAcceptsType } from './constants';
import { outputTypeCanConnectToTarget } from './connectionUtils'; import { outputTypeCanConnectToTarget } from './connectionUtils';
import { compareMenuNodes, compareMenuCategories } from './canvasEvents'; import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
import { useFavorites } from './favorites';
import { recordUsage, pickWeightedRandom } from './nodeUsage';
const FAVORITES_CATEGORY = 'favorites';
export default function ContextMenu({ export default function ContextMenu({
x, x,
@@ -26,6 +30,7 @@ export default function ContextMenu({
selectedNodeCount?: number; selectedNodeCount?: number;
onCreateGroup?: (() => void) | null; onCreateGroup?: (() => void) | null;
}) { }) {
const favorites = useFavorites();
const [openCat, setOpenCat] = useState<string | null>(null); const [openCat, setOpenCat] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -88,13 +93,31 @@ export default function ContextMenu({
}); });
} }
} }
return Object.values(cats) const sorted = Object.values(cats)
.map((category: any) => ({ .map((category: any) => ({
...category, ...category,
items: [...category.items].sort(compareMenuNodes), items: [...category.items].sort(compareMenuNodes),
})) }))
.sort(compareMenuCategories); .sort(compareMenuCategories);
}, [nodeDefs, filterDirection, filterSpec, filterType]);
const favItems: any[] = [];
const seenFav = new Set<string>();
for (const category of sorted) {
for (const item of category.items) {
if (favorites.has(item.className) && !seenFav.has(item.className)) {
seenFav.add(item.className);
favItems.push(item);
}
}
}
if (favItems.length > 0) {
return [
{ name: FAVORITES_CATEGORY, order: -Infinity, items: favItems.sort(compareMenuNodes) },
...sorted,
];
}
return sorted;
}, [nodeDefs, filterDirection, filterSpec, filterType, favorites]);
// Flat filtered list for search // Flat filtered list for search
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
@@ -191,6 +214,29 @@ export default function ContextMenu({
setOpenCat(cat); setOpenCat(cat);
}, []); }, []);
const allNodeEntries = useMemo(() => {
const map = new Map<string, any>();
for (const category of categories) {
for (const item of category.items) {
if (!map.has(item.className)) map.set(item.className, item.def);
}
}
return map;
}, [categories]);
const handleAdd = useCallback((className: string, def: any) => {
recordUsage(className);
onAdd(className, def);
}, [onAdd]);
const handleRandomNode = useCallback(() => {
const classNames = [...allNodeEntries.keys()];
const pick = pickWeightedRandom(classNames);
if (!pick) return;
const def = allNodeEntries.get(pick);
if (def) { handleAdd(pick, def); onClose(); }
}, [allNodeEntries, handleAdd, onClose]);
if (categories.length === 0) { if (categories.length === 0) {
return ( return (
<div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}> <div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}>
@@ -234,7 +280,7 @@ export default function ContextMenu({
className="context-item" className="context-item"
onClick={() => { onCreateGroup(); onClose(); }} onClick={() => { onCreateGroup(); onClose(); }}
> >
create group Create Group
</div> </div>
)} )}
@@ -248,7 +294,7 @@ export default function ContextMenu({
key={className} key={className}
ref={idx === selectedIndex ? selectedItemRef : null} ref={idx === selectedIndex ? selectedItemRef : null}
className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`} className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`}
onClick={() => { onAdd(className, def); onClose(); }} onClick={() => { handleAdd(className, def); onClose(); }}
onMouseEnter={() => setSelectedIndex(idx)} onMouseEnter={() => setSelectedIndex(idx)}
> >
{def.display_name || className} {def.display_name || className}
@@ -262,13 +308,22 @@ export default function ContextMenu({
<div <div
key={cat} key={cat}
ref={(el) => { catRowRefs.current[cat] = el; }} ref={(el) => { catRowRefs.current[cat] = el; }}
className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}`} className={`ctx-cat-item${openCat === cat ? ' ctx-cat-active' : ''}${cat === FAVORITES_CATEGORY ? ' ctx-cat-favorites' : ''}`}
onMouseEnter={() => handleCatEnter(cat)} onMouseEnter={() => handleCatEnter(cat)}
> >
<span className="ctx-cat-label">{cat}</span> <span className="ctx-cat-label">
{cat === FAVORITES_CATEGORY ? 'Favorites' : cat}
</span>
<span className="ctx-cat-arrow"></span> <span className="ctx-cat-arrow"></span>
</div> </div>
))} ))}
<div
className="ctx-cat-item ctx-random-node"
onClick={handleRandomNode}
onMouseEnter={() => setOpenCat(null)}
>
<span className="ctx-cat-label">surprise me</span>
</div>
</div> </div>
)} )}
</div> </div>
@@ -290,7 +345,7 @@ export default function ContextMenu({
<div <div
key={className} key={className}
className="context-item" className="context-item"
onClick={() => { onAdd(className, def); onClose(); }} onClick={() => { handleAdd(className, def); onClose(); }}
> >
{def.display_name || className} {def.display_name || className}
</div> </div>

View File

@@ -13,15 +13,40 @@ interface CropBoxOverlayProps {
bLocked: boolean; bLocked: boolean;
nodeId: string; nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void; onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
square?: boolean;
xreal?: number;
yreal?: number;
}
function snapPhysicalSquare(
anchorX: number, anchorY: number,
moverX: number, moverY: number,
xreal: number, yreal: number,
) {
const dx = moverX - anchorX;
const dy = moverY - anchorY;
const ax = xreal > 0 ? xreal : 1;
const ay = yreal > 0 ? yreal : 1;
const shortPhys = Math.min(Math.abs(dx) * ax, Math.abs(dy) * ay);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
return {
x: anchorX + sx * (shortPhys / ax),
y: anchorY + sy * (shortPhys / ay),
};
} }
export default function CropBoxOverlay({ export default function CropBoxOverlay({
image, x1, y1, x2, y2, image, x1, y1, x2, y2,
aLocked, bLocked, aLocked, bLocked,
nodeId, onWidgetChange, nodeId, onWidgetChange,
square = false,
xreal = 1,
yreal = 1,
}: CropBoxOverlayProps) { }: CropBoxOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null); const [dragging, setDragging] = useState<string | null>(null);
const panStartRef = useRef<{ fx: number; fy: number; x1: number; y1: number; x2: number; y2: number } | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => { const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!); return pointerToFraction(e, containerRef.current!);
@@ -30,28 +55,66 @@ export default function CropBoxOverlay({
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => { const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
if (point === 'p1' && aLocked) return; if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return; if (point === 'p2' && bLocked) return;
if (point === 'rect' && (aLocked || bLocked)) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
if (point === 'rect') {
const { fx, fy } = getCoords(e);
panStartRef.current = { fx, fy, x1, y1, x2, y2 };
}
setDragging(point); setDragging(point);
}, [aLocked, bLocked]); }, [aLocked, bLocked, getCoords, x1, y1, x2, y2]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => { const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return; if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e); const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3));
const vy = parseFloat(fy.toFixed(3)); if (dragging === 'rect') {
if (dragging === 'p1') { const start = panStartRef.current;
onWidgetChange(nodeId, 'x1', vx); if (!start) return;
onWidgetChange(nodeId, 'y1', vy); const left = Math.min(start.x1, start.x2);
} else { const right = Math.max(start.x1, start.x2);
onWidgetChange(nodeId, 'x2', vx); const top = Math.min(start.y1, start.y2);
onWidgetChange(nodeId, 'y2', vy); const bottom = Math.max(start.y1, start.y2);
let dx = fx - start.fx;
let dy = fy - start.fy;
dx = Math.max(-left, Math.min(1 - right, dx));
dy = Math.max(-top, Math.min(1 - bottom, dy));
const nx1 = parseFloat((start.x1 + dx).toFixed(3));
const ny1 = parseFloat((start.y1 + dy).toFixed(3));
const nx2 = parseFloat((start.x2 + dx).toFixed(3));
const ny2 = parseFloat((start.y2 + dy).toFixed(3));
onWidgetChange(nodeId, 'x1', nx1);
onWidgetChange(nodeId, 'y1', ny1);
onWidgetChange(nodeId, 'x2', nx2);
onWidgetChange(nodeId, 'y2', ny2);
return;
} }
}, [dragging, getCoords, nodeId, onWidgetChange]);
let vx = fx;
let vy = fy;
if (square) {
const anchorX = dragging === 'p2' ? x1 : x2;
const anchorY = dragging === 'p2' ? y1 : y2;
const snapped = snapPhysicalSquare(anchorX, anchorY, fx, fy, xreal, yreal);
vx = snapped.x;
vy = snapped.y;
}
const vxR = parseFloat(vx.toFixed(3));
const vyR = parseFloat(vy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vxR);
onWidgetChange(nodeId, 'y1', vyR);
} else {
onWidgetChange(nodeId, 'x2', vxR);
onWidgetChange(nodeId, 'y2', vyR);
}
}, [dragging, getCoords, nodeId, onWidgetChange, square, xreal, yreal, x1, y1, x2, y2]);
const onPointerUp = useCallback(() => { const onPointerUp = useCallback(() => {
setDragging(null); setDragging(null);
panStartRef.current = null;
}, []); }, []);
const left = Math.min(x1, x2); const left = Math.min(x1, x2);
@@ -75,13 +138,14 @@ export default function CropBoxOverlay({
<div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} /> <div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
<div <div
className="crop-rect" className={`crop-rect ${aLocked || bLocked ? 'crop-rect-locked' : ''}`}
style={{ style={{
left: `${left * 100}%`, left: `${left * 100}%`,
top: `${top * 100}%`, top: `${top * 100}%`,
width: `${(right - left) * 100}%`, width: `${(right - left) * 100}%`,
height: `${(bottom - top) * 100}%`, height: `${(bottom - top) * 100}%`,
}} }}
onPointerDown={onPointerDown('rect')}
/> />
<div <div

View File

@@ -12,6 +12,10 @@ const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
const MarkupOverlay = lazy(() => import('./MarkupOverlay')); const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay')); const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram')); const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay'));
const MultiProfileOverlay = lazy(() => import('./MultiProfileOverlay'));
const PerspectiveOverlay = lazy(() => import('./PerspectiveOverlay'));
import TextNoteNode from './TextNoteNode'; import TextNoteNode from './TextNoteNode';
@@ -21,6 +25,7 @@ import {
import { getGroupMinimumSize } from './groupSizing'; import { getGroupMinimumSize } from './groupSizing';
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting'; import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting';
import { useIsFavorite, toggleFavorite } from './favorites';
import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types'; import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types';
@@ -215,6 +220,7 @@ function GroupNode({ id, data }: { id: string; data: NodeData }) {
id={input.handleId} id={input.handleId}
className="typed-handle" className="typed-handle"
style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }} style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }}
isConnectableStart={false}
/> />
<span className="io-label">{formatUiLabel(input.label || input.name)}</span> <span className="io-label">{formatUiLabel(input.label || input.name)}</span>
</> </>
@@ -997,6 +1003,7 @@ function NodeTable({ rows }: { rows: Array<Record<string, unknown>> }) {
function CustomNode({ id, data }: { id: string; data: NodeData }) { function CustomNode({ id, data }: { id: string; data: NodeData }) {
const ctx = useContext(NodeContext); const ctx = useContext(NodeContext);
const def = data.definition; const def = data.definition;
const favorited = useIsFavorite(data.className);
const scalarDisplay = formatScalarDisplay(data.scalarValue); const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs); const processingTimeText = formatProcessingTime(data.processingTimeMs);
const nodeWidth = useStore( const nodeWidth = useStore(
@@ -1109,11 +1116,24 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
for (const [name, spec] of Object.entries(optional)) { for (const [name, spec] of Object.entries(optional)) {
const [type, opts] = getSpecTypeAndOptions(spec as InputSpec); const [type, opts] = getSpecTypeAndOptions(spec as InputSpec);
if (isProgressive && isDataSocketSpec(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+)$/); const match = name.match(/^field_(\d+)$/);
if (match) { if (match) {
const idx = parseInt(match[1], 10); 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) }); dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) });
visibleInputNames.add(name); visibleInputNames.add(name);
} }
@@ -1180,6 +1200,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|| data.overlay.kind === 'mask_paint' || data.overlay.kind === 'mask_paint'
|| data.overlay.kind === 'markup' || data.overlay.kind === 'markup'
|| data.overlay.kind === 'threshold_histogram' || data.overlay.kind === 'threshold_histogram'
|| data.overlay.kind === 'radial_profile'
|| data.overlay.kind === 'straighten_path'
|| data.overlay.kind === 'multi_profile'
|| data.overlay.kind === 'perspective'
); );
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup'; const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
const overlayTitle = data.overlay?.section_title const overlayTitle = data.overlay?.section_title
@@ -1195,6 +1219,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
? 'Cursors' ? 'Cursors'
: data.overlay?.kind === 'line_plot' : data.overlay?.kind === 'line_plot'
? 'Line Plot' ? 'Line Plot'
: data.overlay?.kind === 'radial_profile'
? 'Radial Profile'
: data.overlay?.kind === 'straighten_path'
? 'Path'
: data.overlay?.kind === 'multi_profile'
? 'Preview'
: data.overlay?.kind === 'perspective'
? 'Perspective'
: 'Cross Section'); : 'Cross Section');
const headerMeta = (() => { const headerMeta = (() => {
if (data.className === 'Folder') { if (data.className === 'Folder') {
@@ -1218,6 +1250,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
<div className="node-title-left"> <div className="node-title-left">
<span className="node-title-main">{data.label}</span> <span className="node-title-main">{data.label}</span>
<button className="node-help-btn nodrag nopan" title="Documentation" onClick={(e) => { e.stopPropagation(); ctx?.openHelp(data.label); }}>?</button> <button className="node-help-btn nodrag nopan" title="Documentation" onClick={(e) => { e.stopPropagation(); ctx?.openHelp(data.label); }}>?</button>
<button
className={`node-fav-btn nodrag nopan${favorited ? ' is-favorited' : ''}`}
title={favorited ? 'Remove from favorites' : 'Add to favorites'}
aria-pressed={favorited}
onClick={(e) => { e.stopPropagation(); toggleFavorite(data.className); }}
>
{favorited ? '♥' : '♡'}
</button>
</div> </div>
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>} {headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
</div> </div>
@@ -1239,6 +1279,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
id={`input::${socketName}::${socketType}`} id={`input::${socketName}::${socketType}`}
className="typed-handle" className="typed-handle"
style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }} style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }}
isConnectableStart={false}
/> />
)} )}
{( {(
@@ -1278,6 +1319,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
id={`input::${inp.name}::${inp.type}`} id={`input::${inp.name}::${inp.type}`}
className="typed-handle" className="typed-handle"
style={{ background: TYPE_COLORS[inp.type as string] || 'var(--fallback-type)' }} style={{ background: TYPE_COLORS[inp.type as string] || 'var(--fallback-type)' }}
isConnectableStart={false}
/> />
<span className="io-label">{inp.label || inp.name}</span> <span className="io-label">{inp.label || inp.name}</span>
{inlineWidgetsByInput.has(inp.name) && ( {inlineWidgetsByInput.has(inp.name) && (
@@ -1351,6 +1393,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
id={`input::${socketName}::${socketType}`} id={`input::${socketName}::${socketType}`}
className="typed-handle" className="typed-handle"
style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }} style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }}
isConnectableStart={false}
/> />
)} )}
{(w.socketType && connectedInputs?.has(w.name)) {(w.socketType && connectedInputs?.has(w.name))
@@ -1387,15 +1430,17 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
{/* Interactive 3D surface view */} {/* Interactive 3D surface view */}
{!!data.meshData && ( {!!data.meshData && (
<CollapsibleSection title="3D View" defaultOpen={true}> <CollapsibleSection title="3D View" defaultOpen={true}>
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}> <PreviewBoundary resetKey={String(data.meshData)}>
<SurfaceView <Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
meshData={data.meshData as any} <SurfaceView
nodeId={id} meshData={data.meshData as any}
widgetValues={data.widgetValues} nodeId={id}
runtimeValues={data.runtimeValues} widgetValues={data.widgetValues}
onRuntimeValuesChange={ctx?.onRuntimeValuesChange} runtimeValues={data.runtimeValues}
/> onRuntimeValuesChange={ctx?.onRuntimeValuesChange}
</Suspense> />
</Suspense>
</PreviewBoundary>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -1482,6 +1527,9 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
bLocked={!!data.overlay!.b_locked} bLocked={!!data.overlay!.b_locked}
nodeId={id} nodeId={id}
onWidgetChange={ctx!.onWidgetChange} onWidgetChange={ctx!.onWidgetChange}
square={!!(data.widgetValues.square ?? data.overlay!.square)}
xreal={(data.overlay!.xreal ?? 1) as number}
yreal={(data.overlay!.yreal ?? 1) as number}
/> />
) : data.overlay!.kind === 'cursor_points' ? ( ) : data.overlay!.kind === 'cursor_points' ? (
<CrossSectionOverlay <CrossSectionOverlay
@@ -1524,6 +1572,43 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
nodeId={id} nodeId={id}
onWidgetChange={ctx!.onWidgetChange} onWidgetChange={ctx!.onWidgetChange}
/> />
) : data.overlay!.kind === 'radial_profile' ? (
<RadialProfileOverlay
image={data.overlay!.image ?? ''}
cx={(data.widgetValues.cx ?? data.overlay!.cx ?? 0.5) as number}
cy={(data.widgetValues.cy ?? data.overlay!.cy ?? 0.5) as number}
ex={(data.widgetValues.ex ?? data.overlay!.ex ?? 0.9) as number}
ey={(data.widgetValues.ey ?? data.overlay!.ey ?? 0.5) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'straighten_path' ? (
<StraightenPathOverlay
image={data.overlay!.image ?? ''}
points={(data.overlay!.points ?? []) as Array<{ x: number; y: number }>}
thickness={(data.widgetValues.thickness ?? data.overlay!.thickness ?? 1) as number}
xres={(data.overlay!.xres ?? 1) as number}
yres={(data.overlay!.yres ?? 1) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'multi_profile' ? (
<MultiProfileOverlay
image={data.overlay!.image ?? ''}
row={(data.overlay!.row ?? 0) as number}
direction={(data.overlay!.direction ?? 'horizontal') as 'horizontal' | 'vertical'}
maxIndex={(data.overlay!.max_index ?? 0) as number}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'perspective' ? (
<PerspectiveOverlay
image={data.overlay!.image ?? ''}
correctedImage={data.overlay!.corrected_image ?? ''}
corners={(data.overlay!.corners ?? []) as Array<{ x: number; y: number }>}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
/>
) : data.overlay!.kind === 'angle_measure' ? ( ) : data.overlay!.kind === 'angle_measure' ? (
<AngleMeasureOverlay <AngleMeasureOverlay
image={data.overlay!.image ?? ''} image={data.overlay!.image ?? ''}

View File

@@ -0,0 +1,89 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { clamp, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.multiprofile-overlay';
interface MultiProfileOverlayProps {
image: string;
row: number;
direction: 'horizontal' | 'vertical';
maxIndex: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
export default function MultiProfileOverlay({
image, row, direction, maxIndex,
nodeId, onWidgetChange,
}: MultiProfileOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [draftRow, setDraftRow] = useState<number | null>(null);
const draggingRef = useRef(false);
const pendingCommitRef = useRef<number | null>(null);
useEffect(() => {
if (pendingCommitRef.current !== null && row === pendingCommitRef.current) {
pendingCommitRef.current = null;
setDraftRow(null);
}
}, [row]);
const displayRow = draftRow ?? row;
const fractionFromEvent = useCallback((e: React.PointerEvent<Element>): number => {
if (!containerRef.current) return 0;
const { fx, fy } = pointerToFraction(e, containerRef.current);
return direction === 'horizontal' ? fy : fx;
}, [direction]);
const fractionToIndex = useCallback((frac: number): number => {
return clamp(Math.round(frac * maxIndex), 0, maxIndex);
}, [maxIndex]);
const onPointerDown = useCallback((e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
setDraftRow(fractionToIndex(fractionFromEvent(e)));
}, [fractionFromEvent, fractionToIndex]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!draggingRef.current) return;
setDraftRow(fractionToIndex(fractionFromEvent(e)));
}, [fractionFromEvent, fractionToIndex]);
const onPointerUp = useCallback(() => {
if (draggingRef.current && draftRow !== null) {
pendingCommitRef.current = draftRow;
onWidgetChange(nodeId, 'row', draftRow);
}
draggingRef.current = false;
}, [draftRow, nodeId, onWidgetChange]);
const fracPos = maxIndex > 0 ? displayRow / maxIndex : 0;
const linePct = clamp(fracPos * 100, 0, 100);
const lineStyle: React.CSSProperties = direction === 'horizontal'
? { left: 0, right: 0, top: `${linePct}%`, height: 0 }
: { top: 0, bottom: 0, left: `${linePct}%`, width: 0 };
const cursorClass = direction === 'horizontal' ? 'cursor-row' : 'cursor-col';
return (
<div
ref={containerRef}
className={`nodrag nowheel multiprofile-overlay ${cursorClass}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="A blended with B" draggable={false} className="multiprofile-image" />
<div className={`multiprofile-line multiprofile-line-${direction}`} style={lineStyle} />
<div className="multiprofile-readout">
{direction === 'horizontal' ? 'row' : 'col'} {displayRow}
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.perspective-overlay';
const CORNER_NAMES = ['top_left', 'top_right', 'bottom_left', 'bottom_right'] as const;
type CornerName = typeof CORNER_NAMES[number];
const CORNER_ANCHORS: Record<CornerName, { ax: number; ay: number }> = {
top_left: { ax: 0, ay: 0 },
top_right: { ax: 1, ay: 0 },
bottom_left: { ax: 0, ay: 1 },
bottom_right: { ax: 1, ay: 1 },
};
interface Corner { x: number; y: number }
interface Props {
image: string;
correctedImage: string;
corners: Corner[];
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
function cornerToPercent(corner: Corner, name: CornerName) {
const anchor = CORNER_ANCHORS[name];
return {
left: (anchor.ax + corner.x) * 100,
top: (anchor.ay + corner.y) * 100,
};
}
function cornersKey(c: Corner[]): string {
return c.map((p) => `${p.x},${p.y}`).join(';');
}
export default function PerspectiveOverlay({
image, correctedImage, corners, nodeId, onWidgetChange,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<CornerName | null>(null);
const [draft, setDraft] = useState<Corner[] | null>(null);
const pendingCommitRef = useRef<string | null>(null);
const [showCorrected, setShowCorrected] = useState(false);
useEffect(() => {
if (pendingCommitRef.current && cornersKey(corners) === pendingCommitRef.current) {
pendingCommitRef.current = null;
setDraft(null);
}
}, [corners]);
const liveCorners = draft ?? corners;
const onPointerDown = useCallback((corner: CornerName) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = corner;
setDraft([...liveCorners]);
}, [liveCorners]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
const name = draggingRef.current;
if (!name || !containerRef.current) return;
const { fx, fy } = pointerToFraction(e, containerRef.current);
const anchor = CORNER_ANCHORS[name];
const cx = Math.max(-1, Math.min(1, parseFloat((fx - anchor.ax).toFixed(3))));
const cy = Math.max(-1, Math.min(1, parseFloat((fy - anchor.ay).toFixed(3))));
const idx = CORNER_NAMES.indexOf(name);
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev];
next[idx] = { x: cx, y: cy };
return next;
});
}, []);
const onPointerUp = useCallback(() => {
const name = draggingRef.current;
if (!name || !draft) {
draggingRef.current = null;
return;
}
draggingRef.current = null;
pendingCommitRef.current = cornersKey(draft);
for (let i = 0; i < CORNER_NAMES.length; i++) {
onWidgetChange(nodeId, `${CORNER_NAMES[i]}_x`, draft[i].x);
onWidgetChange(nodeId, `${CORNER_NAMES[i]}_y`, draft[i].y);
}
}, [draft, nodeId, onWidgetChange]);
const positions = CORNER_NAMES.map((name, i) => cornerToPercent(liveCorners[i] || { x: 0, y: 0 }, name));
const quadPoints = `${positions[0].left},${positions[0].top} ${positions[1].left},${positions[1].top} ${positions[3].left},${positions[3].top} ${positions[2].left},${positions[2].top}`;
return (
<div className="perspective-overlay-wrap">
<div className="perspective-tab-bar">
<button
className={`perspective-tab nodrag${!showCorrected ? ' active' : ''}`}
onClick={() => setShowCorrected(false)}
>
Source
</button>
<button
className={`perspective-tab nodrag${showCorrected ? ' active' : ''}`}
onClick={() => setShowCorrected(true)}
>
Corrected
</button>
</div>
{showCorrected ? (
<div className="perspective-overlay perspective-corrected">
<img src={correctedImage} alt="corrected" draggable={false} />
</div>
) : (
<div
ref={containerRef}
className="nodrag nowheel perspective-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="source" draggable={false} />
<svg className="perspective-quad" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon
points={quadPoints}
fill="none"
stroke="var(--selection, #3b82f6)"
strokeWidth="0.4"
strokeLinejoin="round"
/>
</svg>
{CORNER_NAMES.map((name, i) => (
<div
key={name}
className={`perspective-handle${draggingRef.current === name ? ' dragging' : ''}`}
style={{ left: `${positions[i].left}%`, top: `${positions[i].top}%` }}
onPointerDown={onPointerDown(name)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import React, { useRef, useState, useCallback } from 'react';
import { clampFraction, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.radial-overlay';
interface RadialProfileOverlayProps {
image: string;
cx: number;
cy: number;
ex: number;
ey: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
type DragHandle = 'center' | 'a' | 'b';
interface DragState {
handle: DragHandle;
start: { fx: number; fy: number };
points: { cx: number; cy: number; ex: number; ey: number };
}
const round3 = (v: number) => parseFloat(v.toFixed(3));
export default function RadialProfileOverlay({
image, cx, cy, ex, ey,
nodeId, onWidgetChange,
}: RadialProfileOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<DragState | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!);
}, []);
const updateWidgets = useCallback((updates: Record<string, number>) => {
for (const [name, value] of Object.entries(updates)) {
onWidgetChange(nodeId, name, value);
}
}, [nodeId, onWidgetChange]);
const onPointerDown = useCallback((handle: DragHandle) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const start = getCoords(e);
setDragging({ handle, start, points: { cx, cy, ex, ey } });
}, [cx, cy, ex, ey, getCoords]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const pts = dragging.points;
if (dragging.handle === 'center') {
const dx = fx - dragging.start.fx;
const dy = fy - dragging.start.fy;
updateWidgets({
cx: round3(clampFraction(pts.cx + dx)),
cy: round3(clampFraction(pts.cy + dy)),
ex: round3(clampFraction(pts.ex + dx)),
ey: round3(clampFraction(pts.ey + dy)),
});
} else if (dragging.handle === 'a') {
updateWidgets({ ex: round3(fx), ey: round3(fy) });
} else {
updateWidgets({
ex: round3(clampFraction(2 * pts.cx - fx)),
ey: round3(clampFraction(2 * pts.cy - fy)),
});
}
}, [dragging, getCoords, updateWidgets]);
const onPointerUp = useCallback(() => {
setDragging(null);
}, []);
const bx = 2 * cx - ex;
const by = 2 * cy - ey;
const rxFrac = Math.abs(ex - cx);
const ryFrac = Math.abs(ey - cy);
return (
<div
ref={containerRef}
className="nodrag nowheel radial-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="field" draggable={false} className="radial-image" />
<svg className="radial-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<ellipse
cx={cx * 100} cy={cy * 100}
rx={rxFrac * 100} ry={ryFrac * 100}
className="radial-circle"
/>
<line
x1={ex * 100} y1={ey * 100}
x2={bx * 100} y2={by * 100}
className="radial-diameter"
/>
</svg>
<div
className="radial-marker radial-marker-end"
style={{ left: `${ex * 100}%`, top: `${ey * 100}%` }}
onPointerDown={onPointerDown('a')}
/>
<div
className="radial-marker radial-marker-end"
style={{ left: `${bx * 100}%`, top: `${by * 100}%` }}
onPointerDown={onPointerDown('b')}
/>
<div
className="radial-marker radial-marker-center"
style={{ left: `${cx * 100}%`, top: `${cy * 100}%` }}
onPointerDown={onPointerDown('center')}
/>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { clampFraction, pointerToFraction } from './overlayUtils';
export const CAPTURE_SELECTOR = '.straighten-overlay';
interface Point { x: number; y: number; }
interface StraightenPathOverlayProps {
image: string;
points: Point[];
thickness: number;
xres: number;
yres: number;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
}
const round3 = (v: number) => parseFloat(v.toFixed(3));
function pointsToStrings(points: Point[]) {
return {
points_x: points.map(p => round3(p.x)).join(', '),
points_y: points.map(p => round3(p.y)).join(', '),
};
}
// Solve a 1-D natural cubic spline (matches scipy.interpolate.CubicSpline with
// bc_type="natural") and return a function that evaluates it at any t.
function naturalCubicSpline(t: number[], y: number[]): (tq: number) => number {
const n = t.length;
if (n < 2) return () => y[0] ?? 0;
if (n === 2) {
return (tq) => y[0] + (y[1] - y[0]) * (tq - t[0]) / (t[1] - t[0]);
}
const h = new Array(n - 1);
for (let i = 0; i < n - 1; i++) h[i] = t[i + 1] - t[i];
// Tridiagonal system for second derivatives M[1..n-2] (M[0] = M[n-1] = 0).
const a = new Array(n).fill(0);
const b = new Array(n).fill(0);
const c = new Array(n).fill(0);
const d = new Array(n).fill(0);
for (let i = 1; i < n - 1; i++) {
a[i] = h[i - 1];
b[i] = 2 * (h[i - 1] + h[i]);
c[i] = h[i];
d[i] = 6 * ((y[i + 1] - y[i]) / h[i] - (y[i] - y[i - 1]) / h[i - 1]);
}
for (let i = 2; i < n - 1; i++) {
const w = a[i] / b[i - 1];
b[i] -= w * c[i - 1];
d[i] -= w * d[i - 1];
}
const M = new Array(n).fill(0);
if (n >= 3) {
M[n - 2] = d[n - 2] / b[n - 2];
for (let i = n - 3; i >= 1; i--) {
M[i] = (d[i] - c[i] * M[i + 1]) / b[i];
}
}
return (tq) => {
let i = 0;
while (i < n - 2 && tq > t[i + 1]) i++;
const dx = h[i];
const A = (t[i + 1] - tq) / dx;
const B = (tq - t[i]) / dx;
return A * y[i] + B * y[i + 1]
+ ((A ** 3 - A) * M[i] + (B ** 3 - B) * M[i + 1]) * (dx * dx) / 6;
};
}
const CURVE_SAMPLES_PER_SEGMENT = 24;
function buildCurvePath(points: Point[]): string {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x * 100} ${points[0].y * 100}`;
if (points.length === 2) {
return `M ${points[0].x * 100} ${points[0].y * 100} L ${points[1].x * 100} ${points[1].y * 100}`;
}
const n = points.length;
const t = Array.from({ length: n }, (_, i) => i / (n - 1));
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const fx = naturalCubicSpline(t, xs);
const fy = naturalCubicSpline(t, ys);
const total = (n - 1) * CURVE_SAMPLES_PER_SEGMENT;
const segs: string[] = [`M ${points[0].x * 100} ${points[0].y * 100}`];
for (let i = 1; i <= total; i++) {
const tq = i / total;
segs.push(`L ${fx(tq) * 100} ${fy(tq) * 100}`);
}
return segs.join(' ');
}
function pointsKey(points: Point[]) {
return points.map(p => `${round3(p.x)},${round3(p.y)}`).join('|');
}
export default function StraightenPathOverlay({
image, points, thickness, xres, yres,
nodeId, onWidgetChange,
}: StraightenPathOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [draft, setDraft] = useState<Point[] | null>(null);
const draggingRef = useRef<number | null>(null);
const pendingCommitRef = useRef<string | null>(null);
useEffect(() => {
if (pendingCommitRef.current !== null
&& pointsKey(points) === pendingCommitRef.current) {
pendingCommitRef.current = null;
setDraft(null);
}
}, [points]);
const commit = useCallback((next: Point[]) => {
pendingCommitRef.current = pointsKey(next);
const { points_x, points_y } = pointsToStrings(next);
onWidgetChange(nodeId, 'points_x', points_x);
onWidgetChange(nodeId, 'points_y', points_y);
}, [nodeId, onWidgetChange]);
const displayPoints = draft ?? points;
const onPointerDownMarker = useCallback((idx: number) => (e: React.PointerEvent<Element>) => {
e.stopPropagation();
e.preventDefault();
if (e.shiftKey && displayPoints.length > 2) {
const next = displayPoints.filter((_, i) => i !== idx);
setDraft(next);
commit(next);
return;
}
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = idx;
setDraft(displayPoints);
}, [displayPoints, commit]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
const idx = draggingRef.current;
if (idx === null || !containerRef.current) return;
const { fx, fy } = pointerToFraction(e, containerRef.current);
setDraft(prev => {
const base = prev ?? points;
return base.map((p, i) => i === idx
? { x: clampFraction(fx), y: clampFraction(fy) }
: p);
});
}, [points]);
const onPointerUp = useCallback(() => {
if (draggingRef.current !== null && draft) {
commit(draft);
}
draggingRef.current = null;
}, [draft, commit]);
const onDoubleClick = useCallback((e: React.MouseEvent<Element>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const fx = clampFraction((e.clientX - rect.left) / rect.width);
const fy = clampFraction((e.clientY - rect.top) / rect.height);
const next = [...displayPoints, { x: fx, y: fy }];
setDraft(next);
commit(next);
}, [displayPoints, commit]);
const curveD = buildCurvePath(displayPoints);
const refRes = Math.max(xres, yres) || 1;
const bandWidthPct = (thickness / refRes) * 100;
return (
<div
ref={containerRef}
className="nodrag nowheel straighten-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
onDoubleClick={onDoubleClick}
>
<img src={image} alt="field" draggable={false} className="straighten-image" />
<svg className="straighten-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
{curveD && bandWidthPct > 0 && (
<path
d={curveD}
className="straighten-band"
fill="none"
strokeWidth={bandWidthPct}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{curveD && (
<path d={curveD} className="straighten-curve" fill="none" />
)}
</svg>
{displayPoints.map((p, i) => (
<div
key={i}
className="straighten-marker"
style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%` }}
onPointerDown={onPointerDownMarker(i)}
title="shift-click to remove"
/>
))}
</div>
);
}

View File

@@ -133,6 +133,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const pointerEnteredAtRef = useRef(0); const pointerEnteredAtRef = useRef(0);
const lastWheelAtRef = useRef(0); const lastWheelAtRef = useRef(0);
const gestureStartedInsideRef = useRef(false); const gestureStartedInsideRef = useRef(false);
const scheduleViewportSyncRef = useRef<(delay?: number, scheduleRun?: boolean) => void>(() => {});
const updateDiagnosticsRef = useRef<(patch: Partial<DiagnosticsState>) => void>(() => {});
const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({ const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({
status: meshData ? 'initializing' : 'waiting for mesh', status: meshData ? 'initializing' : 'waiting for mesh',
webgl: 'pending', webgl: 'pending',
@@ -239,6 +241,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
scheduleViewportSync(0, true); scheduleViewportSync(0, true);
}, [applyCameraState, scheduleViewportSync]); }, [applyCameraState, scheduleViewportSync]);
scheduleViewportSyncRef.current = scheduleViewportSync;
updateDiagnosticsRef.current = updateDiagnostics;
// Initialize Three.js scene once // Initialize Three.js scene once
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -256,8 +261,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x0f172a); renderer.setClearColor(0x0f172a);
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
updateDiagnostics({ updateDiagnosticsRef.current({
status: meshData ? 'renderer ready' : 'waiting for mesh', status: 'renderer ready',
webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`, webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`,
canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`, canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`,
render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`, render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`,
@@ -266,13 +271,13 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
const handleContextLost = (event: Event) => { const handleContextLost = (event: Event) => {
event.preventDefault(); event.preventDefault();
updateDiagnostics({ updateDiagnosticsRef.current({
status: 'webgl context lost', status: 'webgl context lost',
error: 'WebGL context lost', error: 'WebGL context lost',
}); });
}; };
const handleContextRestored = () => { const handleContextRestored = () => {
updateDiagnostics({ updateDiagnosticsRef.current({
status: 'webgl context restored', status: 'webgl context restored',
error: '', error: '',
}); });
@@ -303,7 +308,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
TWO: THREE.TOUCH.DOLLY_ROTATE, TWO: THREE.TOUCH.DOLLY_ROTATE,
}; };
renderer.domElement.style.touchAction = 'none'; renderer.domElement.style.touchAction = 'none';
const handleControlsEnd = () => scheduleViewportSync(120, true); const handleControlsEnd = () => scheduleViewportSyncRef.current(120, true);
controls.addEventListener('end', handleControlsEnd); controls.addEventListener('end', handleControlsEnd);
// Lighting // Lighting
@@ -341,7 +346,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
r.setSize(w, w); r.setSize(w, w);
c.aspect = 1; c.aspect = 1;
c.updateProjectionMatrix(); c.updateProjectionMatrix();
updateDiagnostics({ updateDiagnosticsRef.current({
canvas: `${r.domElement.width}x${r.domElement.height} px`, canvas: `${r.domElement.width}x${r.domElement.height} px`,
}); });
}); });
@@ -361,7 +366,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
} }
threeRef.current = null; threeRef.current = null;
}; };
}, [scheduleViewportSync, updateDiagnostics]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyCameraState]);
useEffect(() => { useEffect(() => {
if (meshData) { if (meshData) {

View File

@@ -56,6 +56,34 @@ async function sessionFetch(input: string, init?: RequestInit) {
return fetch(input, withSessionHeaders(init)); 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() { export async function getNodes() {
const r = await sessionFetch('/nodes'); const r = await sessionFetch('/nodes');
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`); if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
@@ -84,30 +112,40 @@ export async function createUploadFolder(relativePath: string) {
return r.json(); 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(); const fd = new FormData();
if (relativePath) fd.append('relative_path', relativePath); if (relativePath) fd.append('relative_path', relativePath);
fd.append('file', file); fd.append('file', file);
const r = await sessionFetch('/upload', { method: 'POST', body: fd }); const { status, text } = await xhrRequest('POST', '/upload', fd, {
if (!r.ok) { headers: { 'X-Argonode-Session': getSessionId() },
const text = await r.text(); onProgress,
throw new Error(`Upload failed (${r.status}): ${text}`); });
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(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const r = await fetch('/upload-plugin', { method: 'POST', body: fd }); const { status, text } = await xhrRequest('POST', '/upload-plugin', fd, { onProgress });
if (r.status === 404) { if (status === 404) {
throw new Error('Plugin upload is not available in this build.'); throw new Error('Plugin upload is not available in this build.');
} }
if (!r.ok) { if (status < 200 || status >= 300) {
const text = await r.text(); throw new Error(text || `Upload failed (${status})`);
throw new Error(text || `Upload failed (${r.status})`);
} }
return r.json(); try { return JSON.parse(text); } catch { return {}; }
} }
export async function getChannels(filepath: string) { export async function getChannels(filepath: string) {

View File

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

68
frontend/src/favorites.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
const STORAGE_KEY = 'tono_favorite_nodes';
let favorites: Set<string> = loadFromStorage();
const listeners = new Set<() => void>();
function loadFromStorage(): Set<string> {
if (typeof localStorage === 'undefined') return new Set();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((x): x is string => typeof x === 'string'));
} catch {
return new Set();
}
}
function persist(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...favorites]));
} catch {
// Storage full or disabled — ignore.
}
}
function notify(): void {
for (const cb of listeners) cb();
}
export function getFavorites(): Set<string> {
return favorites;
}
export function isFavorite(className: string): boolean {
return favorites.has(className);
}
export function toggleFavorite(className: string): void {
const next = new Set(favorites);
if (next.has(className)) next.delete(className);
else next.add(className);
favorites = next;
persist();
notify();
}
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
export function useFavorites(): Set<string> {
return useSyncExternalStore(subscribe, getFavorites, getFavorites);
}
export function useIsFavorite(className: string): boolean {
return useSyncExternalStore(
subscribe,
() => favorites.has(className),
() => favorites.has(className),
);
}

View File

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

46
frontend/src/nodeUsage.ts Normal file
View File

@@ -0,0 +1,46 @@
const STORAGE_KEY = 'tono_node_usage_counts';
let counts: Record<string, number> = loadFromStorage();
function loadFromStorage(): Record<string, number> {
if (typeof localStorage === 'undefined') return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
return parsed;
} catch {
return {};
}
}
function persist(): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(counts));
} catch {
// ignore
}
}
export function recordUsage(className: string): void {
counts = { ...counts, [className]: (counts[className] || 0) + 1 };
persist();
}
export function getUsageCount(className: string): number {
return counts[className] || 0;
}
export function pickWeightedRandom(classNames: string[]): string | null {
if (classNames.length === 0) return null;
const weights = classNames.map((cn) => 1 / (1 + (counts[cn] || 0)));
const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (let i = 0; i < classNames.length; i++) {
r -= weights[i];
if (r <= 0) return classNames[i];
}
return classNames[classNames.length - 1];
}

View File

@@ -1,4 +1,10 @@
/* ── Theme tokens ──────────────────────────────────────────────────── */ /* ── 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 { :root {
/* Backgrounds */ /* Backgrounds */
--bg-app: #1a1a2e; --bg-app: #1a1a2e;
@@ -22,6 +28,7 @@
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-bright: #e2e8f0; --text-bright: #e2e8f0;
--text-heading: #ffffff; --text-heading: #ffffff;
--text-node-title: #ffffff;
--text-secondary: #94a3b8; --text-secondary: #94a3b8;
--text-muted: #64748b; --text-muted: #64748b;
--text-faint: #475569; --text-faint: #475569;
@@ -45,8 +52,13 @@
--danger: #e94560; --danger: #e94560;
--danger-hover: #ff6b81; --danger-hover: #ff6b81;
--danger-locked: #e91e63; --danger-locked: #e91e63;
--danger-outline: #ef4444;
--danger-outline-glow: rgba(239, 68, 68, 0.35);
--error-text: #ef9a9a; --error-text: #ef9a9a;
--error-bg: rgba(183, 28, 28, 0.2); --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 */
--warning: #fbbf24; --warning: #fbbf24;
@@ -120,6 +132,212 @@
/* Dynamic-lookup fallbacks */ /* Dynamic-lookup fallbacks */
--fallback-type: #999; --fallback-type: #999;
--fallback-cat: #333; --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 ──────────────────────────────────────────────────── */ /* ── Reset & base ──────────────────────────────────────────────────── */
@@ -279,8 +497,12 @@ html, body, #root {
background: var(--bg-toolbar); background: var(--bg-toolbar);
border: 1px solid var(--border-toolbar); border: 1px solid var(--border-toolbar);
max-width: 60%; max-width: 60%;
min-width: 240px;
text-align: center; text-align: center;
animation: toast-in 0.2s ease-out; animation: toast-in 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 5px;
} }
.status-toast.closing { .status-toast.closing {
animation: toast-out 0.3s ease-in forwards; animation: toast-out 0.3s ease-in forwards;
@@ -288,6 +510,19 @@ html, body, #root {
.status-toast.info { color: var(--accent-light); } .status-toast.info { color: var(--accent-light); }
.status-toast.error { color: var(--error-text); background: var(--error-bg); } .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 { @keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); } from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 1; transform: translateX(-50%) translateY(0); }
@@ -343,7 +578,7 @@ html, body, #root {
} }
.group-node-title { .group-node-title {
background: #334155; background: var(--bg-group-title);
} }
.group-node-title .node-title-main { .group-node-title .node-title-main {
@@ -384,7 +619,7 @@ html, body, #root {
padding: 2px 6px; padding: 2px 6px;
border: 1px solid rgba(148, 163, 184, 0.45); border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 4px; border-radius: 4px;
background: rgba(15, 23, 42, 0.72); background: var(--bg-overlay-input);
color: var(--text-heading); color: var(--text-heading);
font: inherit; font: inherit;
} }
@@ -398,7 +633,7 @@ html, body, #root {
.group-toggle { .group-toggle {
border: 0; border: 0;
background: rgba(15, 23, 42, 0.65); background: var(--bg-overlay-input);
color: var(--text-heading); color: var(--text-heading);
border-radius: 4px; border-radius: 4px;
padding: 2px 8px; padding: 2px 8px;
@@ -447,7 +682,7 @@ html, body, #root {
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 12px; left: 12px;
color: rgba(148, 163, 184, 0.58); color: var(--text-watermark);
font-size: 10px; font-size: 10px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: lowercase; text-transform: lowercase;
@@ -497,7 +732,7 @@ html, body, #root {
gap: 8px; gap: 8px;
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
color: var(--text-heading); color: var(--text-node-title);
border-radius: 5px 5px 0 0; border-radius: 5px 5px 0 0;
border-bottom: 1px solid var(--border-title); border-bottom: 1px solid var(--border-title);
} }
@@ -537,9 +772,9 @@ html, body, #root {
width: 15px; width: 15px;
height: 15px; height: 15px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.12); background: var(--node-help-btn-bg);
border: 1px solid rgba(255, 255, 255, 0.25); border: 1px solid var(--node-help-btn-border);
color: rgba(255, 255, 255, 0.75); color: var(--node-help-btn-text);
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
@@ -553,8 +788,36 @@ html, body, #root {
} }
.node-help-btn:hover { .node-help-btn:hover {
background: rgba(255, 255, 255, 0.28); background: var(--node-help-btn-bg-hover);
border-color: rgba(255, 255, 255, 0.5); border-color: var(--node-help-btn-border-hover);
}
.node-fav-btn {
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--node-help-btn-bg);
border: 1px solid var(--node-help-btn-border);
color: var(--node-help-btn-text);
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 0;
cursor: pointer;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.node-fav-btn:hover {
background: var(--node-help-btn-bg-hover);
border-color: var(--node-help-btn-border-hover);
}
.node-fav-btn.is-favorited {
color: #ffffff;
} }
/* ── Node help panel ─────────────────────────────────────── */ /* ── Node help panel ─────────────────────────────────────── */
@@ -564,15 +827,15 @@ html, body, #root {
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px; gap: 2px;
padding: 6px 8px 0; padding: 6px 8px 0;
background: #0a0f1a; background: var(--bg-help-tabs);
border-bottom: 1px solid #1e293b; border-bottom: 1px solid var(--bg-help-tab-active);
flex-shrink: 0; flex-shrink: 0;
} }
.node-help-fold-btn { .node-help-fold-btn {
background: none; background: none;
border: none; border: none;
color: #64748b; color: var(--text-muted);
font-size: 9px; font-size: 9px;
padding: 0 4px; padding: 0 4px;
cursor: pointer; cursor: pointer;
@@ -581,7 +844,7 @@ html, body, #root {
align-self: center; align-self: center;
transition: color 0.12s; transition: color 0.12s;
} }
.node-help-fold-btn:hover { color: #f1f5f9; } .node-help-fold-btn:hover { color: var(--text-heading); }
.node-help-tab { .node-help-tab {
display: flex; display: flex;
@@ -589,23 +852,23 @@ html, body, #root {
gap: 4px; gap: 4px;
padding: 4px 8px; padding: 4px 8px;
border-radius: 5px 5px 0 0; border-radius: 5px 5px 0 0;
background: #0f172a; background: var(--bg-help-tab);
border: 1px solid #1e293b; border: 1px solid var(--bg-help-tab-active);
border-bottom: none; border-bottom: none;
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 11px;
color: #64748b; color: var(--text-muted);
transition: color 0.12s, background 0.12s; transition: color 0.12s, background 0.12s;
user-select: none; user-select: none;
max-width: 160px; 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 { .node-help-tab.active {
color: #f1f5f9; color: var(--text-heading);
background: #1e293b; background: var(--bg-help-tab-active);
border-color: #334155; border-color: var(--border-default);
} }
.node-help-tab-label { .node-help-tab-label {
@@ -630,8 +893,8 @@ html, body, #root {
.node-help-tab-add { .node-help-tab-add {
background: none; background: none;
border: 1px dashed #334155; border: 1px dashed var(--border-help-tab-add);
color: #475569; color: var(--text-faint);
font-size: 13px; font-size: 13px;
line-height: 1; line-height: 1;
width: 22px; width: 22px;
@@ -645,7 +908,7 @@ html, body, #root {
flex-shrink: 0; flex-shrink: 0;
transition: color 0.12s, border-color 0.12s; 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 { .node-help-panel {
position: fixed; position: fixed;
@@ -653,10 +916,10 @@ html, body, #root {
right: 20px; right: 20px;
width: 620px; width: 620px;
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
background: #1e293b; background: var(--bg-help-panel);
border: 1px solid #334155; border: 1px solid var(--border-default);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55); box-shadow: 0 8px 32px var(--shadow-heavy);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 9999; z-index: 9999;
@@ -676,33 +939,33 @@ html, body, #root {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 5px 10px; padding: 5px 10px;
border-bottom: 1px solid #1e293b; border-bottom: 1px solid var(--bg-help-tab-active);
flex-shrink: 0; flex-shrink: 0;
} }
.node-help-journal-toggle { .node-help-journal-toggle {
background: #0f172a; background: var(--bg-help-tab);
border: 1px solid #334155; border: 1px solid var(--border-default);
color: #94a3b8; color: var(--text-secondary);
font-size: 11px; font-size: 11px;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: color 0.12s, border-color 0.12s; 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 { .node-help-journal-hint {
font-size: 10px; font-size: 10px;
color: #475569; color: var(--text-faint);
} }
.node-help-journal-textarea { .node-help-journal-textarea {
flex: 1; flex: 1;
background: #0d1624; background: var(--bg-help-textarea);
border: none; border: none;
outline: none; outline: none;
color: #e2e8f0; color: var(--text-bright);
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
@@ -718,7 +981,7 @@ html, body, #root {
} }
.node-help-journal-placeholder { .node-help-journal-placeholder {
color: #475569; color: var(--text-faint);
font-style: italic; font-style: italic;
} }
@@ -727,14 +990,14 @@ html, body, #root {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
font-size: 12.5px; font-size: 12.5px;
color: #cbd5e1; color: var(--text-table);
line-height: 1.65; line-height: 1.65;
} }
.node-help-panel-body h1, .node-help-panel-body h1,
.node-help-panel-body h2, .node-help-panel-body h2,
.node-help-panel-body h3 { .node-help-panel-body h3 {
color: #f1f5f9; color: var(--text-heading);
margin: 14px 0 5px; margin: 14px 0 5px;
font-weight: 600; font-weight: 600;
} }
@@ -754,23 +1017,23 @@ html, body, #root {
.node-help-panel-body th, .node-help-panel-body th,
.node-help-panel-body td { .node-help-panel-body td {
border: 1px solid #334155; border: 1px solid var(--border-default);
padding: 4px 8px; padding: 4px 8px;
text-align: left; text-align: left;
} }
.node-help-panel-body th { .node-help-panel-body th {
background: #0f172a; background: var(--bg-help-tab);
color: #94a3b8; color: var(--text-secondary);
font-weight: 600; font-weight: 600;
} }
.node-help-panel-body code { .node-help-panel-body code {
background: #0f172a; background: var(--bg-help-tab);
padding: 1px 5px; padding: 1px 5px;
border-radius: 3px; border-radius: 3px;
font-size: 11px; font-size: 11px;
color: #7dd3fc; color: var(--accent-lighter);
} }
.node-help-panel-body ul, .node-help-panel-body ul,
@@ -781,12 +1044,12 @@ html, body, #root {
.node-help-panel-body li { margin: 2px 0; } .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 { color: var(--link-color); }
.node-help-panel-body a:hover { color: #ffb74d; } .node-help-panel-body a:hover { color: var(--link-hover); }
/* ── Help panel TOC + content layout ──────────────────────────────── */ /* ── Help panel TOC + content layout ──────────────────────────────── */
@@ -806,10 +1069,10 @@ html, body, #root {
width: 160px; width: 160px;
flex-shrink: 0; flex-shrink: 0;
overflow-y: auto; overflow-y: auto;
border-right: 1px solid #1e293b; border-right: 1px solid var(--bg-help-tab-active);
padding: 8px 0; padding: 8px 0;
font-size: 11px; font-size: 11px;
background: #0f172a; background: var(--bg-help-tab);
} }
.help-toc-root { padding: 0; } .help-toc-root { padding: 0; }
@@ -833,7 +1096,7 @@ html, body, #root {
.help-toc-arrow { .help-toc-arrow {
background: none; background: none;
border: none; border: none;
color: #475569; color: var(--text-faint);
font-size: 7px; font-size: 7px;
padding: 0; padding: 0;
width: 12px; width: 12px;
@@ -843,7 +1106,7 @@ html, body, #root {
line-height: 1; line-height: 1;
transition: color 0.12s; transition: color 0.12s;
} }
.help-toc-arrow:hover { color: #94a3b8; } .help-toc-arrow:hover { color: var(--text-secondary); }
.help-toc-arrow-spacer { .help-toc-arrow-spacer {
display: inline-block; display: inline-block;
@@ -852,7 +1115,7 @@ html, body, #root {
} }
.help-toc-link { .help-toc-link {
color: #94a3b8; color: var(--text-secondary);
text-decoration: none; text-decoration: none;
padding: 2px 6px 2px 0; padding: 2px 6px 2px 0;
display: block; display: block;
@@ -862,7 +1125,7 @@ html, body, #root {
white-space: nowrap; white-space: nowrap;
transition: color 0.12s; transition: color 0.12s;
} }
.help-toc-link:hover { color: #f1f5f9; } .help-toc-link:hover { color: var(--text-heading); }
.node-body { .node-body {
padding: 4px 0; padding: 4px 0;
@@ -886,18 +1149,18 @@ html, body, #root {
} }
.custom-node.node-error { .custom-node.node-error {
outline: 2px solid #ef4444; outline: 2px solid var(--danger-outline);
outline-offset: -1px; 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 { .node-error-message {
padding: 3px 10px; padding: 3px 10px;
font-size: 10px; font-size: 10px;
color: #fca5a5; color: var(--error-text-2);
background: rgba(239, 68, 68, 0.12); background: var(--error-bg-2);
border-top: 1px solid rgba(239, 68, 68, 0.3); border-top: 1px solid var(--error-border-2);
border-bottom: 1px solid rgba(239, 68, 68, 0.3); border-bottom: 1px solid var(--error-border-2);
} }
.node-value-display { .node-value-display {
@@ -1398,6 +1661,213 @@ html, body, #root {
opacity: 0.9; opacity: 0.9;
} }
/* ── Radial profile overlay ───────────────────────────────────────── */
.radial-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.radial-image {
width: 100%;
display: block;
}
.radial-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.radial-circle {
fill: none;
stroke: var(--marker);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
stroke-dasharray: 4 3;
}
.radial-diameter {
stroke: var(--marker);
stroke-width: 1.2;
vector-effect: non-scaling-stroke;
stroke-dasharray: 3 3;
opacity: 0.7;
}
.radial-marker {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--marker);
border: 1px solid var(--marker-border);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 4px var(--marker-shadow);
z-index: 1;
}
.radial-marker:active {
cursor: grabbing;
background: var(--marker-active);
transform: translate(-50%, -50%) scale(1.2);
}
.radial-marker-center {
width: 10px;
height: 10px;
border-radius: 2px;
}
/* ── Straighten Path overlay ──────────────────────────────────────── */
.straighten-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
cursor: crosshair;
}
.straighten-image {
width: 100%;
display: block;
}
.straighten-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.straighten-band {
stroke: var(--accent-lighter);
opacity: 0.25;
}
.straighten-curve {
stroke: var(--marker);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
}
.straighten-marker {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--marker);
border: 1px solid var(--marker-border);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 0 4px var(--marker-shadow);
z-index: 1;
}
.straighten-marker:active {
cursor: grabbing;
background: var(--marker-active);
transform: translate(-50%, -50%) scale(1.2);
}
/* ── Multi Profile overlay ────────────────────────────────────────── */
.multiprofile-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.multiprofile-overlay.cursor-row { cursor: row-resize; }
.multiprofile-overlay.cursor-col { cursor: col-resize; }
.multiprofile-image {
width: 100%;
display: block;
}
.multiprofile-line {
position: absolute;
pointer-events: none;
}
.multiprofile-line-horizontal {
border-top: 1.5px solid var(--marker);
box-shadow: 0 0 4px var(--marker-shadow);
}
.multiprofile-line-vertical {
border-left: 1.5px solid var(--marker);
box-shadow: 0 0 4px var(--marker-shadow);
}
.multiprofile-readout {
position: absolute;
top: 4px;
left: 4px;
font-size: 10px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
padding: 2px 5px;
border-radius: 3px;
pointer-events: none;
}
/* ── Perspective correction overlay ──────────────────────────────────── */
.perspective-overlay-wrap {
display: flex;
flex-direction: column;
}
.perspective-tab-bar {
display: flex;
gap: 1px;
background: var(--border-default);
border-bottom: 1px solid var(--border-default);
}
.perspective-tab {
flex: 1;
padding: 4px 0;
font-size: 11px;
font-weight: 500;
border: none;
background: var(--bg-deep);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.perspective-tab:hover {
background: var(--bg-panel);
}
.perspective-tab.active {
background: var(--bg-panel);
color: var(--text-primary);
}
.perspective-overlay {
position: relative;
user-select: none;
touch-action: none;
overflow: hidden;
}
.perspective-overlay img {
width: 100%;
display: block;
}
.perspective-quad {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.perspective-handle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--selection, #3b82f6);
border: 2px solid #fff;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
transform: translate(-50%, -50%);
cursor: grab;
z-index: 2;
}
.perspective-handle:hover,
.perspective-handle.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.2);
}
.is-panning .perspective-overlay {
pointer-events: none;
}
.angle-overlay { .angle-overlay {
--angle-line-color: #ff9800; --angle-line-color: #ff9800;
--angle-arc-color: rgb(255, 166, 77); --angle-arc-color: rgb(255, 166, 77);
@@ -1415,6 +1885,15 @@ html, body, #root {
border-radius: 6px; 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 { .angle-image {
width: 100%; width: 100%;
display: block; display: block;
@@ -1480,13 +1959,13 @@ html, body, #root {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
padding: 3px 7px; padding: 3px 7px;
border-radius: 999px; border-radius: 999px;
background: rgba(15, 23, 42, 0.9); background: var(--overlay-chip-bg-strong);
border: 1px solid var(--angle-badge-border-color); border: 1px solid var(--angle-badge-border-color);
color: var(--angle-badge-text-color); color: var(--angle-badge-text-color);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.01em; 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; cursor: grab;
user-select: none; user-select: none;
z-index: 1; z-index: 1;
@@ -1560,7 +2039,15 @@ html, body, #root {
border: 2px solid var(--accent-lighter); border: 2px solid var(--accent-lighter);
box-shadow: inset 0 0 0 1px var(--crop-inset); box-shadow: inset 0 0 0 1px var(--crop-inset);
background: transparent; background: transparent;
pointer-events: none; cursor: grab;
}
.crop-rect:active {
cursor: grabbing;
}
.crop-rect-locked {
cursor: default;
} }
.crop-marker { .crop-marker {
@@ -1635,7 +2122,10 @@ html, body, #root {
.is-panning .lineplot-overlay, .is-panning .lineplot-overlay,
.is-panning .crop-overlay, .is-panning .crop-overlay,
.is-panning .mask-paint-overlay, .is-panning .mask-paint-overlay,
.is-panning .markup-overlay { .is-panning .markup-overlay,
.is-panning .radial-overlay,
.is-panning .straighten-overlay,
.is-panning .multiprofile-overlay {
pointer-events: none; pointer-events: none;
} }
@@ -1720,21 +2210,21 @@ html, body, #root {
z-index: 2; z-index: 2;
min-width: 54px; min-width: 54px;
padding: 4px 10px; padding: 4px 10px;
border: 1px solid rgba(148, 163, 184, 0.42); border: 1px solid var(--overlay-chip-border);
border-radius: 999px; border-radius: 999px;
background: rgba(15, 23, 42, 0.86); background: var(--overlay-chip-bg);
color: var(--text-bright); color: var(--text-bright);
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
cursor: pointer; cursor: pointer;
backdrop-filter: blur(8px); 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 { .surface-view-home:hover {
background: rgba(30, 41, 59, 0.94); background: var(--overlay-chip-bg-hover);
border-color: rgba(125, 211, 252, 0.55); border-color: var(--overlay-chip-border-hover);
} }
.surface-view-diagnostics { .surface-view-diagnostics {
@@ -1745,8 +2235,8 @@ html, body, #root {
max-width: calc(100% - 84px); max-width: calc(100% - 84px);
padding: 7px 8px; padding: 7px 8px;
border-radius: 8px; border-radius: 8px;
background: rgba(15, 23, 42, 0.82); background: var(--overlay-chip-bg);
color: rgba(226, 232, 240, 0.92); color: var(--text-bright);
font-family: "SF Mono", "Fira Code", monospace; font-family: "SF Mono", "Fira Code", monospace;
font-size: 9px; font-size: 9px;
line-height: 1.35; line-height: 1.35;
@@ -1945,7 +2435,7 @@ html, body, #root {
} }
.text-note-color-btn:hover { transform: scale(1.25); } .text-note-color-btn:hover { transform: scale(1.25); }
.text-note-color-btn.active { .text-note-color-btn.active {
border-color: rgba(255,255,255,0.7); border-color: var(--note-active-ring);
} }
.text-note-fold-btn { .text-note-fold-btn {
background: none; background: none;
@@ -1987,15 +2477,15 @@ html, body, #root {
.text-note-content code { .text-note-content code {
font-family: monospace; font-family: monospace;
font-size: 11px; font-size: 11px;
background: rgba(0,0,0,0.3); background: var(--note-code-bg);
border-radius: 3px; border-radius: 3px;
padding: 1px 4px; padding: 1px 4px;
} }
.text-note-content strong { font-weight: 600; } .text-note-content strong { font-weight: 600; }
.text-note-content em { font-style: italic; opacity: 0.85; } .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 { .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; margin: 4px 0;
padding-left: 10px; padding-left: 10px;
opacity: 0.8; opacity: 0.8;
@@ -2003,7 +2493,7 @@ html, body, #root {
.text-note-placeholder { color: var(--text-faint); font-style: italic; } .text-note-placeholder { color: var(--text-faint); font-style: italic; }
.text-note-textarea { .text-note-textarea {
flex: 1; flex: 1;
background: rgba(0,0,0,0.25); background: var(--note-textarea-bg);
border: none; border: none;
outline: none; outline: none;
color: inherit; color: inherit;
@@ -2020,7 +2510,7 @@ html, body, #root {
/* ── Context menu ──────────────────────────────────────────────────── */ /* ── Context menu ──────────────────────────────────────────────────── */
.context-menu { .context-menu {
position: fixed; position: fixed;
z-index: 1000; z-index: 10000;
background: var(--bg-panel); background: var(--bg-panel);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: 6px;
@@ -2089,6 +2579,14 @@ html, body, #root {
.ctx-cat-active .ctx-cat-arrow { .ctx-cat-active .ctx-cat-arrow {
color: var(--text-primary); color: var(--text-primary);
} }
.ctx-cat-favorites .ctx-cat-label {
text-transform: none;
}
.ctx-random-node {
border-top: 1px solid var(--border-strong);
color: var(--text-secondary);
font-style: italic;
}
/* ── Submenu panel (separate fixed-position sibling) ── */ /* ── Submenu panel (separate fixed-position sibling) ── */
.ctx-submenu { .ctx-submenu {
@@ -2098,7 +2596,7 @@ html, body, #root {
} }
.context-item { .context-item {
padding: 5px 20px; padding: 5px 12px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
color: var(--text-primary); color: var(--text-primary);

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

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

View File

@@ -66,8 +66,24 @@ export interface OverlayData {
y2?: number; y2?: number;
xm?: number; xm?: number;
ym?: number; ym?: number;
cx?: number;
cy?: number;
ex?: number;
ey?: number;
xreal?: number;
yreal?: number;
square?: boolean;
a_locked?: boolean; a_locked?: boolean;
b_locked?: boolean; b_locked?: boolean;
points?: Array<{ x: number; y: number }>;
thickness?: number;
xres?: number;
yres?: number;
row?: number;
direction?: 'horizontal' | 'vertical';
max_index?: number;
corrected_image?: string;
corners?: Array<{ x: number; y: number }>;
section_title?: string; section_title?: string;
line?: number[]; line?: number[];
shape?: string; shape?: string;

View File

@@ -11,6 +11,10 @@ export const OVERLAY_CAPTURE_SELECTORS = [
'.crop-overlay', // CropBoxOverlay '.crop-overlay', // CropBoxOverlay
'.markup-overlay', // MarkupOverlay '.markup-overlay', // MarkupOverlay
'.angle-overlay', // AngleMeasureOverlay '.angle-overlay', // AngleMeasureOverlay
'.radial-overlay', // RadialProfileOverlay
'.straighten-overlay', // StraightenPathOverlay
'.multiprofile-overlay', // MultiProfileOverlay
'.perspective-overlay', // PerspectiveOverlay
]; ];
function encodeBase64(bytes: Uint8Array) { function encodeBase64(bytes: Uint8Array) {

View File

@@ -11,6 +11,7 @@
"clean:build": "node scripts/clean-build-artifacts.mjs", "clean:build": "node scripts/clean-build-artifacts.mjs",
"clean:native": "node scripts/clean-build-artifacts.mjs --mode=native", "clean:native": "node scripts/clean-build-artifacts.mjs --mode=native",
"dev": "npm run clean:dev && npm --prefix frontend run dev", "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", "build": "npm run clean:build && npm --prefix frontend run build",
"preview": "npm --prefix frontend run preview", "preview": "npm --prefix frontend run preview",
"test:frontend": "npm --prefix frontend test", "test:frontend": "npm --prefix frontend test",

22
scripts/dev.sh Executable file
View File

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

View File

@@ -2,7 +2,7 @@
Generate test images and their FFT outputs for visual comparison with Gwyddion. Generate test images and their FFT outputs for visual comparison with Gwyddion.
Saves PNG files to tests/output/. 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 sys
import os import os
@@ -12,7 +12,7 @@ sys.path.insert(0, ".")
from backend.data_types import DataField, datafield_to_uint8, encode_preview from backend.data_types import DataField, datafield_to_uint8, encode_preview
from backend.nodes.fft_2d import FFT2D 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) os.makedirs(OUT_DIR, exist_ok=True)

View File

@@ -0,0 +1,55 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_arc_revolve_horizontal():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
rng = np.random.default_rng(42)
x = np.linspace(0, 1, 64)
bow = 10.0 * x ** 2
data = bow[None, :] + rng.standard_normal((64, 64)) * 0.01
field = make_field(data=data)
leveled, bg = node.process(field, radius=40, direction="horizontal")
assert leveled.data.shape == field.data.shape
assert bg.data.shape == field.data.shape
assert np.allclose(leveled.data + bg.data, data)
def test_arc_revolve_vertical():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
y = np.linspace(0, 1, 64)
data = (5.0 * y ** 2)[:, None] * np.ones((1, 64))
field = make_field(data=data)
leveled, bg = node.process(field, radius=40, direction="vertical")
assert np.allclose(leveled.data + bg.data, data)
def test_arc_revolve_both():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
y, x = np.mgrid[:32, :32] / 32.0
data = 5.0 * x ** 2 + 3.0 * y ** 2
field = make_field(data=data)
leveled, bg = node.process(field, radius=30, direction="both")
assert leveled.data.shape == data.shape
assert bg.data.shape == data.shape
def test_arc_revolve_flat_passthrough():
from backend.nodes.arc_revolve import ArcRevolve
node = ArcRevolve()
data = np.ones((32, 32)) * 5.0
field = make_field(data=data)
leveled, bg = node.process(field, radius=20, direction="horizontal")
assert leveled.data.std() < 1e-10
assert np.allclose(leveled.data + bg.data, data)

View File

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

View File

@@ -55,3 +55,73 @@ def test_crop_resize_field():
raise AssertionError("Expected invalid crop bounds to raise ValueError") raise AssertionError("Expected invalid crop bounds to raise ValueError")
except ValueError: except ValueError:
pass pass
def test_crop_resize_square_constraint():
"""With square=True, the crop region is coerced to a physical square."""
from backend.nodes.crop_resize import CropResizeField
node = CropResizeField()
# Square-pixel field (xreal == yreal): fraction-square == physical-square.
data = np.arange(64 * 64, dtype=np.float64).reshape(64, 64)
field = DataField(
data=data, xreal=1e-6, yreal=1e-6,
si_unit_xy="m", si_unit_z="m",
)
# Requested region: 0.1..0.9 (wide, 80%) x 0.1..0.5 (tall, 40%).
# Physical-square clamp shrinks the longer (x) side to match y → 40% x 40%.
cropped, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.5,
target_width=0, target_height=0, interpolation="bilinear", square=True,
)
assert cropped.data.shape[0] == cropped.data.shape[1], (
f"expected square crop, got {cropped.data.shape}"
)
assert np.isclose(cropped.xreal, cropped.yreal)
def test_crop_resize_square_physical_aspect():
"""Square on a non-square-pixel field gives a physical square (not pixel square)."""
from backend.nodes.crop_resize import CropResizeField
node = CropResizeField()
# 64x64 pixels but xreal = 2*yreal → x is physically twice as wide per fraction.
data = np.arange(64 * 64, dtype=np.float64).reshape(64, 64)
field = DataField(
data=data, xreal=2e-6, yreal=1e-6,
si_unit_xy="m", si_unit_z="m",
)
# Requested region: 0.1..0.9 x 0.1..0.9 (both 80% fraction).
# Physical widths: 0.8 * 2e-6 = 1.6e-6 vs 0.8 * 1e-6 = 0.8e-6.
# Shorter is y (0.8e-6). Clamp x to 0.4 fraction → 0.1..0.5.
cropped, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9,
target_width=0, target_height=0, interpolation="bilinear", square=True,
)
assert np.isclose(cropped.xreal, cropped.yreal, rtol=0.05), (
f"expected physical square, got xreal={cropped.xreal} yreal={cropped.yreal}"
)
def test_crop_resize_overlay_includes_aspect():
"""Overlay payload should include xreal/yreal so the frontend can snap to square."""
from backend.nodes.crop_resize import CropResizeField
node = CropResizeField()
data = np.ones((16, 16), dtype=np.float64)
field = DataField(
data=data, xreal=3e-6, yreal=2e-6,
si_unit_xy="m", si_unit_z="m",
)
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9,
target_width=0, target_height=0, interpolation="bilinear",
)
assert overlays[0]["xreal"] == 3e-6
assert overlays[0]["yreal"] == 2e-6

View File

@@ -0,0 +1,568 @@
"""
Tests for the exporter registry and the round-trippable DataField formats.
The Save node's format-specific behavior is covered in test_save_generic
(tests/node_tests/save.py). This module focuses on:
1. Registry contract — every exporter module satisfies the protocol.
2. Dispatch — type_name_for_value classifies values correctly and
get_exporter returns a matching module.
3. Round-trip — GWY and TIFF (data) preserve xreal/yreal/units/data.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import numpy as np
from backend.data_types import (
DataField,
DataTable,
ImageData,
LineData,
MeshModel,
RecordTable,
)
def test_exporter_registry_contract():
"""Every registered exporter module must expose the required attributes."""
from backend.exporters import _REGISTRY
from backend.exporters._base import FormatSpec
assert _REGISTRY, "Registry must not be empty"
seen_modules = {mod for (mod, _) in _REGISTRY.values()}
for module in seen_modules:
assert hasattr(module, "accepted_types")
assert hasattr(module, "FORMATS")
assert hasattr(module, "save")
assert isinstance(module.accepted_types, tuple)
assert all(isinstance(t, str) and t.isupper() for t in module.accepted_types)
assert isinstance(module.FORMATS, dict)
for name, spec in module.FORMATS.items():
assert isinstance(name, str) and name
assert isinstance(spec, FormatSpec)
assert spec.ext.startswith(".")
def test_type_name_for_value_classification():
from backend.exporters import type_name_for_value
assert type_name_for_value(DataField(data=np.zeros((4, 4)))) == "DATA_FIELD"
assert type_name_for_value(np.zeros((4, 4))) == "IMAGE"
assert type_name_for_value(np.zeros((4, 4, 3), dtype=np.uint8)) == "IMAGE"
assert type_name_for_value(ImageData(np.zeros((4, 4), dtype=np.uint8))) == "IMAGE"
assert type_name_for_value(np.zeros(8)) == "LINE"
assert type_name_for_value(LineData(data=np.zeros(8))) == "LINE"
assert type_name_for_value(RecordTable([{"a": 1}])) == "RECORD_TABLE"
assert type_name_for_value(DataTable([{"a": 1}])) == "DATA_TABLE"
assert type_name_for_value(1.25) == "FLOAT"
assert type_name_for_value(np.float64(0.5)) == "FLOAT"
mesh = MeshModel(
vertices=np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
)
assert type_name_for_value(mesh) == "MESH_MODEL"
try:
type_name_for_value(object())
assert False, "Expected ValueError for unsupported type"
except ValueError:
pass
def test_get_exporter_known_and_unknown():
from backend.exporters import get_exporter
mod, spec = get_exporter("DATA_FIELD", "GWY")
assert spec.ext == ".gwy"
assert spec.round_trip is True
mod, spec = get_exporter("DATA_FIELD", "TIFF")
assert spec.ext == ".tiff"
# Legacy preview path — not round-trippable.
assert spec.round_trip is False
mod, spec = get_exporter("DATA_FIELD", "TIFF (data)")
assert spec.round_trip is True
try:
get_exporter("DATA_FIELD", "DOES_NOT_EXIST")
assert False, "Expected ValueError for unknown format"
except ValueError:
pass
try:
get_exporter("FLOAT", "GWY")
assert False, "Expected ValueError for type/format mismatch"
except ValueError:
pass
def test_available_formats_includes_new_datafield_formats():
from backend.exporters import available_formats
formats = available_formats("DATA_FIELD")
assert "TIFF" in formats
assert "TIFF (data)" in formats
assert "GWY" in formats
assert "PNG" in formats
assert "NPZ" in formats
assert "HDF5" in formats
assert "HDF5 (Ergo)" in formats
def test_datafield_gwy_round_trip():
"""Writing a DataField to .gwy and reloading via the importer preserves everything."""
from backend.importers import gwy as gwy_importer
from backend.nodes.save import Save
rng = np.random.default_rng(7)
data = rng.standard_normal((32, 48)).astype(np.float64) * 1e-9
field = DataField(
data=data,
xreal=3.2e-6,
yreal=2.4e-6,
xoff=1.1e-7,
yoff=-5.5e-7,
si_unit_xy="m",
si_unit_z="m",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(filename=str(path), format="GWY", value=field)
out_path = path.with_suffix(".gwy")
assert out_path.exists()
reloaded = gwy_importer.load(out_path)
assert len(reloaded) == 1
rf = reloaded[0]
assert rf.data.shape == field.data.shape
assert np.allclose(rf.data, field.data)
assert np.isclose(rf.xreal, field.xreal)
assert np.isclose(rf.yreal, field.yreal)
assert np.isclose(rf.xoff, field.xoff)
assert np.isclose(rf.yoff, field.yoff)
assert rf.si_unit_xy == "m"
assert rf.si_unit_z == "m"
# channel_names() should return the stem we used as the title
names = gwy_importer.channel_names(out_path)
assert names == ["topo"]
def test_datafield_tiff_data_round_trip():
"""TIFF (data) writes float64 pixels + JSON metadata; we verify both."""
import tifffile
from backend.nodes.save import Save
rng = np.random.default_rng(11)
data = rng.standard_normal((24, 36)).astype(np.float64) * 1e-8
field = DataField(
data=data,
xreal=5e-6,
yreal=3e-6,
xoff=0.0,
yoff=0.0,
si_unit_xy="m",
si_unit_z="V",
colormap="viridis",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "field"
Save().save(filename=str(path), format="TIFF (data)", value=field)
out_path = path.with_suffix(".tiff")
assert out_path.exists()
with tifffile.TiffFile(out_path) as tif:
arr = tif.asarray()
desc = tif.pages[0].tags["ImageDescription"].value
assert arr.dtype == np.float64
assert arr.shape == field.data.shape
assert np.allclose(arr, field.data)
# Per-layer metadata lives under tono.layers[*]; a single-layer save
# still produces the same shape, just with one entry.
meta = json.loads(desc)["tono"]
assert meta["version"] == 1
assert len(meta["layers"]) == 1
layer0 = meta["layers"][0]
assert layer0["kind"] == "data_field"
assert layer0["xreal"] == field.xreal
assert layer0["yreal"] == field.yreal
assert layer0["si_unit_xy"] == "m"
assert layer0["si_unit_z"] == "V"
assert layer0["domain"] == "spatial"
def test_datafield_hdf5_generic_round_trip():
"""HDF5 (generic) writes /data + attrs that our hdf5 importer reads back."""
from backend.importers import hdf5 as hdf5_importer
from backend.nodes.save import Save
rng = np.random.default_rng(23)
data = rng.standard_normal((20, 28)).astype(np.float64) * 1e-7
field = DataField(
data=data,
xreal=4.8e-6,
yreal=3.2e-6,
xoff=1.5e-7,
yoff=-2.5e-7,
si_unit_xy="m",
si_unit_z="V",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(filename=str(path), format="HDF5", value=field)
out_path = path.with_suffix(".h5")
assert out_path.exists()
reloaded = hdf5_importer.load(out_path)
assert len(reloaded) == 1
rf = reloaded[0]
assert rf.data.shape == field.data.shape
assert np.allclose(rf.data, field.data)
assert np.isclose(rf.xreal, field.xreal)
assert np.isclose(rf.yreal, field.yreal)
assert np.isclose(rf.xoff, field.xoff)
assert np.isclose(rf.yoff, field.yoff)
assert rf.si_unit_xy == "m"
assert rf.si_unit_z == "V"
def test_datafield_hdf5_ergo_round_trip():
"""HDF5 (Ergo) writes the Asylum sidecar layout and round-trips via ergo_hdf5."""
import h5py
from backend.importers import ergo_hdf5 as ergo_importer
from backend.nodes.save import Save
rng = np.random.default_rng(29)
data = rng.standard_normal((16, 24)).astype(np.float64) * 1e-9
field = DataField(
data=data,
xreal=2.5e-6,
yreal=1.8e-6,
xoff=0.5e-7,
yoff=-1.1e-7,
si_unit_xy="m",
si_unit_z="N",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(filename=str(path), format="HDF5 (Ergo)", value=field)
out_path = path.with_suffix(".h5")
assert out_path.exists()
# Sanity-check the layout: the dataset lives under
# Image/DataSet/Resolution 0/Frame 0/<title>/Image, and the sidecar
# group under Image/DataSetInfo/Global/Channels/<title>/ImageDims.
with h5py.File(str(out_path), "r") as f:
assert "Image/DataSet/Resolution 0/Frame 0/topo/Image" in f
dims = f["Image/DataSetInfo/Global/Channels/topo/ImageDims"]
scaling = np.asarray(dims.attrs["DimScaling"])
assert scaling.shape == (2, 2)
# DimScaling is Y-first: [[y_start, y_end], [x_start, x_end]]
assert np.isclose(scaling[1, 1] - scaling[1, 0], field.xreal)
assert np.isclose(scaling[0, 1] - scaling[0, 0], field.yreal)
reloaded = ergo_importer.load(out_path)
assert len(reloaded) == 1
rf = reloaded[0]
assert rf.data.shape == field.data.shape
assert np.allclose(rf.data, field.data)
assert np.isclose(rf.xreal, field.xreal)
assert np.isclose(rf.yreal, field.yreal)
assert np.isclose(rf.xoff, field.xoff)
assert np.isclose(rf.yoff, field.yoff)
assert rf.si_unit_xy == "m"
assert rf.si_unit_z == "N"
def test_save_multi_layer_tiff_data():
"""TIFF (data) with extra layers writes multi-page float64 with per-layer metadata."""
import tifffile
from backend.nodes.save import Save
rng = np.random.default_rng(41)
primary = DataField(
data=rng.standard_normal((16, 20)).astype(np.float64) * 1e-9,
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="m",
)
layer2 = DataField(
data=rng.standard_normal((16, 20)).astype(np.float64) * 1e-12,
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="N",
)
layer3 = DataField(
data=rng.standard_normal((16, 20)).astype(np.float64),
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="V",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "stack"
Save().save(
filename=str(path),
format="TIFF (data)",
value=primary,
field_0=layer2,
field_1=layer3,
primary_name="height",
layer_name_0="force",
layer_name_1="potential",
)
out_path = path.with_suffix(".tiff")
assert out_path.exists()
with tifffile.TiffFile(out_path) as tif:
assert len(tif.pages) == 3
meta = json.loads(tif.pages[0].tags["ImageDescription"].value)["tono"]
assert len(meta["layers"]) == 3
assert [layer["name"] for layer in meta["layers"]] == ["height", "force", "potential"]
assert meta["layers"][1]["si_unit_z"] == "N"
assert meta["layers"][2]["si_unit_z"] == "V"
assert tif.pages[0].asarray().shape == (16, 20)
assert tif.pages[1].asarray().shape == (16, 20)
assert np.allclose(tif.pages[0].asarray(), primary.data)
assert np.allclose(tif.pages[2].asarray(), layer3.data)
def test_save_multi_layer_npz_named_keys():
"""Multi-layer NPZ uses safe-identifier keys from layer names."""
from backend.nodes.save import Save
rng = np.random.default_rng(47)
primary = DataField(data=rng.standard_normal((8, 8)).astype(np.float64))
layer2 = DataField(data=rng.standard_normal((8, 8)).astype(np.float64))
annotated = np.zeros((12, 12, 3), dtype=np.uint8)
annotated[..., 0] = 255
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "stack"
Save().save(
filename=str(path),
format="NPZ",
value=primary,
field_0=layer2,
field_1=annotated,
primary_name="height map",
layer_name_0="force-retrace",
layer_name_1="annotated overview",
)
out_path = path.with_suffix(".npz")
assert out_path.exists()
npz = np.load(out_path)
# Non-identifier characters collapse to underscores.
assert set(npz.files) == {"height_map", "force_retrace", "annotated_overview"}
assert np.allclose(npz["height_map"], primary.data)
assert np.allclose(npz["force_retrace"], layer2.data)
assert np.array_equal(npz["annotated_overview"], annotated)
def test_save_multi_layer_tiff_preview_rejected():
"""Single-layer-only formats must reject extra layers with a clear error."""
from backend.nodes.save import Save
field_a = DataField(data=np.zeros((4, 4)))
field_b = DataField(data=np.ones((4, 4)))
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "preview"
try:
Save().save(
filename=str(path),
format="TIFF", # preview format, single-layer only
value=field_a,
field_0=field_b,
)
assert False, "TIFF preview must reject extra layers"
except ValueError as exc:
assert "single layer" in str(exc).lower()
try:
Save().save(
filename=str(path),
format="PNG",
value=field_a,
field_0=field_b,
)
assert False, "PNG must reject extra layers"
except ValueError as exc:
assert "single layer" in str(exc).lower()
def test_save_multi_channel_gwy_round_trip():
"""A multi-channel GWY save round-trips via the gwy importer."""
from backend.importers import gwy as gwy_importer
from backend.nodes.save import Save
rng = np.random.default_rng(53)
primary = DataField(
data=rng.standard_normal((24, 32)).astype(np.float64) * 1e-9,
xreal=4e-6, yreal=3e-6, si_unit_xy="m", si_unit_z="m",
)
layer2 = DataField(
data=rng.standard_normal((24, 32)).astype(np.float64) * 1e-11,
xreal=4e-6, yreal=3e-6, si_unit_xy="m", si_unit_z="N",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(
filename=str(path),
format="GWY",
value=primary,
field_0=layer2,
primary_name="height",
layer_name_0="adhesion",
)
out_path = path.with_suffix(".gwy")
assert out_path.exists()
reloaded = gwy_importer.load(out_path)
assert len(reloaded) == 2
names = gwy_importer.channel_names(out_path)
assert set(names) == {"height", "adhesion"}
# GWY does not guarantee iteration order across channels, so match
# each input by content rather than by position.
assert any(np.allclose(f.data, primary.data) for f in reloaded)
assert any(np.allclose(f.data, layer2.data) for f in reloaded)
for f in reloaded:
assert np.isclose(f.xreal, 4e-6)
assert np.isclose(f.yreal, 3e-6)
def test_save_multi_channel_hdf5_round_trip():
"""Multi-channel generic HDF5 round-trips via the hdf5 importer."""
from backend.importers import hdf5 as hdf5_importer
from backend.nodes.save import Save
rng = np.random.default_rng(59)
primary = DataField(
data=rng.standard_normal((12, 18)).astype(np.float64) * 1e-7,
xreal=2e-6, yreal=1.5e-6, si_unit_xy="m", si_unit_z="V",
)
layer2 = DataField(
data=rng.standard_normal((12, 18)).astype(np.float64) * 1e-9,
xreal=2e-6, yreal=1.5e-6, si_unit_xy="m", si_unit_z="A",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "stack"
Save().save(
filename=str(path),
format="HDF5",
value=primary,
field_0=layer2,
primary_name="potential",
layer_name_0="current",
)
out_path = path.with_suffix(".h5")
assert out_path.exists()
reloaded = hdf5_importer.load(out_path)
assert len(reloaded) == 2
# Identify the two channels by their unique z-units.
by_unit = {rf.si_unit_z: rf for rf in reloaded}
assert set(by_unit.keys()) == {"V", "A"}
assert np.allclose(by_unit["V"].data, primary.data)
assert np.allclose(by_unit["A"].data, layer2.data)
def test_save_multi_channel_hdf5_ergo_round_trip():
"""Multi-channel Ergo-layout HDF5 round-trips via the ergo_hdf5 importer."""
from backend.importers import ergo_hdf5 as ergo_importer
from backend.nodes.save import Save
rng = np.random.default_rng(61)
primary = DataField(
data=rng.standard_normal((10, 14)).astype(np.float64) * 1e-9,
xreal=1.5e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m",
)
layer2 = DataField(
data=rng.standard_normal((10, 14)).astype(np.float64) * 1e-11,
xreal=1.5e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="N",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
Save().save(
filename=str(path),
format="HDF5 (Ergo)",
value=primary,
field_0=layer2,
primary_name="height",
layer_name_0="adhesion",
)
out_path = path.with_suffix(".h5")
assert out_path.exists()
reloaded = ergo_importer.load(out_path)
assert len(reloaded) == 2
by_unit = {rf.si_unit_z: rf for rf in reloaded}
assert set(by_unit.keys()) == {"m", "N"}
assert np.allclose(by_unit["m"].data, primary.data)
assert np.allclose(by_unit["N"].data, layer2.data)
def test_save_gwy_rejects_image_layer():
"""GWY/HDF5 formats must error cleanly on non-DataField layers."""
from backend.nodes.save import Save
field = DataField(data=np.zeros((4, 4)))
image = np.zeros((4, 4, 3), dtype=np.uint8)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "topo"
try:
Save().save(
filename=str(path),
format="GWY",
value=field,
field_0=image,
)
assert False, "GWY must reject non-DataField layers"
except ValueError as exc:
assert "DataField" in str(exc) or "data field" in str(exc).lower()
def test_save_ignores_extra_layers_for_non_stackable_types():
"""Stray field_N kwargs must be ignored when value is a scalar/line/table."""
from backend.nodes.save import Save
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "scalar"
# field_0 is connected but should be silently ignored for a FLOAT value.
Save().save(
filename=str(path),
format="TXT",
value=1.25,
field_0=DataField(data=np.zeros((4, 4))),
)
assert Path(tmpdir, "scalar.txt").read_text(encoding="utf-8").strip() == "1.25"
def test_tiff_preview_is_still_rgb_uint8():
"""The legacy TIFF format for DATA_FIELD must keep producing 8-bit RGB."""
import tifffile
from backend.nodes.save import Save
field = DataField(
data=np.array([[0.0, 1.0], [2.0, 3.0]], dtype=np.float64),
xreal=1e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m",
)
with tempfile.TemporaryDirectory() as tmpdir:
path = Path(tmpdir) / "preview"
Save().save(filename=str(path), format="TIFF", value=field)
arr = tifffile.imread(str(path.with_suffix(".tiff")))
assert arr.dtype == np.uint8
assert arr.shape == (2, 2, 3)

View File

@@ -0,0 +1,37 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_level_rotate_removes_tilt():
from backend.nodes.level_rotate import LevelRotate
node = LevelRotate()
y, x = np.mgrid[:64, :64].astype(np.float64)
data = 2.0 * x + 3.0 * y
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == data.shape
assert result.data.std() < data.std() * 0.25
def test_level_rotate_preserves_shape():
from backend.nodes.level_rotate import LevelRotate
node = LevelRotate()
data = np.random.default_rng(42).standard_normal((48, 48))
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == (48, 48)
def test_level_rotate_flat_noop():
from backend.nodes.level_rotate import LevelRotate
node = LevelRotate()
data = np.ones((32, 32)) * 7.0
field = make_field(data=data)
(result,) = node.process(field)
assert np.allclose(result.data, 7.0, atol=1e-6)

View File

@@ -0,0 +1,143 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_mask_rectangular_basic():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
mask, = node.process(
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=False,
)
assert mask.dtype == np.uint8
assert mask.shape == (32, 32)
# Corners defined by 0.25..0.75 on a 32-wide field → pixels 8..24
assert mask[0, 0] == 0
assert mask[16, 16] == 255
assert np.all(mask[8:24, 8:24] == 255)
assert np.all(mask[:8, :] == 0)
assert np.all(mask[24:, :] == 0)
def test_mask_rectangular_invert():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
mask, = node.process(
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=True,
)
assert mask[0, 0] == 255
assert mask[16, 16] == 0
def test_mask_rectangular_corner_inputs_override_widgets():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
mask, = node.process(
field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, square=False, invert=False,
corner_a=(0.5, 0.5), corner_b=(1.0, 1.0),
)
# Corner override → rectangle is the lower-right quadrant (pixels 16..32)
assert mask[0, 0] == 0
assert mask[24, 24] == 255
assert np.all(mask[16:32, 16:32] == 255)
assert np.all(mask[:16, :16] == 0)
def test_mask_rectangular_reversed_corners():
"""x2 < x1 or y2 < y1 should still produce the same rectangle."""
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
forward, = node.process(
field, x1=0.25, y1=0.25, x2=0.75, y2=0.75, square=False, invert=False,
)
reversed_, = node.process(
field, x1=0.75, y1=0.75, x2=0.25, y2=0.25, square=False, invert=False,
)
assert np.array_equal(forward, reversed_)
def test_mask_rectangular_clamps_out_of_bounds():
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((16, 16)))
mask, = node.process(
field, x1=-0.5, y1=-0.5, x2=2.0, y2=2.0, square=False, invert=False,
)
assert mask.shape == (16, 16)
assert np.all(mask == 255)
def test_mask_rectangular_square_shrinks_longer_side():
"""With square=True on a square field, the longer side collapses to the shorter."""
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((64, 64)))
# Non-square fractional region: 0.1..0.9 in x (80% wide), 0.1..0.5 in y (40% tall).
# With square=True the shorter dimension (y, 40%) wins; x shrinks to match.
mask, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.5, square=True, invert=False,
)
ys, xs = np.where(mask == 255)
assert ys.size > 0
width = xs.max() - xs.min() + 1
height = ys.max() - ys.min() + 1
assert width == height, f"expected square, got {width}x{height}"
def test_mask_rectangular_square_physical_aspect():
"""On a field with non-square physical aspect, 'square' is physical, not pixel."""
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
# xreal = 2e-6, yreal = 1e-6 — so a physical square covers twice the x-fraction of the y-fraction.
field = make_field(data=np.zeros((64, 64)), xreal=2e-6, yreal=1e-6)
# Start with a region 0.1..0.9 in x (0.8 frac, 1.6e-6 phys) and 0.1..0.9 in y (0.8 frac, 0.8e-6 phys).
# Shorter physical side = 0.8e-6. In x that's 0.4 fraction → shrink x to 0.1..0.5.
mask, = node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9, square=True, invert=False,
)
ys, xs = np.where(mask == 255)
assert ys.size > 0
# The selected region in pixels should be roughly 0.1..0.5 in x (pixels ~6..32)
# and 0.1..0.9 in y (pixels ~6..58)
assert xs.max() < 40
assert ys.max() > 50
def test_mask_rectangular_emits_overlay():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.mask_rectangular import RectangularMask
node = RectangularMask()
field = make_field(data=np.zeros((32, 32)))
overlays = []
with execution_callbacks(
overlay=lambda nid, d: overlays.append(d),
), active_node("test"):
node.process(
field, x1=0.1, y1=0.1, x2=0.9, y2=0.9, square=False, invert=False,
)
assert len(overlays) == 1
assert overlays[0]["kind"] == "crop_box"
assert overlays[0]["section_title"] == "Preview"
assert overlays[0]["x1"] == 0.1
assert overlays[0]["image"].startswith("data:image/png;base64,")

View File

@@ -31,3 +31,41 @@ def test_vertical_direction():
field = make_field(shape=(80, 40)) field = make_field(shape=(80, 40))
(profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay") (profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay")
assert len(profile.data) == 80, f"Vertical profile length should be field height (80), got {len(profile.data)}" assert len(profile.data) == 80, f"Vertical profile length should be field height (80), got {len(profile.data)}"
def test_emits_blended_overlay():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.multi_profile import MultipleProfiles
node = MultipleProfiles()
field = make_field(shape=(64, 128))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(field, field, row=10, direction="horizontal", mode="overlay")
assert len(overlays) == 1
ov = overlays[0]
assert ov["kind"] == "multi_profile"
assert ov["section_title"] == "Preview"
assert ov["image"].startswith("data:image/png;base64,")
assert ov["row"] == 10
assert ov["direction"] == "horizontal"
assert ov["max_index"] == 63 # height - 1
def test_overlay_max_index_for_vertical():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.multi_profile import MultipleProfiles
node = MultipleProfiles()
field = make_field(shape=(80, 40))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(field, field, row=-1, direction="vertical", mode="overlay")
ov = overlays[0]
assert ov["direction"] == "vertical"
assert ov["max_index"] == 39 # width - 1
assert ov["row"] == 20 # center column for 40 wide

View File

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

View File

@@ -51,3 +51,57 @@ def test_output_shape():
bottom_right_x=0.0, bottom_right_y=0.0, bottom_right_x=0.0, bottom_right_y=0.0,
) )
assert result.data.shape == (48, 96) assert result.data.shape == (48, 96)
def test_emits_perspective_overlay():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.perspective_correction import PerspectiveCorrection
node = PerspectiveCorrection()
field = make_field(shape=(64, 64))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(
field,
top_left_x=0.05, top_left_y=0.05,
top_right_x=-0.05, top_right_y=0.05,
bottom_left_x=0.05, bottom_left_y=-0.05,
bottom_right_x=-0.05, bottom_right_y=-0.05,
)
assert len(overlays) == 1
ov = overlays[0]
assert ov["kind"] == "perspective"
assert ov["section_title"] == "Perspective"
assert ov["image"].startswith("data:image/png;base64,")
assert ov["corrected_image"].startswith("data:image/png;base64,")
assert len(ov["corners"]) == 4
assert ov["corners"][0] == {"x": 0.05, "y": 0.05}
assert ov["corners"][3] == {"x": -0.05, "y": -0.05}
def test_coord_input_overrides_floats():
from backend.nodes.perspective_correction import PerspectiveCorrection
node = PerspectiveCorrection()
field = make_field(shape=(64, 64))
result_floats, = node.process(
field,
top_left_x=0.1, top_left_y=0.1,
top_right_x=0.0, top_right_y=0.0,
bottom_left_x=0.0, bottom_left_y=0.0,
bottom_right_x=0.0, bottom_right_y=0.0,
)
result_coord, = node.process(
field,
top_left_x=0.0, top_left_y=0.0,
top_right_x=0.0, top_right_y=0.0,
bottom_left_x=0.0, bottom_left_y=0.0,
bottom_right_x=0.0, bottom_right_y=0.0,
top_left=(0.1, 0.1),
)
assert np.allclose(result_floats.data, result_coord.data)

View File

@@ -11,7 +11,7 @@ def test_radial_profile_constant_field():
node = RadialProfile() node = RadialProfile()
field = make_field(data=np.full((64, 64), 2.5)) field = make_field(data=np.full((64, 64), 2.5))
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32) result, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert isinstance(result, LineData) assert isinstance(result, LineData)
assert len(result.data) == 32 assert len(result.data) == 32
@@ -26,7 +26,7 @@ def test_radial_profile_units():
node = RadialProfile() node = RadialProfile()
field = make_field() field = make_field()
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32) result, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert result.x_unit == field.si_unit_xy assert result.x_unit == field.si_unit_xy
assert result.y_unit == field.si_unit_z assert result.y_unit == field.si_unit_z
@@ -38,7 +38,7 @@ def test_radial_profile_x_axis_monotone():
node = RadialProfile() node = RadialProfile()
field = make_field() field = make_field()
result, = node.process(field, cx=0.5, cy=0.5, n_bins=64) result, = node.process(field, n_bins=64, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert result.x_axis[0] >= 0.0 assert result.x_axis[0] >= 0.0
assert np.all(np.diff(result.x_axis) > 0) assert np.all(np.diff(result.x_axis) > 0)
@@ -50,7 +50,7 @@ def test_radial_profile_off_centre():
node = RadialProfile() node = RadialProfile()
field = make_field(data=np.ones((64, 64))) field = make_field(data=np.ones((64, 64)))
result, = node.process(field, cx=0.0, cy=0.0, n_bins=32) result, = node.process(field, n_bins=32, cx=0.0, cy=0.0, ex=1.0, ey=1.0)
assert len(result.data) == 32 assert len(result.data) == 32
finite = result.data[np.isfinite(result.data)] finite = result.data[np.isfinite(result.data)]
assert np.allclose(finite, 1.0, atol=1e-10) assert np.allclose(finite, 1.0, atol=1e-10)
@@ -67,7 +67,7 @@ def test_radial_profile_radial_symmetry():
data = np.cos(r * np.pi / (xres / 2.0)) data = np.cos(r * np.pi / (xres / 2.0))
field = make_field(data=data) field = make_field(data=data)
result, = node.process(field, cx=0.5, cy=0.5, n_bins=32) result, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
finite = result.data[np.isfinite(result.data)] finite = result.data[np.isfinite(result.data)]
# The profile should vary (not constant) # The profile should vary (not constant)
assert np.std(finite) > 0.01 assert np.std(finite) > 0.01
@@ -80,6 +80,25 @@ def test_radial_profile_n_bins():
field = make_field() field = make_field()
for n in (16, 64, 256): for n in (16, 64, 256):
result, = node.process(field, cx=0.5, cy=0.5, n_bins=n) result, = node.process(field, n_bins=n, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
assert len(result.data) == n assert len(result.data) == n
assert len(result.x_axis) == n assert len(result.x_axis) == n
def test_radial_profile_radius_controlled_by_endpoint():
"""The outer radius is set by the distance from (cx,cy) to (ex,ey)."""
from backend.nodes.radial_profile import RadialProfile
node = RadialProfile()
field = make_field()
# End at (1.0, 0.5): radius = 0.5 * xreal
short, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=0.5)
expected_r_short = 0.5 * field.xreal
assert np.isclose(short.x_axis[-1], expected_r_short, rtol=0.05)
# End at corner: radius = sqrt(xreal^2 + yreal^2) * 0.5 (half-diagonal)
diag, = node.process(field, n_bins=32, cx=0.5, cy=0.5, ex=1.0, ey=1.0)
expected_r_diag = 0.5 * np.hypot(field.xreal, field.yreal)
assert np.isclose(diag.x_axis[-1], expected_r_diag, rtol=0.05)
assert diag.x_axis[-1] > short.x_axis[-1]

View File

@@ -1,77 +0,0 @@
import os
import tempfile
import numpy as np
import tifffile
from PIL import Image
from tests.node_tests._shared import make_field
def test_save_image():
from backend.nodes.save_layers import SaveImage
node = SaveImage()
input_types = SaveImage.INPUT_TYPES()
field_spec = input_types["optional"]["field_0"]
assert field_spec[0] == "DATA_FIELD"
assert field_spec[1]["accepted_types"] == ["IMAGE", "ANNOTATION_SOURCE"]
field_a = make_field(data=np.random.default_rng(4).random((32, 32)))
field_b = make_field(data=np.random.default_rng(5).random((32, 32)))
annotated = np.zeros((24, 24, 3), dtype=np.uint8)
annotated[..., 0] = 255
with tempfile.TemporaryDirectory() as tmpdir:
tiff_path = os.path.join(tmpdir, "out.tiff")
node.save(filename=tiff_path, format="TIFF", field_0=field_a)
assert os.path.exists(tiff_path)
im = Image.open(tiff_path)
assert im.n_frames == 1
assert np.array(im).shape == (32, 32)
tiff_path2 = os.path.join(tmpdir, "multi.tiff")
node.save(filename=tiff_path2, format="TIFF", field_0=field_a, field_1=field_b)
im2 = Image.open(tiff_path2)
assert im2.n_frames == 2
annotated_tiff = os.path.join(tmpdir, "annotated.tiff")
node.save(filename=annotated_tiff, format="TIFF", field_0=annotated, layer_name_0="annotated overview")
with tifffile.TiffFile(annotated_tiff) as tif:
assert len(tif.pages) == 1
assert tif.pages[0].description == "annotated overview"
assert tif.pages[0].asarray().shape == annotated.shape
npz_path = os.path.join(tmpdir, "out.npz")
node.save(filename=npz_path, format="NPZ", field_0=field_a, field_1=annotated, layer_name_0="height map", layer_name_1="annotated-overview")
assert os.path.exists(npz_path)
npz = np.load(npz_path)
assert len(npz.files) == 2
assert np.allclose(npz["height_map"], field_a.data)
assert np.array_equal(npz["annotated_overview"], annotated)
wrong_ext = os.path.join(tmpdir, "output.png")
node.save(filename=wrong_ext, format="TIFF", field_0=field_a)
assert os.path.exists(os.path.join(tmpdir, "output.tiff"))
driven_dir = os.path.join(tmpdir, "nested-output")
node.save(filename="driven_name", directory=driven_dir, format="NPZ", field_0=field_a)
assert os.path.exists(os.path.join(driven_dir, "driven_name.npz"))
try:
node.save(filename="bad", directory=os.path.join(tmpdir, "looks_like_file.txt"), format="TIFF", field_0=field_a)
assert False, "Should have raised ValueError for file-like directory path"
except ValueError:
pass
try:
node.save(filename=os.path.join(tmpdir, "empty.tiff"), format="TIFF")
assert False, "Should have raised ValueError"
except ValueError:
pass
try:
node.save(filename="", format="TIFF", field_0=field_a)
assert False, "Should have raised ValueError"
except ValueError:
pass

View File

@@ -0,0 +1,41 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_sphere_revolve_basic():
from backend.nodes.sphere_revolve import SphereRevolve
node = SphereRevolve()
y, x = np.mgrid[:64, :64] / 64.0
data = 10.0 * (x ** 2 + y ** 2)
field = make_field(data=data)
leveled, bg = node.process(field, radius=30)
assert leveled.data.shape == data.shape
assert bg.data.shape == data.shape
assert np.allclose(leveled.data + bg.data, data)
def test_sphere_revolve_flat():
from backend.nodes.sphere_revolve import SphereRevolve
node = SphereRevolve()
data = np.ones((32, 32)) * 3.0
field = make_field(data=data)
leveled, bg = node.process(field, radius=20)
assert leveled.data.std() < 1e-10
assert np.allclose(leveled.data + bg.data, data)
def test_sphere_revolve_outputs_two_fields():
from backend.nodes.sphere_revolve import SphereRevolve
node = SphereRevolve()
data = np.random.default_rng(7).standard_normal((32, 32))
field = make_field(data=data)
result = node.process(field, radius=15)
assert len(result) == 2
leveled, bg = result
assert np.allclose(leveled.data + bg.data, data)

View File

@@ -26,3 +26,46 @@ def test_statistics():
assert const_stats["RMS"] == 0.0 assert const_stats["RMS"] == 0.0
assert const_stats["skewness"] == 0.0 assert const_stats["skewness"] == 0.0
assert const_stats["kurtosis"] == 0.0 assert const_stats["kurtosis"] == 0.0
def test_statistics_with_mask():
"""A mask restricts the stats to pixels where mask != 0."""
from backend.nodes.statistics import Statistics
node = Statistics()
data = np.array([[1, 2], [3, 4]], dtype=np.float64)
field = make_field(data=data)
# Mask selects only pixels >= 3 (bottom row).
mask = np.array([[0, 0], [255, 255]], dtype=np.uint8)
table, = node.process(field, mask=mask)
stats = {row["quantity"]: row["value"] for row in table}
assert stats["min"] == 3.0
assert stats["max"] == 4.0
assert stats["mean"] == 3.5
def test_statistics_mask_shape_mismatch():
from backend.nodes.statistics import Statistics
node = Statistics()
field = make_field(data=np.zeros((4, 4)))
bad_mask = np.zeros((3, 3), dtype=np.uint8)
try:
node.process(field, mask=bad_mask)
raise AssertionError("expected shape mismatch to raise")
except ValueError:
pass
def test_statistics_empty_mask():
from backend.nodes.statistics import Statistics
node = Statistics()
field = make_field(data=np.ones((4, 4)))
empty_mask = np.zeros((4, 4), dtype=np.uint8)
try:
node.process(field, mask=empty_mask)
raise AssertionError("expected empty mask to raise")
except ValueError:
pass

View File

@@ -8,10 +8,11 @@ def test_basic_extraction():
node = StraightenPath() node = StraightenPath()
field = make_field(shape=(64, 64)) field = make_field(shape=(64, 64))
(result,) = node.process(field, points_x="0.25, 0.5, 0.75", result, profile = node.process(field, points_x="0.25, 0.5, 0.75",
points_y="0.5, 0.3, 0.5", points_y="0.5, 0.3, 0.5",
thickness=1, n_samples=256) thickness=1, n_samples=256)
assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}" assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}"
assert profile.data.shape == (256,)
def test_thickness(): def test_thickness():
@@ -19,10 +20,14 @@ def test_thickness():
node = StraightenPath() node = StraightenPath()
field = make_field(shape=(64, 64)) field = make_field(shape=(64, 64))
(result,) = node.process(field, points_x="0.2, 0.8", result, profile = node.process(field, points_x="0.2, 0.8",
points_y="0.5, 0.5", points_y="0.5, 0.5",
thickness=5, n_samples=100) thickness=5, n_samples=100)
assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}" assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}"
# Profile is the 1-pixel-wide centerline regardless of thickness.
assert profile.data.shape == (100,)
# For a horizontal line, the centerline equals the middle row of the strip.
assert np.allclose(profile.data, result.data[2])
def test_single_point_returns_input(): def test_single_point_returns_input():
@@ -30,8 +35,33 @@ def test_single_point_returns_input():
node = StraightenPath() node = StraightenPath()
field = make_field(shape=(64, 64)) field = make_field(shape=(64, 64))
(result,) = node.process(field, points_x="0.5", result, profile = node.process(field, points_x="0.5",
points_y="0.5", points_y="0.5",
thickness=1, n_samples=100) thickness=1, n_samples=100)
# With only 1 point, node returns the original field unchanged # With only 1 point, node returns the original field unchanged + empty profile.
assert np.array_equal(result.data, field.data) assert np.array_equal(result.data, field.data)
assert profile.data.shape == (0,)
def test_emits_overlay_with_points_and_thickness():
from backend.execution_context import active_node, execution_callbacks
from backend.nodes.straighten_path import StraightenPath
node = StraightenPath()
field = make_field(shape=(64, 64))
overlays = []
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
node.process(field, points_x="0.25, 0.5, 0.75",
points_y="0.5, 0.3, 0.5",
thickness=4, n_samples=128)
assert len(overlays) == 1
ov = overlays[0]
assert ov["kind"] == "straighten_path"
assert ov["section_title"] == "Path"
assert ov["image"].startswith("data:image/png;base64,")
assert ov["thickness"] == 4
assert ov["xres"] == 64 and ov["yres"] == 64
assert [p["x"] for p in ov["points"]] == [0.25, 0.5, 0.75]
assert [p["y"] for p in ov["points"]] == [0.5, 0.3, 0.5]

View File

@@ -0,0 +1,50 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_unrotate_preserves_shape():
from backend.nodes.unrotate import Unrotate
node = Unrotate()
data = np.random.default_rng(42).standard_normal((64, 64))
field = make_field(data=data)
(result,) = node.process(field, symmetry="4-fold")
assert result.data.shape == (64, 64)
def test_unrotate_small_angle():
from backend.nodes.unrotate import Unrotate, _slope_angle_histogram, _find_dominant_angle
y, x = np.mgrid[:128, :128].astype(np.float64)
angle_deg = 3.0
angle_rad = np.radians(angle_deg)
data = np.sin(2 * np.pi * (x * np.cos(angle_rad) + y * np.sin(angle_rad)) / 20.0)
hist = _slope_angle_histogram(data)
correction = _find_dominant_angle(hist, 4)
assert abs(np.degrees(correction)) < 10.0
def test_unrotate_no_rotation_passthrough():
from backend.nodes.unrotate import Unrotate
node = Unrotate()
y, x = np.mgrid[:64, :64].astype(np.float64)
data = np.sin(2 * np.pi * x / 16.0)
field = make_field(data=data)
(result,) = node.process(field, symmetry="4-fold")
assert np.allclose(result.data, data, atol=0.1)
def test_unrotate_symmetry_options():
from backend.nodes.unrotate import Unrotate
node = Unrotate()
data = np.random.default_rng(99).standard_normal((64, 64))
field = make_field(data=data)
for sym in ["2-fold", "3-fold", "4-fold", "6-fold"]:
(result,) = node.process(field, symmetry=sym)
assert result.data.shape == (64, 64)

View File

@@ -0,0 +1,46 @@
import numpy as np
from tests.node_tests._shared import make_field
def test_zero_mean():
from backend.nodes.zero_value import ZeroMean
node = ZeroMean()
data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == field.data.shape
assert abs(result.data.mean()) < 1e-10
def test_zero_mean_preserves_variation():
from backend.nodes.zero_value import ZeroMean
node = ZeroMean()
data = np.random.default_rng(7).standard_normal((32, 32)) + 50.0
field = make_field(data=data)
(result,) = node.process(field)
assert np.allclose(result.data - result.data.mean(), data - data.mean())
def test_zero_maximum():
from backend.nodes.zero_value import ZeroMaximum
node = ZeroMaximum()
data = np.random.default_rng(42).standard_normal((64, 64)) + 100.0
field = make_field(data=data)
(result,) = node.process(field)
assert result.data.shape == field.data.shape
assert abs(result.data.max()) < 1e-10
assert result.data.min() < 0
def test_zero_maximum_preserves_differences():
from backend.nodes.zero_value import ZeroMaximum
node = ZeroMaximum()
data = np.array([[1.0, 3.0], [2.0, 5.0]])
field = make_field(data=data)
(result,) = node.process(field)
expected = data - 5.0
assert np.allclose(result.data, expected)

View File

@@ -10,7 +10,6 @@ import numpy as np
sys.path.insert(0, ".") sys.path.insert(0, ".")
from backend.data_types import DataField from backend.data_types import DataField
from backend.nodes.fft_2d import FFT2D 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): def make_field(data, xreal=1e-6, yreal=1e-6):
@@ -247,91 +246,6 @@ def test_log_magnitude_visual_range():
print(" PASS\n") 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__": if __name__ == "__main__":
test_dc_removal() test_dc_removal()
test_single_frequency() test_single_frequency()
@@ -341,8 +255,4 @@ if __name__ == "__main__":
test_plane_subtraction() test_plane_subtraction()
test_non_square() test_non_square()
test_log_magnitude_visual_range() 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!") print("All tests passed!")