Compare commits
22 Commits
561501259b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c5cf4670 | |||
| 92ede31867 | |||
| d35cdd6971 | |||
| a4c8d2b01c | |||
| 924b29757f | |||
| ad48a40edc | |||
| c7e7531206 | |||
| 2d66eaef02 | |||
| 9fbd305854 | |||
| 31422e76db | |||
| 349142f0e6 | |||
| 0bf001c24b | |||
| 1d98ccf190 | |||
| c38c2dc29a | |||
| 08aff81f02 | |||
| 0f9b500c34 | |||
| c6096b53a8 | |||
| b8d5c11ee9 | |||
| 2d9e1a1ecf | |||
| 591186bc14 | |||
| ce10edd9cd | |||
| d4ca88f108 |
@@ -20,8 +20,7 @@ pip install -e ".[server,dev]"
|
||||
npm install
|
||||
|
||||
# Running the servers
|
||||
npm run backend # terminal 1 — Python server at http://127.0.0.1:8188
|
||||
npm run dev # terminal 2 — Vite dev server, open the URL it prints
|
||||
npm run dev:all # one terminal — starts the Python backend and the Vite dev server together
|
||||
```
|
||||
|
||||
## Self-hosting
|
||||
|
||||
@@ -492,7 +492,7 @@ class ExecutionEngine:
|
||||
return
|
||||
|
||||
if cls in (Image, ImageDemo) and on_preview:
|
||||
preview = self._render_load_node_preview(result, inputs or {})
|
||||
preview = self._render_load_node_preview(cls, result, inputs or {})
|
||||
if preview:
|
||||
on_preview(node_id, preview)
|
||||
return
|
||||
@@ -539,17 +539,23 @@ class ExecutionEngine:
|
||||
|
||||
def _render_load_node_preview(
|
||||
self,
|
||||
cls: type,
|
||||
result: tuple,
|
||||
inputs: dict[str, Any],
|
||||
) -> dict | None:
|
||||
from backend.data_types import DataField, encode_preview, render_datafield_preview
|
||||
from backend.nodes.helpers import list_channels
|
||||
from backend.nodes.helpers import list_channels, DEMO_DIR
|
||||
from backend.nodes.image_demo import ImageDemo
|
||||
|
||||
fields = [value for value in result if isinstance(value, DataField)]
|
||||
if not fields:
|
||||
return None
|
||||
|
||||
selected_path = str(inputs.get("path") or inputs.get("filename") or inputs.get("name") or "").strip()
|
||||
# ImageDemo passes only the bare demo filename; resolve against DEMO_DIR
|
||||
# so list_channels() can find the file and return real channel names.
|
||||
if cls is ImageDemo and selected_path:
|
||||
selected_path = str(DEMO_DIR / selected_path)
|
||||
channel_names: list[str] = []
|
||||
if selected_path:
|
||||
try:
|
||||
|
||||
128
backend/exporters/__init__.py
Normal file
128
backend/exporters/__init__.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Exporter registry.
|
||||
|
||||
Each module in this package exports a tuple of tono type names it can handle
|
||||
(`accepted_types`), a FORMATS map of format name → FormatSpec, and a `save()`
|
||||
function. This registry walks those modules and builds lookup tables the
|
||||
Save node uses to dispatch.
|
||||
|
||||
Usage::
|
||||
|
||||
from backend.exporters import get_exporter, resolve_path, type_name_for_value
|
||||
|
||||
type_name = type_name_for_value(value) # e.g. "DATA_FIELD"
|
||||
exporter, spec = get_exporter(type_name, "GWY") # raises on unknown combo
|
||||
path = resolve_path(filename, spec)
|
||||
exporter.save(path, value, "GWY")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import (
|
||||
DataField,
|
||||
DataTable,
|
||||
ImageData,
|
||||
LineData,
|
||||
MeshModel,
|
||||
RecordTable,
|
||||
)
|
||||
from backend.exporters import datafield, image, line, mesh, scalar, table
|
||||
from backend.exporters._base import Exporter, FormatSpec
|
||||
|
||||
_EXPORTER_MODULES: list[ModuleType] = [datafield, image, line, mesh, scalar, table]
|
||||
|
||||
# (type_name, format_name) → (module, FormatSpec)
|
||||
_REGISTRY: dict[tuple[str, str], tuple[ModuleType, FormatSpec]] = {}
|
||||
for _mod in _EXPORTER_MODULES:
|
||||
for _type_name in _mod.accepted_types:
|
||||
for _format_name, _spec in _mod.FORMATS.items():
|
||||
_REGISTRY[(_type_name, _format_name)] = (_mod, _spec)
|
||||
|
||||
|
||||
def get_exporter(type_name: str, format_name: str) -> tuple[ModuleType, FormatSpec]:
|
||||
"""Return the (module, FormatSpec) for a type + format combination.
|
||||
|
||||
Raises ValueError with a user-readable message when the combination is
|
||||
unknown. That message gets propagated straight to the UI status toast,
|
||||
so keep it actionable.
|
||||
"""
|
||||
entry = _REGISTRY.get((type_name, format_name))
|
||||
if entry is None:
|
||||
raise ValueError(f"Format {format_name!r} is not supported for {type_name}.")
|
||||
return entry
|
||||
|
||||
|
||||
def available_formats(type_name: str) -> list[str]:
|
||||
"""Format names available for a given tono type, in registration order."""
|
||||
return [fmt for (t, fmt) in _REGISTRY if t == type_name]
|
||||
|
||||
|
||||
def type_name_for_value(value: Any) -> str:
|
||||
"""Classify a runtime Python value into a tono type name.
|
||||
|
||||
The ordering matters: ImageData is a subclass of ndarray, and RecordTable /
|
||||
DataTable are subclasses of list, so check the more specific classes first.
|
||||
"""
|
||||
if isinstance(value, MeshModel):
|
||||
return "MESH_MODEL"
|
||||
if isinstance(value, DataField):
|
||||
return "DATA_FIELD"
|
||||
if isinstance(value, LineData):
|
||||
return "LINE"
|
||||
if isinstance(value, ImageData):
|
||||
# Annotation outputs carry context in ``.metadata``; regardless, image
|
||||
# formats are the right set.
|
||||
return "IMAGE"
|
||||
if isinstance(value, np.ndarray):
|
||||
if value.ndim == 1:
|
||||
return "LINE"
|
||||
return "IMAGE"
|
||||
if isinstance(value, RecordTable):
|
||||
return "RECORD_TABLE"
|
||||
if isinstance(value, DataTable):
|
||||
return "DATA_TABLE"
|
||||
if isinstance(value, list):
|
||||
# Plain list — treat as a data table; the table exporter handles both.
|
||||
return "DATA_TABLE"
|
||||
if isinstance(value, (int, float, np.floating, np.integer)):
|
||||
return "FLOAT"
|
||||
raise ValueError(f"Save does not support input type: {type(value).__name__}")
|
||||
|
||||
|
||||
def resolve_path(filename: str, spec: FormatSpec, default_dir: Path) -> Path:
|
||||
"""Expand *filename* into an absolute Path with the correct extension.
|
||||
|
||||
Relative names are written under *default_dir* (the session download dir);
|
||||
absolute paths are honored as-is, with parent directories created.
|
||||
"""
|
||||
raw_filename = str(filename).strip() if filename is not None else ""
|
||||
if not raw_filename:
|
||||
raise ValueError("No output filename selected — enter a file name.")
|
||||
|
||||
candidate = Path(raw_filename).expanduser()
|
||||
if candidate.is_absolute():
|
||||
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||||
path = candidate
|
||||
else:
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = default_dir / candidate.name
|
||||
|
||||
if path.suffix.lower() != spec.ext:
|
||||
path = path.with_suffix(spec.ext)
|
||||
return path
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Exporter",
|
||||
"FormatSpec",
|
||||
"available_formats",
|
||||
"get_exporter",
|
||||
"resolve_path",
|
||||
"type_name_for_value",
|
||||
]
|
||||
60
backend/exporters/_base.py
Normal file
60
backend/exporters/_base.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Base protocol for file exporters.
|
||||
|
||||
Each exporter module handles one tono value type (DATA_FIELD, IMAGE, LINE, …)
|
||||
and implements one or more output formats. Registration is discovered via the
|
||||
module-level attributes declared below, so adding a new exporter is a matter
|
||||
of dropping a new file in this package and importing it from __init__.
|
||||
|
||||
A single file per value type (rather than per format) keeps format choices
|
||||
that share plumbing — PNG & TIFF previews for DATA_FIELD, CSV & JSON for
|
||||
tables — co-located, which is where most of the shared logic lives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormatSpec:
|
||||
"""One output format supported by an exporter module."""
|
||||
|
||||
#: File extension (leading dot), e.g. ".tiff".
|
||||
ext: str
|
||||
#: True if the format preserves enough information to reload the value
|
||||
#: via the matching importer. Advertised in the UI so users can tell
|
||||
#: "save a preview" and "save for later" apart.
|
||||
round_trip: bool
|
||||
#: Short human-readable label. The enum key used in the format dropdown
|
||||
#: is the dict key in each module's FORMATS map; `label` is what we'd
|
||||
#: surface in tooltips or docs. Leave empty to fall back to the key.
|
||||
label: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Exporter(Protocol):
|
||||
"""Structural protocol satisfied by every module in backend.exporters."""
|
||||
|
||||
#: Tono type names this exporter handles. Must match the upper-case names
|
||||
#: used in node INPUT_TYPES / OUTPUTS (e.g. "DATA_FIELD", "IMAGE", "LINE").
|
||||
accepted_types: tuple[str, ...]
|
||||
|
||||
#: Format name → spec. Format names are what users pick in the Save node's
|
||||
#: format dropdown, so they should be short and recognizable.
|
||||
FORMATS: dict[str, FormatSpec]
|
||||
|
||||
def save(self, path: Path, value: Any, format_name: str, **opts: Any) -> None:
|
||||
"""Write *value* to *path* in *format_name*.
|
||||
|
||||
The caller is responsible for ensuring ``path`` has the correct
|
||||
extension (see registry.resolve_path) and that ``value`` is of a type
|
||||
listed in ``accepted_types``.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Re-exported so modules can write `from backend.exporters._base import FormatSpec`.
|
||||
__all__ = ["FormatSpec", "Exporter"]
|
||||
365
backend/exporters/datafield.py
Normal file
365
backend/exporters/datafield.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Exporter for DATA_FIELD values (single layer or multi-layer stacks).
|
||||
|
||||
Format choices:
|
||||
|
||||
* **TIFF** — 8-bit RGB colormap preview. *Not* round-trippable and single-layer
|
||||
only; connect multiple channels and pick "TIFF (data)" for a stack.
|
||||
* **TIFF (data)** — float64 pixels with tono metadata JSON-embedded in the
|
||||
TIFF ImageDescription tag. Round-trips and supports multi-page stacks: one
|
||||
IFD per layer, the first page's description carries a ``{"tono": {...},
|
||||
"layers": [...]}`` document.
|
||||
* **PNG** — 8-bit RGB colormap preview. Single-layer only.
|
||||
* **NPZ** — for a single layer, writes a plain ``field=...`` key. For a stack,
|
||||
each layer gets its own key derived from its display name (identifier-safe,
|
||||
deduplicated).
|
||||
* **GWY** — Gwyddion native format via the ``gwyfile`` package. A multi-layer
|
||||
save writes one channel per layer (``/0/data``, ``/1/data``, …), each with
|
||||
its own title, producing a true multi-channel .gwy file.
|
||||
* **HDF5** — generic HDF5 with one ``data`` dataset per layer and physical
|
||||
dimensions as dataset attrs. Round-trips via our generic ``hdf5`` importer,
|
||||
which picks up every 2-D numeric dataset.
|
||||
* **HDF5 (Ergo)** — Asylum Research / Ergo layout, one dataset per layer under
|
||||
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` plus a matching sidecar
|
||||
group ``Image/DataSetInfo/Global/Channels/<title>/ImageDims``. Round-trips
|
||||
via our ``ergo_hdf5`` importer and opens in Ergo / Igor.
|
||||
|
||||
Mixed layer stacks (DataField + Image) are supported for TIFF (data) and NPZ
|
||||
only; the physics-carrying formats (GWY, HDF5, HDF5 Ergo) require every layer
|
||||
to be a DataField and raise a clear error otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import DataField, datafield_to_uint8, image_to_uint8
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
accepted_types: tuple[str, ...] = ("DATA_FIELD",)
|
||||
|
||||
FORMATS: dict[str, FormatSpec] = {
|
||||
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF (preview)"),
|
||||
"TIFF (data)": FormatSpec(ext=".tiff", round_trip=True, label="TIFF (calibrated data)"),
|
||||
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG (preview)"),
|
||||
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
|
||||
"GWY": FormatSpec(ext=".gwy", round_trip=True, label="Gwyddion (.gwy)"),
|
||||
"HDF5": FormatSpec(ext=".h5", round_trip=True, label="HDF5 (generic)"),
|
||||
"HDF5 (Ergo)": FormatSpec(ext=".h5", round_trip=True, label="HDF5 (Asylum Research / Ergo)"),
|
||||
}
|
||||
|
||||
# Formats that only make sense for a single layer. When extra layers are
|
||||
# connected, the Save node raises before we get here, but we keep the check
|
||||
# defensive so the protocol is enforced at the exporter boundary too.
|
||||
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"TIFF", "PNG"})
|
||||
|
||||
|
||||
def save(
|
||||
path: Path,
|
||||
value: DataField,
|
||||
format_name: str,
|
||||
*,
|
||||
extra_layers: Sequence[Any] | None = None,
|
||||
layer_names: Sequence[str] | None = None,
|
||||
**_opts,
|
||||
) -> None:
|
||||
extras = list(extra_layers or [])
|
||||
layers: list[Any] = [value, *extras]
|
||||
names = _resolve_layer_names(layers, layer_names, default_primary=path.stem or "field")
|
||||
|
||||
if extras and format_name in _SINGLE_LAYER_ONLY:
|
||||
raise ValueError(
|
||||
f"{format_name} only supports a single layer. Use 'TIFF (data)', "
|
||||
f"'NPZ', 'GWY', or an HDF5 format for multi-layer saves."
|
||||
)
|
||||
|
||||
if format_name == "TIFF":
|
||||
_save_tiff_preview(path, value)
|
||||
return
|
||||
if format_name == "TIFF (data)":
|
||||
_save_tiff_data(path, layers, names)
|
||||
return
|
||||
if format_name == "PNG":
|
||||
_save_png_preview(path, value)
|
||||
return
|
||||
if format_name == "NPZ":
|
||||
_save_npz(path, layers, names)
|
||||
return
|
||||
if format_name == "GWY":
|
||||
_save_gwy(path, _require_all_datafields(layers, "GWY"), names)
|
||||
return
|
||||
if format_name == "HDF5":
|
||||
_save_hdf5_generic(path, _require_all_datafields(layers, "HDF5"), names)
|
||||
return
|
||||
if format_name == "HDF5 (Ergo)":
|
||||
_save_hdf5_ergo(path, _require_all_datafields(layers, "HDF5 (Ergo)"), names)
|
||||
return
|
||||
raise ValueError(f"Format {format_name!r} is not supported for DATA_FIELD.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_layer_names(
|
||||
layers: Sequence[Any],
|
||||
raw_names: Sequence[str] | None,
|
||||
*,
|
||||
default_primary: str,
|
||||
) -> list[str]:
|
||||
"""Fill in layer names, falling back to defaults for blank/missing entries.
|
||||
|
||||
The primary layer (index 0) defaults to ``default_primary`` (usually the
|
||||
file stem), and each extra layer defaults to ``layer_N+1`` (1-indexed for
|
||||
humans: "layer 2", "layer 3", …).
|
||||
"""
|
||||
raw_names = list(raw_names or [])
|
||||
out: list[str] = []
|
||||
for i in range(len(layers)):
|
||||
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
|
||||
if raw:
|
||||
out.append(raw)
|
||||
elif i == 0:
|
||||
out.append(default_primary)
|
||||
else:
|
||||
out.append(f"layer_{i + 1}")
|
||||
return out
|
||||
|
||||
|
||||
def _require_all_datafields(layers: Sequence[Any], format_label: str) -> list[DataField]:
|
||||
"""Return the list cast to DataFields, raising if any layer is not one."""
|
||||
out: list[DataField] = []
|
||||
for i, layer in enumerate(layers):
|
||||
if not isinstance(layer, DataField):
|
||||
raise ValueError(
|
||||
f"{format_label} only supports DataField layers; layer {i + 1} "
|
||||
f"is a {type(layer).__name__}. Use TIFF (data) or NPZ for mixed stacks."
|
||||
)
|
||||
out.append(layer)
|
||||
return out
|
||||
|
||||
|
||||
def _safe_identifier(name: str, index: int) -> str:
|
||||
"""Turn a free-form layer name into a safe identifier (used as an NPZ key)."""
|
||||
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
|
||||
if not key:
|
||||
key = f"layer_{index + 1}"
|
||||
if key[0].isdigit():
|
||||
key = f"layer_{key}"
|
||||
return key
|
||||
|
||||
|
||||
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
|
||||
used: set[str] = set()
|
||||
result: list[str] = []
|
||||
for k in raw_keys:
|
||||
candidate = k
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
candidate = f"{k}_{suffix}"
|
||||
suffix += 1
|
||||
used.add(candidate)
|
||||
result.append(candidate)
|
||||
return result
|
||||
|
||||
|
||||
def _layer_to_float_array(layer: Any) -> np.ndarray:
|
||||
"""Coerce a layer into a float array for TIFF (data). Images are promoted."""
|
||||
if isinstance(layer, DataField):
|
||||
return np.ascontiguousarray(layer.data, dtype=np.float64)
|
||||
if isinstance(layer, np.ndarray):
|
||||
# Images are left as-is so multi-channel RGB pages survive the write.
|
||||
return np.ascontiguousarray(layer)
|
||||
raise ValueError(f"Unsupported layer type for TIFF (data): {type(layer).__name__}")
|
||||
|
||||
|
||||
def _layer_to_npz_array(layer: Any) -> np.ndarray:
|
||||
if isinstance(layer, DataField):
|
||||
return np.asarray(layer.data)
|
||||
if isinstance(layer, np.ndarray):
|
||||
return np.asarray(layer)
|
||||
raise ValueError(f"Unsupported layer type for NPZ: {type(layer).__name__}")
|
||||
|
||||
|
||||
def _datafield_meta(field: DataField) -> dict:
|
||||
"""Build the JSON-serializable physics metadata dict for a DataField."""
|
||||
return {
|
||||
"xreal": float(field.xreal),
|
||||
"yreal": float(field.yreal),
|
||||
"xoff": float(field.xoff),
|
||||
"yoff": float(field.yoff),
|
||||
"si_unit_xy": str(field.si_unit_xy),
|
||||
"si_unit_z": str(field.si_unit_z),
|
||||
"domain": str(field.domain),
|
||||
"colormap": field.colormap if isinstance(field.colormap, str) else "viridis",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-format writers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_tiff_preview(path: Path, field: DataField) -> None:
|
||||
import tifffile
|
||||
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
|
||||
|
||||
|
||||
def _save_tiff_data(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
|
||||
"""Write the raw pixels as a multi-page TIFF with tono metadata.
|
||||
|
||||
The ImageDescription tag on the first page carries a JSON document of
|
||||
shape ``{"tono": {"version": 1, "layers": [{...}, {...}]}}``. Each entry in
|
||||
``layers`` gives the per-layer physics (xreal/yreal/xoff/yoff/units/domain)
|
||||
and its display name so a future multi-layer importer can reconstruct the
|
||||
whole stack. Non-DataField layers (plain images) get a minimal entry with
|
||||
just the name and dtype — they're pixels, not physics.
|
||||
"""
|
||||
import tifffile
|
||||
|
||||
per_layer_meta: list[dict] = []
|
||||
for layer, layer_name in zip(layers, names):
|
||||
if isinstance(layer, DataField):
|
||||
entry = {"name": layer_name, "kind": "data_field", **_datafield_meta(layer)}
|
||||
else:
|
||||
arr = np.asarray(layer)
|
||||
entry = {"name": layer_name, "kind": "image", "dtype": str(arr.dtype), "shape": list(arr.shape)}
|
||||
per_layer_meta.append(entry)
|
||||
|
||||
description = json.dumps(
|
||||
{"tono": {"version": 1, "layers": per_layer_meta}},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
with tifffile.TiffWriter(str(path)) as tif:
|
||||
for i, (layer, layer_name) in enumerate(zip(layers, names)):
|
||||
arr = _layer_to_float_array(layer)
|
||||
# Full metadata document lives on the first page; subsequent pages
|
||||
# carry only their display name so readers that walk IFDs see
|
||||
# something meaningful per channel.
|
||||
page_desc = description if i == 0 else layer_name
|
||||
tif.write(arr, description=page_desc)
|
||||
|
||||
|
||||
def _save_png_preview(path: Path, field: DataField) -> None:
|
||||
from PIL import Image
|
||||
Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path))
|
||||
|
||||
|
||||
def _save_npz(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
|
||||
if len(layers) == 1:
|
||||
# Single-layer: keep the historical `field` key so nothing that reads
|
||||
# existing tono .npz outputs breaks.
|
||||
np.savez(str(path), field=_layer_to_npz_array(layers[0]))
|
||||
return
|
||||
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
|
||||
keys = _dedupe_keys(raw_keys)
|
||||
arrays = {key: _layer_to_npz_array(layer) for key, layer in zip(keys, layers)}
|
||||
np.savez(str(path), **arrays)
|
||||
|
||||
|
||||
def _save_gwy(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
|
||||
"""Write an N-channel .gwy file via the gwyfile package."""
|
||||
from gwyfile.objects import GwyContainer, GwyDataField, GwySIUnit
|
||||
|
||||
container_data: dict[str, Any] = {}
|
||||
for i, (field, title) in enumerate(zip(fields, names)):
|
||||
gwy_field = GwyDataField(
|
||||
np.ascontiguousarray(field.data, dtype=np.float64),
|
||||
xreal=float(field.xreal),
|
||||
yreal=float(field.yreal),
|
||||
xoff=float(field.xoff),
|
||||
yoff=float(field.yoff),
|
||||
si_unit_xy=GwySIUnit(unitstr=str(field.si_unit_xy or "")),
|
||||
si_unit_z=GwySIUnit(unitstr=str(field.si_unit_z or "")),
|
||||
)
|
||||
container_data[f"/{i}/data"] = gwy_field
|
||||
container_data[f"/{i}/data/title"] = title
|
||||
GwyContainer(container_data).tofile(str(path))
|
||||
|
||||
|
||||
def _save_hdf5_generic(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
|
||||
"""Write one HDF5 dataset per layer with physical dims as dataset attrs.
|
||||
|
||||
Single-layer saves use ``/data`` for backward compatibility with the
|
||||
tests that read the original layout; multi-layer saves use one
|
||||
top-level dataset per channel, keyed by the safe-identifier form of its
|
||||
name and deduplicated against collisions.
|
||||
"""
|
||||
import h5py
|
||||
|
||||
with h5py.File(str(path), "w") as f:
|
||||
if len(fields) == 1:
|
||||
_write_hdf5_dataset(f, "data", fields[0])
|
||||
return
|
||||
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
|
||||
keys = _dedupe_keys(raw_keys)
|
||||
for key, field in zip(keys, fields):
|
||||
_write_hdf5_dataset(f, key, field)
|
||||
|
||||
|
||||
def _write_hdf5_dataset(h5file: Any, name: str, field: DataField) -> None:
|
||||
arr = np.ascontiguousarray(field.data, dtype=np.float64)
|
||||
ds = h5file.create_dataset(name, data=arr)
|
||||
ds.attrs["xreal"] = float(field.xreal)
|
||||
ds.attrs["yreal"] = float(field.yreal)
|
||||
ds.attrs["xoff"] = float(field.xoff)
|
||||
ds.attrs["yoff"] = float(field.yoff)
|
||||
ds.attrs["si_unit_xy"] = str(field.si_unit_xy or "")
|
||||
ds.attrs["si_unit_z"] = str(field.si_unit_z or "")
|
||||
|
||||
|
||||
def _save_hdf5_ergo(path: Path, fields: list[DataField], names: Sequence[str]) -> None:
|
||||
"""Write an Asylum Research / Ergo-compatible HDF5 file (N channels).
|
||||
|
||||
Each channel gets its own dataset at
|
||||
``Image/DataSet/Resolution 0/Frame 0/<title>/Image`` with a matching
|
||||
sidecar group ``Image/DataSetInfo/Global/Channels/<title>/ImageDims``
|
||||
carrying ``DimScaling`` / ``DimUnits`` / ``DataUnits``. The channel
|
||||
names are the dedupe-safe form of each layer name. Opens in Ergo / Igor
|
||||
and round-trips through :mod:`backend.importers.ergo_hdf5`.
|
||||
"""
|
||||
import h5py
|
||||
|
||||
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
|
||||
titles = _dedupe_keys(raw_keys)
|
||||
|
||||
with h5py.File(str(path), "w") as f:
|
||||
for field, title in zip(fields, titles):
|
||||
arr = np.ascontiguousarray(field.data, dtype=np.float64)
|
||||
ds = f.create_dataset(
|
||||
f"Image/DataSet/Resolution 0/Frame 0/{title}/Image",
|
||||
data=arr,
|
||||
)
|
||||
ds.attrs["xreal"] = float(field.xreal)
|
||||
ds.attrs["yreal"] = float(field.yreal)
|
||||
ds.attrs["xoff"] = float(field.xoff)
|
||||
ds.attrs["yoff"] = float(field.yoff)
|
||||
xy_unit = str(field.si_unit_xy or "m")
|
||||
z_unit = str(field.si_unit_z or "")
|
||||
ds.attrs["si_unit_xy"] = xy_unit
|
||||
ds.attrs["si_unit_z"] = z_unit
|
||||
|
||||
x_start = float(field.xoff)
|
||||
x_end = float(field.xoff) + float(field.xreal)
|
||||
y_start = float(field.yoff)
|
||||
y_end = float(field.yoff) + float(field.yreal)
|
||||
# DimScaling is Y-first to match the importer (ergo_hdf5.py:110-113).
|
||||
dim_scaling = np.array(
|
||||
[[y_start, y_end], [x_start, x_end]],
|
||||
dtype=np.float64,
|
||||
)
|
||||
dim_units = np.array([xy_unit, xy_unit], dtype=h5py.string_dtype())
|
||||
|
||||
dims_grp = f.create_group(
|
||||
f"Image/DataSetInfo/Global/Channels/{title}/ImageDims"
|
||||
)
|
||||
dims_grp.attrs["DimScaling"] = dim_scaling
|
||||
dims_grp.attrs["DimUnits"] = dim_units
|
||||
dims_grp.attrs["DataUnits"] = z_unit
|
||||
129
backend/exporters/image.py
Normal file
129
backend/exporters/image.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Exporter for IMAGE values (numpy arrays, ImageData annotation sources).
|
||||
|
||||
Images are raw pixel arrays — no physical calibration by design — so none of
|
||||
the formats here round-trip dimensions. PNG/TIFF convert to uint8 via the
|
||||
same image_to_uint8 helper the preview pipeline uses; NPZ preserves the raw
|
||||
array.
|
||||
|
||||
Multi-layer stacks are supported for TIFF (multi-page uint8) and NPZ (one
|
||||
named array per layer). PNG is single-layer only and raises if extra layers
|
||||
are connected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import image_to_uint8
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
accepted_types: tuple[str, ...] = ("IMAGE", "ANNOTATION_SOURCE")
|
||||
|
||||
FORMATS: dict[str, FormatSpec] = {
|
||||
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG"),
|
||||
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF"),
|
||||
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
|
||||
}
|
||||
|
||||
_SINGLE_LAYER_ONLY: frozenset[str] = frozenset({"PNG"})
|
||||
|
||||
|
||||
def save(
|
||||
path: Path,
|
||||
value: np.ndarray,
|
||||
format_name: str,
|
||||
*,
|
||||
extra_layers: Sequence[Any] | None = None,
|
||||
layer_names: Sequence[str] | None = None,
|
||||
**_opts,
|
||||
) -> None:
|
||||
extras = list(extra_layers or [])
|
||||
layers: list[Any] = [value, *extras]
|
||||
names = _resolve_layer_names(len(layers), layer_names, default_primary=path.stem or "image")
|
||||
|
||||
if extras and format_name in _SINGLE_LAYER_ONLY:
|
||||
raise ValueError(
|
||||
f"{format_name} only supports a single layer. Use 'TIFF' or 'NPZ' "
|
||||
f"for multi-layer image saves."
|
||||
)
|
||||
|
||||
if format_name == "PNG":
|
||||
from PIL import Image
|
||||
Image.fromarray(image_to_uint8(np.asarray(value))).save(str(path))
|
||||
return
|
||||
if format_name == "TIFF":
|
||||
_save_tiff(path, layers, names)
|
||||
return
|
||||
if format_name == "NPZ":
|
||||
_save_npz(path, layers, names)
|
||||
return
|
||||
raise ValueError(f"Format {format_name!r} is not supported for IMAGE.")
|
||||
|
||||
|
||||
def _save_tiff(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
|
||||
import tifffile
|
||||
|
||||
if len(layers) == 1:
|
||||
tifffile.imwrite(str(path), image_to_uint8(np.asarray(layers[0])))
|
||||
return
|
||||
with tifffile.TiffWriter(str(path)) as tif:
|
||||
for layer, layer_name in zip(layers, names):
|
||||
tif.write(image_to_uint8(np.asarray(layer)), description=layer_name)
|
||||
|
||||
|
||||
def _save_npz(path: Path, layers: Sequence[Any], names: Sequence[str]) -> None:
|
||||
if len(layers) == 1:
|
||||
# Preserve the single-layer key used by the legacy test suite.
|
||||
np.savez(str(path), image=np.asarray(layers[0]))
|
||||
return
|
||||
raw_keys = [_safe_identifier(name, i) for i, name in enumerate(names)]
|
||||
keys = _dedupe_keys(raw_keys)
|
||||
arrays = {key: np.asarray(layer) for key, layer in zip(keys, layers)}
|
||||
np.savez(str(path), **arrays)
|
||||
|
||||
|
||||
def _resolve_layer_names(
|
||||
count: int,
|
||||
raw_names: Sequence[str] | None,
|
||||
*,
|
||||
default_primary: str,
|
||||
) -> list[str]:
|
||||
raw_names = list(raw_names or [])
|
||||
out: list[str] = []
|
||||
for i in range(count):
|
||||
raw = str(raw_names[i]).strip() if i < len(raw_names) and raw_names[i] is not None else ""
|
||||
if raw:
|
||||
out.append(raw)
|
||||
elif i == 0:
|
||||
out.append(default_primary)
|
||||
else:
|
||||
out.append(f"layer_{i + 1}")
|
||||
return out
|
||||
|
||||
|
||||
def _safe_identifier(name: str, index: int) -> str:
|
||||
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(name).strip()).strip("_")
|
||||
if not key:
|
||||
key = f"layer_{index + 1}"
|
||||
if key[0].isdigit():
|
||||
key = f"layer_{key}"
|
||||
return key
|
||||
|
||||
|
||||
def _dedupe_keys(raw_keys: Sequence[str]) -> list[str]:
|
||||
used: set[str] = set()
|
||||
result: list[str] = []
|
||||
for k in raw_keys:
|
||||
candidate = k
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
candidate = f"{k}_{suffix}"
|
||||
suffix += 1
|
||||
used.add(candidate)
|
||||
result.append(candidate)
|
||||
return result
|
||||
182
backend/exporters/line.py
Normal file
182
backend/exporters/line.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Exporter for LINE values (1-D profiles as LineData or bare ndarrays).
|
||||
|
||||
PNG / TIFF render a plot image via Pillow; CSV / JSON / NPZ save the raw
|
||||
(x, y, unit) arrays. The plot renderer is self-contained (no matplotlib
|
||||
dependency) and handles SI-prefix axis labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import LineData, _PREFIXABLE_UNITS, _SI_PREFIXES
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
accepted_types: tuple[str, ...] = ("LINE",)
|
||||
|
||||
FORMATS: dict[str, FormatSpec] = {
|
||||
"PNG": FormatSpec(ext=".png", round_trip=False, label="PNG plot"),
|
||||
"TIFF": FormatSpec(ext=".tiff", round_trip=False, label="TIFF plot"),
|
||||
"CSV": FormatSpec(ext=".csv", round_trip=True, label="CSV"),
|
||||
"NPZ": FormatSpec(ext=".npz", round_trip=False, label="NumPy (.npz)"),
|
||||
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
|
||||
}
|
||||
|
||||
|
||||
def save(path: Path, value, format_name: str, *, plot_title: str = "", **_opts) -> None:
|
||||
line = value if isinstance(value, LineData) else LineData(data=np.asarray(value).ravel())
|
||||
|
||||
y = np.asarray(line.data, dtype=np.float64).ravel()
|
||||
if line.x_axis is not None:
|
||||
x = np.asarray(line.x_axis, dtype=np.float64).ravel()[: len(y)]
|
||||
else:
|
||||
x = np.arange(len(y), dtype=np.float64)
|
||||
|
||||
if format_name in ("PNG", "TIFF"):
|
||||
_save_line_plot(path, x, y, line.x_unit, line.y_unit, plot_title, format_name)
|
||||
return
|
||||
if format_name == "CSV":
|
||||
with path.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["x", "y", "x_unit", "y_unit"])
|
||||
for xv, yv in zip(x, y):
|
||||
writer.writerow([xv, yv, line.x_unit, line.y_unit])
|
||||
return
|
||||
if format_name == "NPZ":
|
||||
np.savez(str(path), x=x, y=y)
|
||||
return
|
||||
if format_name == "JSON":
|
||||
path.write_text(
|
||||
json.dumps({
|
||||
"x": x.tolist(),
|
||||
"y": y.tolist(),
|
||||
"x_unit": line.x_unit,
|
||||
"y_unit": line.y_unit,
|
||||
}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return
|
||||
raise ValueError(f"Format {format_name!r} is not supported for LINE.")
|
||||
|
||||
|
||||
def _save_line_plot(
|
||||
path: Path,
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
x_unit: str,
|
||||
y_unit: str,
|
||||
title: str,
|
||||
format_name: str,
|
||||
) -> None:
|
||||
"""Render a simple PNG/TIFF line plot with SI-prefixed axes.
|
||||
|
||||
Intentionally self-contained (Pillow only, no matplotlib) so that builds
|
||||
stay lean. Layout is fixed 1200×750 with 5×5 grid and a single blue line.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
w, h = 1200, 750
|
||||
bg = (255, 255, 255)
|
||||
line_color = (79, 142, 247) # #4f8ef7
|
||||
grid_color = (200, 200, 200)
|
||||
text_color = (60, 60, 60)
|
||||
margin = {"left": 80, "right": 30, "top": 50, "bottom": 60}
|
||||
|
||||
img = Image.new("RGB", (w, h), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", 14)
|
||||
font_small = ImageFont.truetype("DejaVuSans.ttf", 11)
|
||||
font_title = ImageFont.truetype("DejaVuSans.ttf", 16)
|
||||
except (OSError, IOError):
|
||||
font = ImageFont.load_default()
|
||||
font_small = font
|
||||
font_title = font
|
||||
|
||||
pw = w - margin["left"] - margin["right"]
|
||||
ph = h - margin["top"] - margin["bottom"]
|
||||
|
||||
def _si_scale(unit: str, vmin: float, vmax: float) -> tuple[float, str]:
|
||||
"""Pick the best SI prefix for an axis range. Returns (divisor, prefixed_unit)."""
|
||||
unit = (unit or "").strip()
|
||||
if not unit or unit not in _PREFIXABLE_UNITS:
|
||||
return 1.0, unit if unit else ""
|
||||
peak = max(abs(vmin), abs(vmax))
|
||||
if peak == 0:
|
||||
return 1.0, unit
|
||||
for scale, prefix in _SI_PREFIXES:
|
||||
if peak / scale >= 1.0:
|
||||
return scale, f"{prefix}{unit}"
|
||||
return _SI_PREFIXES[-1][0], f"{_SI_PREFIXES[-1][1]}{unit}"
|
||||
|
||||
xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
|
||||
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
|
||||
|
||||
x_scale, x_label = _si_scale(x_unit, xmin, xmax)
|
||||
y_scale, y_label = _si_scale(y_unit, ymin, ymax)
|
||||
if not x_label:
|
||||
x_label = "x"
|
||||
if not y_label:
|
||||
y_label = "y"
|
||||
|
||||
x = x / x_scale
|
||||
y = y / y_scale
|
||||
xmin, xmax = xmin / x_scale, xmax / x_scale
|
||||
ymin, ymax = ymin / y_scale, ymax / y_scale
|
||||
|
||||
if ymax == ymin:
|
||||
ymin, ymax = ymin - 1, ymax + 1
|
||||
if xmax == xmin:
|
||||
xmax = xmin + 1
|
||||
ypad = (ymax - ymin) * 0.05
|
||||
ymin -= ypad
|
||||
ymax += ypad
|
||||
|
||||
def to_px(xv: float, yv: float) -> tuple[float, float]:
|
||||
px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw
|
||||
py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph
|
||||
return px, py
|
||||
|
||||
for i in range(6):
|
||||
gy = ymin + (ymax - ymin) * i / 5
|
||||
_, py = to_px(xmin, gy)
|
||||
draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1)
|
||||
label = f"{gy:.4g}"
|
||||
draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm")
|
||||
|
||||
gx = xmin + (xmax - xmin) * i / 5
|
||||
px, _ = to_px(gx, ymin)
|
||||
draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1)
|
||||
label = f"{gx:.4g}"
|
||||
draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt")
|
||||
|
||||
n = len(y)
|
||||
step = max(1, n // pw)
|
||||
xs, ys = x[::step], y[::step]
|
||||
pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))]
|
||||
if len(pts) > 1:
|
||||
draw.line(pts, fill=line_color, width=2)
|
||||
|
||||
draw.rectangle(
|
||||
[margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph],
|
||||
outline=(100, 100, 100), width=1,
|
||||
)
|
||||
draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb")
|
||||
|
||||
y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0))
|
||||
y_draw = ImageDraw.Draw(y_label_img)
|
||||
y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm")
|
||||
y_label_img = y_label_img.rotate(90, expand=True)
|
||||
img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img)
|
||||
|
||||
if title and title.strip():
|
||||
draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt")
|
||||
|
||||
ext = ".png" if format_name == "PNG" else ".tiff"
|
||||
img.save(str(path.with_suffix(ext)))
|
||||
60
backend/exporters/mesh.py
Normal file
60
backend/exporters/mesh.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Exporter for MESH_MODEL values (Wavefront OBJ, ASCII STL).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import MeshModel
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
accepted_types: tuple[str, ...] = ("MESH_MODEL",)
|
||||
|
||||
FORMATS: dict[str, FormatSpec] = {
|
||||
"OBJ": FormatSpec(ext=".obj", round_trip=True, label="Wavefront OBJ"),
|
||||
"STL": FormatSpec(ext=".stl", round_trip=True, label="STL (ASCII)"),
|
||||
}
|
||||
|
||||
|
||||
def save(path: Path, value: MeshModel, format_name: str, **_opts) -> None:
|
||||
if format_name == "OBJ":
|
||||
_save_obj(path, value)
|
||||
return
|
||||
if format_name == "STL":
|
||||
_save_stl(path, value)
|
||||
return
|
||||
raise ValueError(f"Format {format_name!r} is not supported for MESH_MODEL.")
|
||||
|
||||
|
||||
def _save_obj(path: Path, mesh: MeshModel) -> None:
|
||||
lines: list[str] = []
|
||||
for vertex in mesh.vertices:
|
||||
lines.append(f"v {vertex[0]} {vertex[1]} {vertex[2]}")
|
||||
for face in mesh.faces:
|
||||
lines.append(f"f {int(face[0]) + 1} {int(face[1]) + 1} {int(face[2]) + 1}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _save_stl(path: Path, mesh: MeshModel) -> None:
|
||||
def normal(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> np.ndarray:
|
||||
n = np.cross(b - a, c - a)
|
||||
length = float(np.linalg.norm(n))
|
||||
return n / length if length > 0 else np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
||||
|
||||
lines = ["solid tono"]
|
||||
vertices = np.asarray(mesh.vertices, dtype=np.float32)
|
||||
for face in np.asarray(mesh.faces, dtype=np.int32):
|
||||
a, b, c = vertices[int(face[0])], vertices[int(face[1])], vertices[int(face[2])]
|
||||
n = normal(a, b, c)
|
||||
lines.append(f" facet normal {n[0]} {n[1]} {n[2]}")
|
||||
lines.append(" outer loop")
|
||||
lines.append(f" vertex {a[0]} {a[1]} {a[2]}")
|
||||
lines.append(f" vertex {b[0]} {b[1]} {b[2]}")
|
||||
lines.append(f" vertex {c[0]} {c[1]} {c[2]}")
|
||||
lines.append(" endloop")
|
||||
lines.append(" endfacet")
|
||||
lines.append("endsolid tono")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
28
backend/exporters/scalar.py
Normal file
28
backend/exporters/scalar.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Exporter for FLOAT scalars (also handles Python int and numpy scalar types).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
accepted_types: tuple[str, ...] = ("FLOAT",)
|
||||
|
||||
FORMATS: dict[str, FormatSpec] = {
|
||||
"TXT": FormatSpec(ext=".txt", round_trip=True, label="Text"),
|
||||
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
|
||||
}
|
||||
|
||||
|
||||
def save(path: Path, value: float, format_name: str, **_opts) -> None:
|
||||
numeric = float(value)
|
||||
if format_name == "TXT":
|
||||
path.write_text(f"{numeric}\n", encoding="utf-8")
|
||||
return
|
||||
if format_name == "JSON":
|
||||
path.write_text(json.dumps({"value": numeric}, indent=2), encoding="utf-8")
|
||||
return
|
||||
raise ValueError(f"Format {format_name!r} is not supported for scalar values.")
|
||||
44
backend/exporters/table.py
Normal file
44
backend/exporters/table.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Exporter for RECORD_TABLE and DATA_TABLE values.
|
||||
|
||||
Both types are list-of-dict; the Save node currently accepts plain lists in
|
||||
this slot too, which is preserved here. CSV auto-derives its column set from
|
||||
the first row's keys (and any additional keys that appear later), matching
|
||||
the prior behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
accepted_types: tuple[str, ...] = ("RECORD_TABLE", "DATA_TABLE")
|
||||
|
||||
FORMATS: dict[str, FormatSpec] = {
|
||||
"CSV": FormatSpec(ext=".csv", round_trip=True, label="CSV"),
|
||||
"JSON": FormatSpec(ext=".json", round_trip=True, label="JSON"),
|
||||
}
|
||||
|
||||
|
||||
def save(path: Path, value: list, format_name: str, **_opts) -> None:
|
||||
rows = list(value)
|
||||
if format_name == "JSON":
|
||||
path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
||||
return
|
||||
if format_name == "CSV":
|
||||
columns: list[str] = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
for key in row.keys():
|
||||
if key not in columns:
|
||||
columns.append(str(key))
|
||||
with path.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=columns)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(row if isinstance(row, dict) else {"value": row})
|
||||
return
|
||||
raise ValueError(f"Format {format_name!r} is not supported for table inputs.")
|
||||
@@ -20,19 +20,42 @@ def load(path: Path) -> list[DataField]:
|
||||
|
||||
fields = []
|
||||
for ch in channels.values():
|
||||
data = np.array(ch.data, dtype=np.float64).reshape(ch.yres, ch.xres)
|
||||
# gwyfile.objects.GwyDataField exposes .data as an already-2D ndarray
|
||||
# (no xres/yres attributes — those were removed in gwyfile 0.3+).
|
||||
data = np.asarray(ch.data, dtype=np.float64)
|
||||
if data.ndim != 2:
|
||||
# Defensive: if a future gwyfile version yields a flat buffer, the
|
||||
# dimensions live in the serialized object's xres/yres keys.
|
||||
xres = int(ch.get("xres", data.size))
|
||||
yres = int(ch.get("yres", 1))
|
||||
data = data.reshape(yres, xres)
|
||||
fields.append(DataField(
|
||||
data=data,
|
||||
xreal=float(ch.xreal),
|
||||
yreal=float(ch.yreal),
|
||||
xoff=float(getattr(ch, "xoff", 0.0)),
|
||||
yoff=float(getattr(ch, "yoff", 0.0)),
|
||||
si_unit_xy="m",
|
||||
si_unit_z="m",
|
||||
si_unit_xy=_unit_str(getattr(ch, "si_unit_xy", None)) or "m",
|
||||
si_unit_z=_unit_str(getattr(ch, "si_unit_z", None)) or "m",
|
||||
))
|
||||
return fields
|
||||
|
||||
|
||||
def _unit_str(si_unit: object) -> str:
|
||||
"""Extract the unit string from a GwySIUnit without importing gwyfile.
|
||||
|
||||
Loaded GwySIUnit objects behave like dicts with a ``unitstr`` key.
|
||||
"""
|
||||
if si_unit is None:
|
||||
return ""
|
||||
if hasattr(si_unit, "unitstr"):
|
||||
return str(getattr(si_unit, "unitstr") or "")
|
||||
try:
|
||||
return str(si_unit["unitstr"] or "")
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def channel_names(path: Path) -> list[str]:
|
||||
import gwyfile
|
||||
try:
|
||||
|
||||
@@ -14,8 +14,8 @@ from typing import Any
|
||||
MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"Input": [
|
||||
"Image",
|
||||
"Folder",
|
||||
"ImageDemo",
|
||||
"Folder",
|
||||
"SyntheticSurface",
|
||||
"Note",
|
||||
"TextNote",
|
||||
@@ -33,7 +33,6 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"ValueIO",
|
||||
"PrintTable",
|
||||
"Save",
|
||||
"SaveImage",
|
||||
"Shade",
|
||||
"PresentationOps",
|
||||
],
|
||||
@@ -151,6 +150,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"MarkDisconnected",
|
||||
"MaskShift",
|
||||
"MaskNoisify",
|
||||
"RectangularMask",
|
||||
],
|
||||
"Grains": [
|
||||
"GrainDistanceTransform",
|
||||
|
||||
88
backend/nodes/arc_revolve.py
Normal file
88
backend/nodes/arc_revolve.py
Normal 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))
|
||||
@@ -3,6 +3,7 @@ import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_overlay
|
||||
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")
|
||||
@@ -19,6 +20,7 @@ class CropResizeField:
|
||||
"target_width": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
|
||||
"target_height": ("INT", {"default": 0, "min": 0, "max": 8192, "step": 1}),
|
||||
"interpolation": (["bilinear", "nearest", "bicubic"],),
|
||||
"square": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"corner_a": ("COORD",),
|
||||
@@ -34,7 +36,8 @@ class CropResizeField:
|
||||
DESCRIPTION = (
|
||||
"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; "
|
||||
"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")
|
||||
@@ -49,6 +52,7 @@ class CropResizeField:
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
interpolation: str,
|
||||
square: bool = False,
|
||||
corner_a=None,
|
||||
corner_b=None,
|
||||
) -> tuple:
|
||||
@@ -62,21 +66,29 @@ class CropResizeField:
|
||||
x2 = float(np.clip(x2, 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)
|
||||
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",
|
||||
"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:
|
||||
raise ValueError("Crop region must have non-zero width and height.")
|
||||
|
||||
|
||||
@@ -319,6 +319,20 @@ def bool_to_mask(binary: np.ndarray) -> np.ndarray:
|
||||
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(
|
||||
mask: np.ndarray | None, shape: tuple[int, int],
|
||||
) -> np.ndarray | None:
|
||||
|
||||
75
backend/nodes/level_rotate.py
Normal file
75
backend/nodes/level_rotate.py
Normal 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),)
|
||||
113
backend/nodes/mask_rectangular.py
Normal file
113
backend/nodes/mask_rectangular.py
Normal 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,)
|
||||
@@ -5,7 +5,23 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
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")
|
||||
@@ -19,11 +35,12 @@ class MultipleProfiles:
|
||||
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
|
||||
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
|
||||
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "slider": True}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('LINE_DATA', 'profile'),
|
||||
('LINE', 'profile'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
@@ -31,12 +48,14 @@ class MultipleProfiles:
|
||||
"Extract and compare line profiles from two fields. "
|
||||
"Row=-1 uses the center row/column. Modes: overlay returns field_a's "
|
||||
"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")
|
||||
|
||||
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)
|
||||
b = np.asarray(field_b.data, dtype=np.float64)
|
||||
|
||||
@@ -49,6 +68,7 @@ class MultipleProfiles:
|
||||
pa = pa[:len(pb)]
|
||||
dx = field_a.dx
|
||||
x_unit = field_a.si_unit_xy
|
||||
line_axis_max = a.shape[0] - 1
|
||||
else:
|
||||
if row < 0:
|
||||
row = a.shape[1] // 2
|
||||
@@ -58,6 +78,7 @@ class MultipleProfiles:
|
||||
pa = pa[:len(pb)]
|
||||
dx = field_a.dy
|
||||
x_unit = field_a.si_unit_xy
|
||||
line_axis_max = a.shape[1] - 1
|
||||
|
||||
x_axis = np.arange(len(pa)) * dx
|
||||
|
||||
@@ -70,5 +91,15 @@ class MultipleProfiles:
|
||||
else:
|
||||
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,
|
||||
y_unit=field_a.si_unit_z),)
|
||||
|
||||
@@ -6,25 +6,34 @@ import numpy as np
|
||||
from scipy.ndimage import map_coordinates
|
||||
|
||||
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")
|
||||
class PerspectiveCorrection:
|
||||
_CUSTOM_PREVIEW = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
||||
"bottom_right_y": ("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.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"top_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"top_right_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"bottom_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"bottom_left_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"bottom_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"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 = (
|
||||
@@ -33,9 +42,8 @@ class PerspectiveCorrection:
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fix perspective distortion by specifying corner offsets. Each corner "
|
||||
"can be shifted by a fractional amount (relative to image size) to "
|
||||
"define the distorted quadrilateral. The image is then warped back to "
|
||||
"Fix perspective distortion by dragging corner handles. Each corner "
|
||||
"offset defines a distorted quadrilateral that is warped back to "
|
||||
"a rectangle."
|
||||
)
|
||||
|
||||
@@ -45,11 +53,23 @@ class PerspectiveCorrection:
|
||||
top_left_x: float, top_left_y: float,
|
||||
top_right_x: float, top_right_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)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Source corners (distorted) as fractional offsets from ideal corners
|
||||
src = np.array([
|
||||
[top_left_y * yres, top_left_x * xres],
|
||||
[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)],
|
||||
], dtype=np.float64)
|
||||
|
||||
# Destination corners (ideal rectangle)
|
||||
dst = np.array([
|
||||
[0, 0],
|
||||
[0, xres - 1],
|
||||
@@ -65,33 +84,54 @@ class PerspectiveCorrection:
|
||||
[yres - 1, xres - 1],
|
||||
], dtype=np.float64)
|
||||
|
||||
# Solve for perspective transform matrix (3x3)
|
||||
H = _solve_perspective(src, dst)
|
||||
|
||||
# Apply inverse warp
|
||||
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 /= src_coords[2:3, :]
|
||||
sy = src_coords[0].reshape(yres, xres)
|
||||
sx = src_coords[1].reshape(yres, xres)
|
||||
sx = src_coords[0].reshape(yres, xres)
|
||||
sy = src_coords[1].reshape(yres, xres)
|
||||
|
||||
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:
|
||||
"""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)
|
||||
A = np.zeros((2 * n, 8))
|
||||
b = np.zeros(2 * n)
|
||||
for i in range(n):
|
||||
dy, dx = dst[i]
|
||||
sy, sx = src[i]
|
||||
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]
|
||||
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]
|
||||
b[2 * i] = sx
|
||||
b[2 * i + 1] = sy
|
||||
dr, dc = dst[i] # dest row, col
|
||||
sr, sc = src[i] # src row, col
|
||||
A[2 * i] = [dc, dr, 1, 0, 0, 0, -sc * dc, -sc * dr]
|
||||
A[2 * i + 1] = [0, 0, 0, dc, dr, 1, -sr * dc, -sr * dr]
|
||||
b[2 * i] = sc
|
||||
b[2 * i + 1] = sr
|
||||
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||
H = np.array([[h[0], h[1], h[2]],
|
||||
[h[3], h[4], h[5]],
|
||||
|
||||
@@ -2,8 +2,14 @@ from __future__ import annotations
|
||||
|
||||
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.data_types import DataField, LineData
|
||||
|
||||
|
||||
@register_node(display_name="Radial Profile")
|
||||
@@ -13,9 +19,11 @@ class RadialProfile:
|
||||
return {
|
||||
"required": {
|
||||
"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}),
|
||||
"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"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Compute the azimuthally averaged radial profile from a centre point. "
|
||||
"cx/cy give the centre as a fraction of the field width/height (0.5 = centre). "
|
||||
"Output x-axis is radius in physical xy units. "
|
||||
"Compute an azimuthally averaged profile around a centre point. "
|
||||
"At each radius, every pixel in the full 360° ring is averaged together, "
|
||||
"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")
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
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
|
||||
ys = (np.arange(yres) + 0.5) * field.dy + field.yoff
|
||||
gx, gy = np.meshgrid(xs, ys)
|
||||
@@ -47,20 +72,19 @@ class RadialProfile:
|
||||
r = np.hypot(gx - xc_phys, gy - yc_phys).ravel()
|
||||
values = field.data.ravel()
|
||||
|
||||
# Maximum radius — farthest pixel from centre
|
||||
r_max = float(r.max())
|
||||
if r_max == 0.0:
|
||||
r_max = float(np.hypot(xe_phys - xc_phys, ye_phys - yc_phys))
|
||||
if r_max <= 0.0:
|
||||
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)
|
||||
mask = r <= r_max
|
||||
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)
|
||||
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)
|
||||
|
||||
with np.errstate(invalid="ignore"):
|
||||
@@ -68,6 +92,16 @@ class RadialProfile:
|
||||
|
||||
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(
|
||||
data=profile,
|
||||
x_axis=centers,
|
||||
|
||||
@@ -1,26 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_warning, emit_file_download
|
||||
from backend.data_types import (
|
||||
DataField, LineData, MeshModel, datafield_to_uint8, image_to_uint8,
|
||||
_SI_PREFIXES, _PREFIXABLE_UNITS,
|
||||
from backend.exporters import (
|
||||
available_formats,
|
||||
get_exporter,
|
||||
resolve_path,
|
||||
type_name_for_value,
|
||||
)
|
||||
from backend.nodes.helpers import _MAX_SAVE_FIELDS
|
||||
|
||||
DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "tono-downloads"
|
||||
|
||||
# Source types that expand into a layer stack (i.e., the Save node grows
|
||||
# extra field_N inputs). Any other type (FLOAT, LINE, MESH, …) is a single
|
||||
# value; no stacking UI is shown.
|
||||
_STACKABLE_SOURCE_TYPES: tuple[str, ...] = ("DATA_FIELD", "IMAGE", "ANNOTATION_SOURCE")
|
||||
|
||||
|
||||
def _choices_by_source_type() -> dict[str, list[str]]:
|
||||
"""Build the format dropdown's source-type map from the exporter registry.
|
||||
|
||||
Centralising this here means adding a new exporter module (or a new format
|
||||
inside an existing one) automatically surfaces in the UI — no parallel
|
||||
list to keep in sync.
|
||||
"""
|
||||
return {
|
||||
"DATA_FIELD": available_formats("DATA_FIELD"),
|
||||
"IMAGE": available_formats("IMAGE"),
|
||||
"ANNOTATION_SOURCE": available_formats("ANNOTATION_SOURCE"),
|
||||
"LINE": available_formats("LINE"),
|
||||
"RECORD_TABLE": available_formats("RECORD_TABLE"),
|
||||
"DATA_TABLE": available_formats("DATA_TABLE"),
|
||||
"FLOAT": available_formats("FLOAT"),
|
||||
"MESH_MODEL": available_formats("MESH_MODEL"),
|
||||
}
|
||||
|
||||
|
||||
@register_node(display_name="Save")
|
||||
class Save:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
choices = _choices_by_source_type()
|
||||
|
||||
optional: dict[str, Any] = {
|
||||
"plot_title": ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "plot title (optional)",
|
||||
"label": "title",
|
||||
"show_when_source_type": {"value": ["LINE"]},
|
||||
}),
|
||||
# Name widget for the primary (value) layer. Only surfaces once
|
||||
# the stack grows beyond one layer, so single-value saves stay
|
||||
# clutter-free.
|
||||
"primary_name": ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "name",
|
||||
"show_when_input_visible": "field_0",
|
||||
"inline_with_input": "layer_1",
|
||||
"hide_label": True,
|
||||
}),
|
||||
}
|
||||
# Extra layer sockets for stackable source types. The frontend
|
||||
# progressive-reveal block keys off `field_N` and only shows slot N
|
||||
# once slot N-1 is connected; we further gate every slot on `value`
|
||||
# being a stackable source type via `show_when_source_type`.
|
||||
for i in range(_MAX_SAVE_FIELDS):
|
||||
optional[f"field_{i}"] = ("DATA_FIELD", {
|
||||
"label": f"layer {i + 2}", # primary is layer 1
|
||||
"accepted_types": ["IMAGE", "ANNOTATION_SOURCE"],
|
||||
"show_when_source_type": {"value": list(_STACKABLE_SOURCE_TYPES)},
|
||||
})
|
||||
optional[f"layer_name_{i}"] = ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "name",
|
||||
"show_when_input_visible": f"field_{i}",
|
||||
"inline_with_input": f"field_{i}",
|
||||
"hide_label": True,
|
||||
})
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"filename": ("STRING", {
|
||||
@@ -29,7 +91,7 @@ class Save:
|
||||
"placement": "top",
|
||||
}),
|
||||
"value": ("DATA_FIELD", {
|
||||
"label": "value",
|
||||
"label": "layer 1",
|
||||
"accepted_types": [
|
||||
"IMAGE",
|
||||
"ANNOTATION_SOURCE",
|
||||
@@ -41,28 +103,12 @@ class Save:
|
||||
],
|
||||
}),
|
||||
"format": ("STRING", {
|
||||
"default": "TIFF",
|
||||
"choices_by_source_type": {
|
||||
"DATA_FIELD": ["TIFF", "PNG", "NPZ"],
|
||||
"IMAGE": ["PNG", "TIFF", "NPZ"],
|
||||
"ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"],
|
||||
"LINE": ["PNG", "TIFF", "CSV", "NPZ", "JSON"],
|
||||
"RECORD_TABLE": ["CSV", "JSON"],
|
||||
"DATA_TABLE": ["CSV", "JSON"],
|
||||
"FLOAT": ["TXT", "JSON"],
|
||||
"MESH_MODEL": ["OBJ", "STL"],
|
||||
},
|
||||
"default": choices["DATA_FIELD"][0] if choices["DATA_FIELD"] else "",
|
||||
"choices_by_source_type": choices,
|
||||
"source_type_input": "value",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"plot_title": ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "plot title (optional)",
|
||||
"label": "title",
|
||||
"show_when_source_type": {"value": ["LINE"]},
|
||||
}),
|
||||
},
|
||||
"optional": optional,
|
||||
}
|
||||
|
||||
OUTPUTS = ()
|
||||
@@ -71,10 +117,15 @@ class Save:
|
||||
OUTPUT_NODE = True
|
||||
MANUAL_TRIGGER = True
|
||||
DESCRIPTION = (
|
||||
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes."
|
||||
"Save one or more channels."
|
||||
"Use 'GWY','TIFF (data)', or 'HDF5' when you need to re-open the result with its "
|
||||
"physical units preserved."
|
||||
)
|
||||
|
||||
KEYWORDS = ("export", "write", "download", "png", "tiff", "csv", "json", "npz", "obj", "stl")
|
||||
KEYWORDS = (
|
||||
"export", "write", "download", "png", "tiff", "csv", "json", "npz",
|
||||
"obj", "stl", "gwy", "hdf5", "layers", "stack", "channels",
|
||||
)
|
||||
|
||||
def save(
|
||||
self,
|
||||
@@ -82,296 +133,62 @@ class Save:
|
||||
format: str,
|
||||
value,
|
||||
plot_title: str = "",
|
||||
primary_name: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
path = self._resolve_save_path(filename, format)
|
||||
type_name = type_name_for_value(value)
|
||||
module, spec = get_exporter(type_name, format)
|
||||
path = resolve_path(filename, spec, DOWNLOAD_DIR)
|
||||
|
||||
if isinstance(value, MeshModel):
|
||||
self._save_mesh(path, value, format)
|
||||
elif isinstance(value, DataField):
|
||||
self._save_datafield(path, value, format)
|
||||
elif isinstance(value, np.ndarray):
|
||||
if value.ndim == 1:
|
||||
self._save_line(path, LineData(data=value), format, title=plot_title)
|
||||
else:
|
||||
self._save_image_or_array(path, value, format)
|
||||
elif isinstance(value, LineData):
|
||||
self._save_line(path, value, format, title=plot_title)
|
||||
elif isinstance(value, list):
|
||||
self._save_table(path, value, format)
|
||||
elif isinstance(value, (int, float, np.floating, np.integer)):
|
||||
self._save_scalar(path, float(value), format)
|
||||
else:
|
||||
raise ValueError(f"Save does not support input type: {type(value).__name__}")
|
||||
extra_layers, layer_names = self._collect_extra_layers(
|
||||
type_name, primary_name, kwargs,
|
||||
)
|
||||
|
||||
module.save(
|
||||
path,
|
||||
value,
|
||||
format,
|
||||
plot_title=plot_title,
|
||||
extra_layers=extra_layers,
|
||||
layer_names=layer_names,
|
||||
)
|
||||
|
||||
emit_warning(f"Saved to {path.name}")
|
||||
emit_file_download(str(path))
|
||||
return ()
|
||||
|
||||
def _resolve_save_path(self, filename: str, format_name: str) -> Path:
|
||||
ext_map = {
|
||||
"PNG": ".png",
|
||||
"TIFF": ".tiff",
|
||||
"NPZ": ".npz",
|
||||
"CSV": ".csv",
|
||||
"JSON": ".json",
|
||||
"OBJ": ".obj",
|
||||
"STL": ".stl",
|
||||
"TXT": ".txt",
|
||||
}
|
||||
ext = ext_map[format_name]
|
||||
|
||||
raw_filename = str(filename).strip() if filename is not None else ""
|
||||
if not raw_filename:
|
||||
raise ValueError("No output filename selected — enter a file name.")
|
||||
|
||||
candidate = Path(raw_filename).expanduser()
|
||||
if candidate.is_absolute():
|
||||
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||||
path = candidate
|
||||
else:
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = DOWNLOAD_DIR / candidate.name
|
||||
|
||||
if path.suffix.lower() != ext:
|
||||
path = path.with_suffix(ext)
|
||||
return path
|
||||
|
||||
def _save_datafield(self, path: Path, field: DataField, format_name: str):
|
||||
if format_name == "TIFF":
|
||||
import tifffile
|
||||
tifffile.imwrite(str(path), datafield_to_uint8(field, field.colormap))
|
||||
return
|
||||
if format_name == "NPZ":
|
||||
np.savez(str(path), field=np.asarray(field.data))
|
||||
return
|
||||
if format_name == "PNG":
|
||||
from PIL import Image
|
||||
Image.fromarray(datafield_to_uint8(field, field.colormap)).save(str(path))
|
||||
return
|
||||
raise ValueError(f"Format {format_name} is not supported for DATA_FIELD.")
|
||||
|
||||
def _save_image_or_array(self, path: Path, image: np.ndarray, format_name: str):
|
||||
arr = np.asarray(image)
|
||||
if format_name == "PNG":
|
||||
from PIL import Image
|
||||
Image.fromarray(image_to_uint8(arr)).save(str(path))
|
||||
return
|
||||
if format_name == "TIFF":
|
||||
import tifffile
|
||||
tifffile.imwrite(str(path), image_to_uint8(arr))
|
||||
return
|
||||
if format_name == "NPZ":
|
||||
np.savez(str(path), image=arr)
|
||||
return
|
||||
raise ValueError(f"Format {format_name} is not supported for IMAGE.")
|
||||
|
||||
def _save_line(self, path: Path, line: LineData, format_name: str, title: str = ""):
|
||||
y = np.asarray(line.data, dtype=np.float64).ravel()
|
||||
x = np.asarray(line.x_axis, dtype=np.float64).ravel()[: len(y)] if line.x_axis is not None else np.arange(len(y), dtype=np.float64)
|
||||
if format_name in ("PNG", "TIFF"):
|
||||
self._save_line_plot(path, x, y, line.x_unit, line.y_unit, title, format_name)
|
||||
return
|
||||
if format_name == "CSV":
|
||||
with path.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["x", "y", "x_unit", "y_unit"])
|
||||
for xv, yv in zip(x, y):
|
||||
writer.writerow([xv, yv, line.x_unit, line.y_unit])
|
||||
return
|
||||
if format_name == "NPZ":
|
||||
np.savez(str(path), x=x, y=y)
|
||||
return
|
||||
if format_name == "JSON":
|
||||
path.write_text(json.dumps({
|
||||
"x": x.tolist(),
|
||||
"y": y.tolist(),
|
||||
"x_unit": line.x_unit,
|
||||
"y_unit": line.y_unit,
|
||||
}, indent=2), encoding="utf-8")
|
||||
return
|
||||
raise ValueError(f"Format {format_name} is not supported for LINE.")
|
||||
|
||||
def _save_line_plot(
|
||||
def _collect_extra_layers(
|
||||
self,
|
||||
path: Path,
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
x_unit: str,
|
||||
y_unit: str,
|
||||
title: str,
|
||||
format_name: str,
|
||||
):
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
type_name: str,
|
||||
primary_name: str,
|
||||
kwargs: dict[str, Any],
|
||||
) -> tuple[list[Any], list[str]]:
|
||||
"""Pull field_N + layer_name_N from kwargs into parallel lists.
|
||||
|
||||
w, h = 1200, 750
|
||||
bg = (255, 255, 255)
|
||||
line_color = (79, 142, 247) # #4f8ef7
|
||||
grid_color = (200, 200, 200)
|
||||
text_color = (60, 60, 60)
|
||||
margin = {"left": 80, "right": 30, "top": 50, "bottom": 60}
|
||||
Only applies when the primary value is a stackable source type; for
|
||||
anything else (LINE, FLOAT, MESH_MODEL, tables) any stray field_N
|
||||
kwargs are ignored — the frontend hides those sockets in that case
|
||||
and the backend treats it as a single-value save.
|
||||
"""
|
||||
if type_name not in _STACKABLE_SOURCE_TYPES:
|
||||
return [], []
|
||||
|
||||
img = Image.new("RGB", (w, h), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
extras: list[Any] = []
|
||||
extra_names: list[str] = []
|
||||
# Preserve the on-node order: iterate field_0, field_1, …, stopping at
|
||||
# the first hole. An unconnected slot in the middle would be a UI bug,
|
||||
# but bailing early keeps the saved stack matching what the user sees.
|
||||
for i in range(_MAX_SAVE_FIELDS):
|
||||
layer = kwargs.get(f"field_{i}")
|
||||
if layer is None:
|
||||
break
|
||||
extras.append(layer)
|
||||
extra_names.append(str(kwargs.get(f"layer_name_{i}", "") or "").strip())
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", 14)
|
||||
font_small = ImageFont.truetype("DejaVuSans.ttf", 11)
|
||||
font_title = ImageFont.truetype("DejaVuSans.ttf", 16)
|
||||
except (OSError, IOError):
|
||||
font = ImageFont.load_default()
|
||||
font_small = font
|
||||
font_title = font
|
||||
if not extras:
|
||||
return [], []
|
||||
|
||||
pw = w - margin["left"] - margin["right"]
|
||||
ph = h - margin["top"] - margin["bottom"]
|
||||
|
||||
def _si_scale(unit: str, vmin: float, vmax: float) -> tuple[float, str]:
|
||||
"""Pick the best SI prefix for an axis range. Returns (divisor, prefixed_unit)."""
|
||||
unit = (unit or "").strip()
|
||||
if not unit or unit not in _PREFIXABLE_UNITS:
|
||||
return 1.0, unit if unit else ""
|
||||
peak = max(abs(vmin), abs(vmax))
|
||||
if peak == 0:
|
||||
return 1.0, unit
|
||||
for scale, prefix in _SI_PREFIXES:
|
||||
if peak / scale >= 1.0:
|
||||
return scale, f"{prefix}{unit}"
|
||||
return _SI_PREFIXES[-1][0], f"{_SI_PREFIXES[-1][1]}{unit}"
|
||||
|
||||
xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
|
||||
ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y))
|
||||
|
||||
x_scale, x_label = _si_scale(x_unit, xmin, xmax)
|
||||
y_scale, y_label = _si_scale(y_unit, ymin, ymax)
|
||||
if not x_label:
|
||||
x_label = "x"
|
||||
if not y_label:
|
||||
y_label = "y"
|
||||
|
||||
# Scale data into prefixed units
|
||||
x = x / x_scale
|
||||
y = y / y_scale
|
||||
xmin, xmax = xmin / x_scale, xmax / x_scale
|
||||
ymin, ymax = ymin / y_scale, ymax / y_scale
|
||||
|
||||
if ymax == ymin:
|
||||
ymin, ymax = ymin - 1, ymax + 1
|
||||
if xmax == xmin:
|
||||
xmax = xmin + 1
|
||||
# Add 5% padding to y range
|
||||
ypad = (ymax - ymin) * 0.05
|
||||
ymin -= ypad
|
||||
ymax += ypad
|
||||
|
||||
def to_px(xv: float, yv: float) -> tuple[float, float]:
|
||||
px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw
|
||||
py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph
|
||||
return px, py
|
||||
|
||||
# Grid lines (5 horizontal, 5 vertical)
|
||||
for i in range(6):
|
||||
gy = ymin + (ymax - ymin) * i / 5
|
||||
_, py = to_px(xmin, gy)
|
||||
draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1)
|
||||
label = f"{gy:.4g}"
|
||||
draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm")
|
||||
|
||||
gx = xmin + (xmax - xmin) * i / 5
|
||||
px, _ = to_px(gx, ymin)
|
||||
draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1)
|
||||
label = f"{gx:.4g}"
|
||||
draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt")
|
||||
|
||||
# Plot line
|
||||
n = len(y)
|
||||
step = max(1, n // pw)
|
||||
xs, ys = x[::step], y[::step]
|
||||
pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))]
|
||||
if len(pts) > 1:
|
||||
draw.line(pts, fill=line_color, width=2)
|
||||
|
||||
# Border
|
||||
draw.rectangle(
|
||||
[margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph],
|
||||
outline=(100, 100, 100), width=1,
|
||||
)
|
||||
draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb")
|
||||
# Vertical y label — draw rotated
|
||||
y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0))
|
||||
y_draw = ImageDraw.Draw(y_label_img)
|
||||
y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm")
|
||||
y_label_img = y_label_img.rotate(90, expand=True)
|
||||
img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img)
|
||||
|
||||
# Title
|
||||
if title and title.strip():
|
||||
draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt")
|
||||
|
||||
ext = ".png" if format_name == "PNG" else ".tiff"
|
||||
img.save(str(path.with_suffix(ext)))
|
||||
|
||||
def _save_table(self, path: Path, rows: list, format_name: str):
|
||||
if format_name == "JSON":
|
||||
path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
||||
return
|
||||
if format_name == "CSV":
|
||||
columns: list[str] = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
for key in row.keys():
|
||||
if key not in columns:
|
||||
columns.append(str(key))
|
||||
with path.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=columns)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(row if isinstance(row, dict) else {"value": row})
|
||||
return
|
||||
raise ValueError(f"Format {format_name} is not supported for table inputs.")
|
||||
|
||||
def _save_scalar(self, path: Path, value: float, format_name: str):
|
||||
if format_name == "TXT":
|
||||
path.write_text(f"{value}\n", encoding="utf-8")
|
||||
return
|
||||
if format_name == "JSON":
|
||||
path.write_text(json.dumps({"value": value}, indent=2), encoding="utf-8")
|
||||
return
|
||||
raise ValueError(f"Format {format_name} is not supported for scalar values.")
|
||||
|
||||
def _save_mesh(self, path: Path, mesh: MeshModel, format_name: str):
|
||||
if format_name == "OBJ":
|
||||
self._save_obj(path, mesh)
|
||||
return
|
||||
if format_name == "STL":
|
||||
self._save_stl(path, mesh)
|
||||
return
|
||||
raise ValueError(f"Format {format_name} is not supported for MESH_MODEL.")
|
||||
|
||||
def _save_obj(self, path: Path, mesh: MeshModel):
|
||||
lines = []
|
||||
for vertex in mesh.vertices:
|
||||
lines.append(f"v {vertex[0]} {vertex[1]} {vertex[2]}")
|
||||
for face in mesh.faces:
|
||||
lines.append(f"f {int(face[0]) + 1} {int(face[1]) + 1} {int(face[2]) + 1}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
def _save_stl(self, path: Path, mesh: MeshModel):
|
||||
def normal(a, b, c):
|
||||
n = np.cross(b - a, c - a)
|
||||
length = float(np.linalg.norm(n))
|
||||
return n / length if length > 0 else np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
||||
|
||||
lines = ["solid tono"]
|
||||
vertices = np.asarray(mesh.vertices, dtype=np.float32)
|
||||
for face in np.asarray(mesh.faces, dtype=np.int32):
|
||||
a, b, c = vertices[int(face[0])], vertices[int(face[1])], vertices[int(face[2])]
|
||||
n = normal(a, b, c)
|
||||
lines.append(f" facet normal {n[0]} {n[1]} {n[2]}")
|
||||
lines.append(" outer loop")
|
||||
lines.append(f" vertex {a[0]} {a[1]} {a[2]}")
|
||||
lines.append(f" vertex {b[0]} {b[1]} {b[2]}")
|
||||
lines.append(f" vertex {c[0]} {c[1]} {c[2]}")
|
||||
lines.append(" endloop")
|
||||
lines.append(" endfacet")
|
||||
lines.append("endsolid tono")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
# Full names list starts with the primary's name (empty → exporter
|
||||
# substitutes path.stem) and then each extra in order.
|
||||
names = [str(primary_name or "").strip(), *extra_names]
|
||||
return extras, names
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_warning, emit_file_download
|
||||
from backend.data_types import DataField, image_to_uint8
|
||||
from backend.nodes.helpers import _MAX_SAVE_FIELDS
|
||||
|
||||
|
||||
@register_node(display_name="Save Layers")
|
||||
class SaveImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
optional = {
|
||||
"directory": ("DIRECTORY", {"label": "directory"}),
|
||||
}
|
||||
for i in range(_MAX_SAVE_FIELDS):
|
||||
optional[f"field_{i}"] = ("DATA_FIELD", {
|
||||
"label": f"layer {i + 1}",
|
||||
"accepted_types": ["IMAGE", "ANNOTATION_SOURCE"],
|
||||
})
|
||||
optional[f"layer_name_{i}"] = ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "name",
|
||||
"show_when_input_visible": f"field_{i}",
|
||||
"inline_with_input": f"field_{i}",
|
||||
"hide_label": True,
|
||||
})
|
||||
return {
|
||||
"required": {
|
||||
"filename": ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "filename",
|
||||
"placement": "top",
|
||||
}),
|
||||
"directory_path": ("STRING", {
|
||||
"default": "",
|
||||
"label": "directory",
|
||||
"placeholder": "directory (optional, desktop only)",
|
||||
"placement": "top",
|
||||
"hide_when_input_connected": "directory",
|
||||
"top_socket_input": "directory",
|
||||
}),
|
||||
"format": (["TIFF", "NPZ"],),
|
||||
},
|
||||
"optional": optional,
|
||||
}
|
||||
|
||||
OUTPUTS = ()
|
||||
FUNCTION = "save"
|
||||
|
||||
OUTPUT_NODE = True
|
||||
MANUAL_TRIGGER = True
|
||||
DESCRIPTION = (
|
||||
"Save one or more image/field layers to a single file. "
|
||||
"Each layer input accepts either a DATA_FIELD or an IMAGE, including annotated images. "
|
||||
"Optionally drive the output directory from a folder/path node, while keeping the filename widget for the file name. "
|
||||
"A new slot appears as each one is filled, with a matching per-layer name field. "
|
||||
"Use this for composing multi-channel stacks. TIFF writes multi-page data and stores layer names as page descriptions; "
|
||||
"NPZ writes named arrays using those layer names as keys. "
|
||||
"Click Save to write (does not auto-run)."
|
||||
)
|
||||
|
||||
KEYWORDS = ("export", "write", "multipage", "stack", "tiff", "npz", "channels")
|
||||
|
||||
def save(
|
||||
self,
|
||||
filename: str,
|
||||
directory_path: str = "",
|
||||
format: str = "TIFF",
|
||||
directory: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
layers = []
|
||||
layer_names = []
|
||||
for i in range(_MAX_SAVE_FIELDS):
|
||||
layer = kwargs.get(f"field_{i}")
|
||||
if layer is not None:
|
||||
layers.append(layer)
|
||||
layer_names.append(self._resolve_layer_name(kwargs.get(f"layer_name_{i}"), i))
|
||||
|
||||
if not layers:
|
||||
raise ValueError("No layers connected — connect at least one DATA_FIELD or IMAGE input.")
|
||||
|
||||
path = self._resolve_save_path(filename, format, directory, directory_path)
|
||||
|
||||
if format == "TIFF":
|
||||
self._save_tiff(path, layers, layer_names)
|
||||
else:
|
||||
self._save_npz(path, layers, layer_names)
|
||||
|
||||
emit_warning(f"Saved {len(layers)} layer(s) to {path.name}")
|
||||
emit_file_download(str(path))
|
||||
return ()
|
||||
|
||||
def _save_tiff(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
|
||||
import tifffile
|
||||
|
||||
with tifffile.TiffWriter(str(path)) as tif:
|
||||
for layer, layer_name in zip(layers, layer_names):
|
||||
tif.write(self._layer_array_for_tiff(layer), description=layer_name)
|
||||
|
||||
def _save_npz(self, path: Path, layers: list[DataField | np.ndarray], layer_names: list[str]):
|
||||
arrays = {}
|
||||
used_keys = set()
|
||||
for i, (layer, layer_name) in enumerate(zip(layers, layer_names)):
|
||||
arrays[self._unique_npz_key(layer_name, used_keys, i)] = self._layer_array_for_npz(layer)
|
||||
np.savez(str(path), **arrays)
|
||||
|
||||
def _resolve_layer_name(self, raw_name: object, index: int) -> str:
|
||||
text = str(raw_name).strip() if raw_name is not None else ""
|
||||
return text or f"layer_{index}"
|
||||
|
||||
def _resolve_save_path(
|
||||
self,
|
||||
filename: str,
|
||||
format: str,
|
||||
directory: str | None,
|
||||
directory_path: str = "",
|
||||
) -> Path:
|
||||
ext = ".tiff" if format == "TIFF" else ".npz"
|
||||
raw_filename = str(filename).strip() if filename is not None else ""
|
||||
raw_directory = str(directory).strip() if directory is not None else ""
|
||||
if not raw_directory:
|
||||
raw_directory = str(directory_path).strip() if directory_path is not None else ""
|
||||
|
||||
if raw_directory:
|
||||
dir_path = Path(raw_directory).expanduser()
|
||||
if dir_path.exists() and not dir_path.is_dir():
|
||||
raise ValueError("Directory input expects a folder path, not a file path.")
|
||||
if not dir_path.exists():
|
||||
if dir_path.suffix:
|
||||
raise ValueError("Directory input expects a folder path, not a file path.")
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename_part = Path(raw_filename).name if raw_filename else ""
|
||||
if not filename_part:
|
||||
raise ValueError("No output filename selected — enter a file name when using a directory input.")
|
||||
path = dir_path / filename_part
|
||||
else:
|
||||
if not raw_filename:
|
||||
raise ValueError("No output filename selected — enter a file name.")
|
||||
candidate = Path(raw_filename).expanduser()
|
||||
if candidate.is_absolute():
|
||||
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||||
path = candidate
|
||||
else:
|
||||
from backend.nodes.save import DOWNLOAD_DIR
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = DOWNLOAD_DIR / candidate.name
|
||||
|
||||
if path.suffix.lower() != ext:
|
||||
path = path.with_suffix(ext)
|
||||
return path
|
||||
|
||||
def _unique_npz_key(self, raw_name: str, used_keys: set[str], index: int) -> str:
|
||||
key = re.sub(r"[^0-9A-Za-z_]+", "_", str(raw_name).strip()).strip("_")
|
||||
if not key:
|
||||
key = f"layer_{index}"
|
||||
if key[0].isdigit():
|
||||
key = f"layer_{key}"
|
||||
|
||||
candidate = key
|
||||
suffix = 2
|
||||
while candidate in used_keys:
|
||||
candidate = f"{key}_{suffix}"
|
||||
suffix += 1
|
||||
used_keys.add(candidate)
|
||||
return candidate
|
||||
|
||||
def _layer_array_for_tiff(self, layer: DataField | np.ndarray) -> np.ndarray:
|
||||
if isinstance(layer, DataField):
|
||||
return np.asarray(layer.data, dtype=np.float32)
|
||||
if isinstance(layer, np.ndarray):
|
||||
return image_to_uint8(layer)
|
||||
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
|
||||
|
||||
def _layer_array_for_npz(self, layer: DataField | np.ndarray) -> np.ndarray:
|
||||
if isinstance(layer, DataField):
|
||||
return np.asarray(layer.data)
|
||||
if isinstance(layer, np.ndarray):
|
||||
return np.asarray(layer)
|
||||
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
|
||||
74
backend/nodes/sphere_revolve.py
Normal file
74
backend/nodes/sphere_revolve.py
Normal 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))
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField, RecordTable
|
||||
from backend.nodes.helpers import mask_to_bool
|
||||
|
||||
|
||||
@register_node(display_name="Statistics")
|
||||
@@ -11,7 +12,10 @@ class Statistics:
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
@@ -21,13 +25,24 @@ class Statistics:
|
||||
|
||||
DESCRIPTION = (
|
||||
"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")
|
||||
|
||||
def process(self, field: DataField) -> tuple:
|
||||
def process(self, field: DataField, mask: np.ndarray | None = None) -> tuple:
|
||||
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())
|
||||
rms = float(np.sqrt(np.mean((d - mean) ** 2)))
|
||||
skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import CubicSpline
|
||||
from scipy.ndimage import map_coordinates
|
||||
|
||||
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")
|
||||
@@ -16,8 +18,8 @@ class StraightenPath:
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}),
|
||||
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}),
|
||||
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75", "hidden": True}),
|
||||
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5", "hidden": True}),
|
||||
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
|
||||
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
|
||||
}
|
||||
@@ -25,14 +27,15 @@ class StraightenPath:
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'straightened'),
|
||||
('LINE', 'profile'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Extract a cross-section along an arbitrary curved path defined by "
|
||||
"control points. Points are given as fractional coordinates (0-1). "
|
||||
"The path is interpolated with cubic splines, and data is sampled "
|
||||
"along it with configurable thickness. "
|
||||
"control points. The path is a natural cubic spline through the "
|
||||
"points. Drag the points on the preview to reshape the path; the "
|
||||
"shaded band shows the sampling thickness. "
|
||||
)
|
||||
|
||||
KEYWORDS = ("unbend", "unroll", "spline", "curved profile", "extract path")
|
||||
@@ -42,36 +45,46 @@ class StraightenPath:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Parse control points
|
||||
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()]
|
||||
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()]
|
||||
fx = [float(v.strip()) for v in points_x.split(",") if v.strip()]
|
||||
fy = [float(v.strip()) 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:
|
||||
# Need at least 2 points
|
||||
return (field,)
|
||||
emit_overlay({
|
||||
"kind": "straighten_path",
|
||||
"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))
|
||||
px, py = px[:n_pts], py[:n_pts]
|
||||
if n_pts < 2:
|
||||
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_sample = np.linspace(0, 1, n_samples)
|
||||
|
||||
# Simple cubic interpolation via numpy
|
||||
if n_pts >= 4:
|
||||
from numpy.polynomial.polynomial import Polynomial
|
||||
cx = np.interp(t_sample, t_ctrl, px)
|
||||
cy = np.interp(t_sample, t_ctrl, py)
|
||||
if n_pts >= 3:
|
||||
cx = CubicSpline(t_ctrl, px, bc_type="natural")(t_sample)
|
||||
cy = CubicSpline(t_ctrl, py, bc_type="natural")(t_sample)
|
||||
else:
|
||||
cx = np.interp(t_sample, t_ctrl, px)
|
||||
cy = np.interp(t_sample, t_ctrl, py)
|
||||
|
||||
# Sample along path with thickness
|
||||
if thickness <= 1:
|
||||
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
||||
result = values.reshape(1, -1)
|
||||
else:
|
||||
# Compute normals
|
||||
dcx = np.gradient(cx)
|
||||
dcy = np.gradient(cy)
|
||||
length = np.sqrt(dcx**2 + dcy**2)
|
||||
@@ -86,12 +99,22 @@ class StraightenPath:
|
||||
sy = cy + off * ny
|
||||
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||
|
||||
# Physical dimensions
|
||||
total_length = 0.0
|
||||
for i in range(1, len(cx)):
|
||||
dx_phys = (cx[i] - cx[i - 1]) * field.dx
|
||||
dy_phys = (cy[i] - cy[i - 1]) * field.dy
|
||||
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
|
||||
|
||||
return (field.replace(data=result, xreal=total_length,
|
||||
yreal=thickness * max(field.dx, field.dy)),)
|
||||
center_values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
||||
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
88
backend/nodes/unrotate.py
Normal 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),)
|
||||
56
backend/nodes/zero_value.py
Normal file
56
backend/nodes/zero_value.py
Normal 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()),)
|
||||
@@ -35,6 +35,14 @@ pip install -e ".[server,dev,desktop]"
|
||||
|
||||
Two servers run in development: the Vite frontend dev server and the Python backend. Vite proxies all API and WebSocket requests to the backend, so you only open the Vite URL in your browser.
|
||||
|
||||
The easiest way is the combined runner, which starts both with prefixed, colour-coded output and tears everything down on Ctrl-C:
|
||||
|
||||
```bash
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
Or run them in separate terminals if you prefer independent logs:
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Python backend (http://127.0.0.1:8188)
|
||||
npm run backend
|
||||
@@ -157,8 +165,9 @@ TONO_APPDATA=/my/data/dir python desktop.py
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run dev` | Start Vite dev server + Python backend |
|
||||
| `npm run backend` | Start Python backend only |
|
||||
| `npm run dev:all` | Start the Python backend and the Vite dev server together |
|
||||
| `npm run dev` | Start the Vite dev server only |
|
||||
| `npm run backend` | Start the Python backend only |
|
||||
| `npm run build` | Build frontend to `frontend/dist/` |
|
||||
| `npm run preview` | Preview the production frontend build |
|
||||
| `npm run desktop` | Build frontend + launch desktop app |
|
||||
|
||||
98
docs/missing_gwyddion_features.md
Normal file
98
docs/missing_gwyddion_features.md
Normal 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
29
docs/nodes/Arc Revolve.md
Normal 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 (1–1000) |
|
||||
| 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.
|
||||
@@ -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_height | INT | 0 | Output pixel height after resampling (0 = keep cropped height) |
|
||||
| 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
21
docs/nodes/Level Rotate.md
Normal file
21
docs/nodes/Level Rotate.md
Normal 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -13,7 +13,7 @@ Extract and compare line profiles from two fields along a chosen row or column.
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| profile | LINE_DATA | Resulting line profile |
|
||||
| profile | LINE | Resulting line profile |
|
||||
|
||||
## 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) |
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# 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
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| 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
|
||||
|
||||
@@ -14,22 +18,15 @@ Fix perspective distortion in a DATA_FIELD via a projective (homography) transfo
|
||||
|------|------|-------------|
|
||||
| corrected | DATA_FIELD | Perspective-corrected field |
|
||||
|
||||
## Controls
|
||||
## Interactive preview
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| top_left_x | FLOAT | 0.0 | Horizontal offset of the top-left corner as a fraction of image width (-0.5-0.5) |
|
||||
| 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) |
|
||||
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.
|
||||
|
||||
Corner positions can also be set by connecting Coordinate nodes to the optional COORD inputs, which override the handle-driven values.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- Set all offsets to 0.0 to pass the field through unchanged.
|
||||
- When all offsets are zero (default), the field passes through unchanged.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -18,11 +18,13 @@ Compute the azimuthally averaged radial profile from a centre point. The output
|
||||
|
||||
| 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) |
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
|
||||
34
docs/nodes/Rectangular Mask.md
Normal file
34
docs/nodes/Rectangular Mask.md
Normal 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.
|
||||
28
docs/nodes/Sphere Revolve.md
Normal file
28
docs/nodes/Sphere Revolve.md
Normal 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 (1–500) |
|
||||
|
||||
## 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.
|
||||
@@ -7,6 +7,7 @@ Compute basic surface statistics: min, max, mean, RMS roughness, median, and ske
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| field | DATA_FIELD | Yes | Input field to analyze |
|
||||
| mask | IMAGE | No | Optional binary mask — only pixels inside the mask contribute to the statistics |
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -20,4 +21,4 @@ None.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -13,20 +13,25 @@ Extract a cross-section along an arbitrary curved path defined by control points
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| 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
|
||||
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
28
docs/nodes/Unrotate.md
Normal file
28
docs/nodes/Unrotate.md
Normal 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.
|
||||
20
docs/nodes/Zero Maximum.md
Normal file
20
docs/nodes/Zero Maximum.md
Normal 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
20
docs/nodes/Zero Mean.md
Normal 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.
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
parseNodeClipboardPayload,
|
||||
} from './nodeClipboard';
|
||||
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
|
||||
import {
|
||||
cycleTheme,
|
||||
getStoredTheme,
|
||||
resolveTheme,
|
||||
subscribeTheme,
|
||||
type Theme,
|
||||
} from './theme';
|
||||
import {
|
||||
serializeExecutionGraph,
|
||||
getAutoRunnableNodes,
|
||||
@@ -162,7 +169,7 @@ function restoreGroupEdges(edges: any[], groupId: string) {
|
||||
function Flow() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<TonoNode>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<TonoEdge>([]);
|
||||
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
|
||||
const [status, setStatus] = useState<{ text: string; level: string; progress?: number | null }>({ text: 'Connecting…', level: 'info' });
|
||||
const [contextMenu, setContextMenu] = useState<any>(null);
|
||||
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
|
||||
const [executingNodeId, setExecutingNodeId] = useState<string | null>(null);
|
||||
@@ -171,6 +178,15 @@ function Flow() {
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuClosing, setMenuClosing] = useState(false);
|
||||
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme());
|
||||
const resolvedTheme = resolveTheme(theme);
|
||||
useEffect(() => {
|
||||
return subscribeTheme((next) => setThemeState(next));
|
||||
}, []);
|
||||
const onCycleTheme = useCallback(() => {
|
||||
const next = cycleTheme();
|
||||
setThemeState(next);
|
||||
}, []);
|
||||
const closeMenu = useCallback(() => {
|
||||
if (!menuOpen || menuClosing) return;
|
||||
setMenuClosing(true);
|
||||
@@ -967,9 +983,17 @@ function Flow() {
|
||||
setStatus({
|
||||
text: `Uploading ${entry.file.name}…`,
|
||||
level: 'info',
|
||||
progress: 0,
|
||||
});
|
||||
|
||||
const uploaded = await api.uploadFile(entry.file, { relativePath: entry.relativePath });
|
||||
const uploaded = await api.uploadFile(entry.file, {
|
||||
relativePath: entry.relativePath,
|
||||
onProgress: (pct) => setStatus({
|
||||
text: `Uploading ${entry.file.name}… ${Math.round(pct * 100)}%`,
|
||||
level: 'info',
|
||||
progress: pct,
|
||||
}),
|
||||
});
|
||||
return uploaded.path;
|
||||
}, []);
|
||||
|
||||
@@ -1036,11 +1060,27 @@ function Flow() {
|
||||
}
|
||||
}
|
||||
|
||||
const total = toUpload.size;
|
||||
let index = 0;
|
||||
for (const uri of toUpload) {
|
||||
const file = pending.get(uri)!;
|
||||
const relativePath = uri.replace(/^session:\/\/uploads\//, '');
|
||||
await api.uploadFile(file, { relativePath });
|
||||
const fileIndex = index;
|
||||
setStatus({
|
||||
text: `Uploading ${file.name} (${fileIndex + 1}/${total})…`,
|
||||
level: 'info',
|
||||
progress: fileIndex / total,
|
||||
});
|
||||
await api.uploadFile(file, {
|
||||
relativePath,
|
||||
onProgress: (pct) => setStatus({
|
||||
text: `Uploading ${file.name} (${fileIndex + 1}/${total})… ${Math.round(pct * 100)}%`,
|
||||
level: 'info',
|
||||
progress: (fileIndex + pct) / total,
|
||||
}),
|
||||
});
|
||||
pending.delete(uri);
|
||||
index++;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1441,32 +1481,56 @@ function Flow() {
|
||||
initializeDynamicNodes(hydrated.nodes);
|
||||
}, [initializeDynamicNodes, setNodes, setEdges]);
|
||||
|
||||
const frameWorkflowViewport = useCallback(() => {
|
||||
const flowEl = document.querySelector('.react-flow') as HTMLElement | null;
|
||||
if (!flowEl) return;
|
||||
const width = flowEl.offsetWidth;
|
||||
const height = flowEl.offsetHeight;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
const allNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||
if (allNodes.length === 0) return;
|
||||
const bounds = getRenderedNodeBounds(allNodes);
|
||||
if (!bounds) return;
|
||||
const vp = getViewportForBounds(bounds, width, height, 0.5, 1, 0.1);
|
||||
reactFlow.setViewport(vp, { duration: 300 });
|
||||
}, [reactFlow]);
|
||||
|
||||
const scheduleFrameWorkflowViewport = useCallback(() => {
|
||||
// Two rAFs so ReactFlow has rendered and measured the freshly-applied nodes
|
||||
// before we read their DOM dimensions.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => frameWorkflowViewport()));
|
||||
}, [frameWorkflowViewport]);
|
||||
|
||||
const applyMaybePackedWorkflow = useCallback(async (data: any) => {
|
||||
if (data.packed && data.packedFiles) {
|
||||
setStatus({ text: 'Unpacking files…', level: 'info' });
|
||||
try {
|
||||
const { workflow, restoredPaths } = await unpackWorkflow(data);
|
||||
applyWorkflowData(workflow, { preservedPaths: restoredPaths });
|
||||
scheduleFrameWorkflowViewport();
|
||||
// Auto-run after packed workflow loads so all previews populate
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => scheduleAutoRun()));
|
||||
} catch {
|
||||
// Unpack failed (e.g. stale session) — load the workflow without file restoration
|
||||
const { packedFiles: _, packed: __, ...cleanWorkflow } = data;
|
||||
applyWorkflowData(cleanWorkflow);
|
||||
scheduleFrameWorkflowViewport();
|
||||
setStatus({ text: 'Workflow loaded but packed files could not be restored. Re-browse your input files.', level: 'error' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
applyWorkflowData(data);
|
||||
scheduleFrameWorkflowViewport();
|
||||
}
|
||||
}, [applyWorkflowData, scheduleAutoRun]);
|
||||
}, [applyWorkflowData, scheduleAutoRun, scheduleFrameWorkflowViewport]);
|
||||
|
||||
const loadDefaultWorkflow = useCallback(async () => {
|
||||
if (defaultWorkflowLoadAttemptedRef.current) return;
|
||||
defaultWorkflowLoadAttemptedRef.current = true;
|
||||
|
||||
// Only auto-load the example workflow on first visit
|
||||
if (localStorage.getItem('tono_visited')) return;
|
||||
// First-visit gating is handled by the caller (see the mount useEffect
|
||||
// below) so we avoid a race with /help-docs, which also flips the
|
||||
// tono_visited flag and often resolves before api.getNodes().
|
||||
|
||||
const graphHasContent = () => {
|
||||
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||
@@ -1507,16 +1571,20 @@ function Flow() {
|
||||
// ── Load node definitions ───────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
// Capture first-visit state once at mount so the /help-docs fetch (which
|
||||
// sets tono_visited as a side effect) cannot race with the default-workflow
|
||||
// gate below. Both decisions must see the same value.
|
||||
const isFirstVisit = !localStorage.getItem('tono_visited');
|
||||
|
||||
api.getNodes().then((defs) => {
|
||||
nodeDefsRef.current = defs;
|
||||
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
|
||||
loadDefaultWorkflow();
|
||||
if (isFirstVisit) loadDefaultWorkflow();
|
||||
}).catch((err) => {
|
||||
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
|
||||
});
|
||||
|
||||
// Load any .md files from frontend/public/ as help tabs
|
||||
const isFirstVisit = !localStorage.getItem('tono_visited');
|
||||
fetch('/help-docs')
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((docs: any[]) => {
|
||||
@@ -1732,9 +1800,15 @@ function Flow() {
|
||||
input.onchange = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
setStatus({ text: 'Uploading plugin…', level: 'info' });
|
||||
setStatus({ text: 'Uploading plugin…', level: 'info', progress: 0 });
|
||||
try {
|
||||
await api.uploadPlugin(file);
|
||||
await api.uploadPlugin(file, {
|
||||
onProgress: (pct) => setStatus({
|
||||
text: `Uploading plugin… ${Math.round(pct * 100)}%`,
|
||||
level: 'info',
|
||||
progress: pct,
|
||||
}),
|
||||
});
|
||||
// Node list refresh is handled by the nodes_updated WebSocket message.
|
||||
} catch (err: any) {
|
||||
setStatus({ text: err.message, level: 'error' });
|
||||
@@ -2239,10 +2313,15 @@ function Flow() {
|
||||
return () => document.removeEventListener('pointerdown', handler);
|
||||
}, [menuOpen]);
|
||||
|
||||
// Auto-dismiss status toast after 5 seconds with close animation
|
||||
// Auto-dismiss status toast after 5 seconds with close animation.
|
||||
// Uploads in progress (progress < 1) pause the timer so the bar stays visible.
|
||||
const [toastClosing, setToastClosing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!status.text) return;
|
||||
if (status.progress != null && status.progress < 1) {
|
||||
setToastClosing(false);
|
||||
return;
|
||||
}
|
||||
setToastClosing(false);
|
||||
const fadeTimer = setTimeout(() => setToastClosing(true), 4700);
|
||||
const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000);
|
||||
@@ -2492,6 +2571,13 @@ function Flow() {
|
||||
<button className="btn" onClick={() => { openDocByFilename('getting-started.md'); closeMenu(); }} title="Getting started guide">
|
||||
? Help
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={onCycleTheme}
|
||||
title={`Theme: ${theme} (click to cycle auto → light → dark)`}
|
||||
>
|
||||
{theme === 'auto' ? '◐' : theme === 'light' ? '☀' : '☾'} Theme: {theme}
|
||||
</button>
|
||||
<a className="btn" href="https://github.com/VIPQualityPost/tono/issues" target="_blank" rel="noopener noreferrer" onClick={closeMenu} title="Report a bug or request a feature">
|
||||
↗ Feedback
|
||||
</a>
|
||||
@@ -2512,7 +2598,17 @@ function Flow() {
|
||||
|
||||
{/* Status toast */}
|
||||
{(status.text || toastClosing) && (
|
||||
<div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>{status.text}</div>
|
||||
<div className={`status-toast ${status.level}${toastClosing ? ' closing' : ''}`}>
|
||||
<div className="status-toast-text">{status.text}</div>
|
||||
{status.progress != null && (
|
||||
<div className="status-toast-progress">
|
||||
<div
|
||||
className="status-toast-progress-fill"
|
||||
style={{ width: `${Math.max(0, Math.min(1, status.progress)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* React Flow canvas */}
|
||||
@@ -2538,7 +2634,7 @@ function Flow() {
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
colorMode="dark"
|
||||
colorMode={resolvedTheme}
|
||||
panOnDrag={[1]}
|
||||
panOnScroll
|
||||
panOnScrollSpeed={1.5}
|
||||
|
||||
@@ -2,6 +2,10 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { socketSpecAcceptsType } from './constants';
|
||||
import { outputTypeCanConnectToTarget } from './connectionUtils';
|
||||
import { compareMenuNodes, compareMenuCategories } from './canvasEvents';
|
||||
import { useFavorites } from './favorites';
|
||||
import { recordUsage, pickWeightedRandom } from './nodeUsage';
|
||||
|
||||
const FAVORITES_CATEGORY = 'favorites';
|
||||
|
||||
export default function ContextMenu({
|
||||
x,
|
||||
@@ -26,6 +30,7 @@ export default function ContextMenu({
|
||||
selectedNodeCount?: number;
|
||||
onCreateGroup?: (() => void) | null;
|
||||
}) {
|
||||
const favorites = useFavorites();
|
||||
const [openCat, setOpenCat] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
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) => ({
|
||||
...category,
|
||||
items: [...category.items].sort(compareMenuNodes),
|
||||
}))
|
||||
.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
|
||||
const searchResults = useMemo(() => {
|
||||
@@ -191,6 +214,29 @@ export default function ContextMenu({
|
||||
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) {
|
||||
return (
|
||||
<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"
|
||||
onClick={() => { onCreateGroup(); onClose(); }}
|
||||
>
|
||||
create group
|
||||
Create Group
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -248,7 +294,7 @@ export default function ContextMenu({
|
||||
key={className}
|
||||
ref={idx === selectedIndex ? selectedItemRef : null}
|
||||
className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`}
|
||||
onClick={() => { onAdd(className, def); onClose(); }}
|
||||
onClick={() => { handleAdd(className, def); onClose(); }}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
{def.display_name || className}
|
||||
@@ -262,13 +308,22 @@ export default function ContextMenu({
|
||||
<div
|
||||
key={cat}
|
||||
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)}
|
||||
>
|
||||
<span className="ctx-cat-label">{cat}</span>
|
||||
<span className="ctx-cat-label">
|
||||
{cat === FAVORITES_CATEGORY ? 'Favorites' : cat}
|
||||
</span>
|
||||
<span className="ctx-cat-arrow">▶</span>
|
||||
</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>
|
||||
@@ -290,7 +345,7 @@ export default function ContextMenu({
|
||||
<div
|
||||
key={className}
|
||||
className="context-item"
|
||||
onClick={() => { onAdd(className, def); onClose(); }}
|
||||
onClick={() => { handleAdd(className, def); onClose(); }}
|
||||
>
|
||||
{def.display_name || className}
|
||||
</div>
|
||||
|
||||
@@ -13,15 +13,40 @@ interface CropBoxOverlayProps {
|
||||
bLocked: boolean;
|
||||
nodeId: string;
|
||||
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({
|
||||
image, x1, y1, x2, y2,
|
||||
aLocked, bLocked,
|
||||
nodeId, onWidgetChange,
|
||||
square = false,
|
||||
xreal = 1,
|
||||
yreal = 1,
|
||||
}: CropBoxOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(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>) => {
|
||||
return pointerToFraction(e, containerRef.current!);
|
||||
@@ -30,28 +55,66 @@ export default function CropBoxOverlay({
|
||||
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||
if (point === 'p1' && aLocked) return;
|
||||
if (point === 'p2' && bLocked) return;
|
||||
if (point === 'rect' && (aLocked || bLocked)) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
(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);
|
||||
}, [aLocked, bLocked]);
|
||||
}, [aLocked, bLocked, getCoords, x1, y1, x2, y2]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(e);
|
||||
const vx = parseFloat(fx.toFixed(3));
|
||||
const vy = parseFloat(fy.toFixed(3));
|
||||
if (dragging === 'p1') {
|
||||
onWidgetChange(nodeId, 'x1', vx);
|
||||
onWidgetChange(nodeId, 'y1', vy);
|
||||
} else {
|
||||
onWidgetChange(nodeId, 'x2', vx);
|
||||
onWidgetChange(nodeId, 'y2', vy);
|
||||
|
||||
if (dragging === 'rect') {
|
||||
const start = panStartRef.current;
|
||||
if (!start) return;
|
||||
const left = Math.min(start.x1, start.x2);
|
||||
const right = Math.max(start.x1, start.x2);
|
||||
const top = Math.min(start.y1, start.y2);
|
||||
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(() => {
|
||||
setDragging(null);
|
||||
panStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
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-rect"
|
||||
className={`crop-rect ${aLocked || bLocked ? 'crop-rect-locked' : ''}`}
|
||||
style={{
|
||||
left: `${left * 100}%`,
|
||||
top: `${top * 100}%`,
|
||||
width: `${(right - left) * 100}%`,
|
||||
height: `${(bottom - top) * 100}%`,
|
||||
}}
|
||||
onPointerDown={onPointerDown('rect')}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -12,6 +12,10 @@ const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
|
||||
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
||||
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
||||
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';
|
||||
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
import { getGroupMinimumSize } from './groupSizing';
|
||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
|
||||
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';
|
||||
|
||||
@@ -215,6 +220,7 @@ function GroupNode({ id, data }: { id: string; data: NodeData }) {
|
||||
id={input.handleId}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }}
|
||||
isConnectableStart={false}
|
||||
/>
|
||||
<span className="io-label">{formatUiLabel(input.label || input.name)}</span>
|
||||
</>
|
||||
@@ -997,6 +1003,7 @@ function NodeTable({ rows }: { rows: Array<Record<string, unknown>> }) {
|
||||
function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
const ctx = useContext(NodeContext);
|
||||
const def = data.definition;
|
||||
const favorited = useIsFavorite(data.className);
|
||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||
const nodeWidth = useStore(
|
||||
@@ -1109,11 +1116,24 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const [type, opts] = getSpecTypeAndOptions(spec as InputSpec);
|
||||
if (isProgressive && isDataSocketSpec(spec as InputSpec)) {
|
||||
// Progressive: show this slot only if it's the first or the previous is connected
|
||||
// Progressive: show this slot only if it's the first or the previous
|
||||
// is connected. If the socket also carries `show_when_source_type`,
|
||||
// the gating input must be connected to a matching source type before
|
||||
// any slot in the chain is revealed — this lets Save's layer stack
|
||||
// stay hidden until `value` is a DataField or Image.
|
||||
const match = name.match(/^field_(\d+)$/);
|
||||
if (match) {
|
||||
const idx = parseInt(match[1], 10);
|
||||
if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) {
|
||||
const sourceTypeRules = opts?.show_when_source_type;
|
||||
let sourceTypeOk = true;
|
||||
if (sourceTypeRules && typeof sourceTypeRules === 'object') {
|
||||
const gateInput = Object.keys(sourceTypeRules)[0];
|
||||
const allowed = Array.isArray(sourceTypeRules[gateInput]) ? sourceTypeRules[gateInput] : [];
|
||||
const actualSourceType = connectedSourceTypes?.[gateInput] ?? null;
|
||||
sourceTypeOk = actualSourceType != null && allowed.includes(actualSourceType);
|
||||
}
|
||||
const progressiveOk = idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`));
|
||||
if (sourceTypeOk && progressiveOk) {
|
||||
dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) });
|
||||
visibleInputNames.add(name);
|
||||
}
|
||||
@@ -1180,6 +1200,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
|| data.overlay.kind === 'mask_paint'
|
||||
|| data.overlay.kind === 'markup'
|
||||
|| 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 overlayTitle = data.overlay?.section_title
|
||||
@@ -1195,6 +1219,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
? 'Cursors'
|
||||
: data.overlay?.kind === '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');
|
||||
const headerMeta = (() => {
|
||||
if (data.className === 'Folder') {
|
||||
@@ -1218,6 +1250,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
<div className="node-title-left">
|
||||
<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-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>
|
||||
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
|
||||
</div>
|
||||
@@ -1239,6 +1279,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
id={`input::${socketName}::${socketType}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }}
|
||||
isConnectableStart={false}
|
||||
/>
|
||||
)}
|
||||
{(
|
||||
@@ -1278,6 +1319,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
id={`input::${inp.name}::${inp.type}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[inp.type as string] || 'var(--fallback-type)' }}
|
||||
isConnectableStart={false}
|
||||
/>
|
||||
<span className="io-label">{inp.label || inp.name}</span>
|
||||
{inlineWidgetsByInput.has(inp.name) && (
|
||||
@@ -1351,6 +1393,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
id={`input::${socketName}::${socketType}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[socketType as string] || 'var(--fallback-type)' }}
|
||||
isConnectableStart={false}
|
||||
/>
|
||||
)}
|
||||
{(w.socketType && connectedInputs?.has(w.name))
|
||||
@@ -1387,6 +1430,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
{/* Interactive 3D surface view */}
|
||||
{!!data.meshData && (
|
||||
<CollapsibleSection title="3D View" defaultOpen={true}>
|
||||
<PreviewBoundary resetKey={String(data.meshData)}>
|
||||
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading 3D...</div>}>
|
||||
<SurfaceView
|
||||
meshData={data.meshData as any}
|
||||
@@ -1396,6 +1440,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
onRuntimeValuesChange={ctx?.onRuntimeValuesChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</PreviewBoundary>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -1482,6 +1527,9 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
bLocked={!!data.overlay!.b_locked}
|
||||
nodeId={id}
|
||||
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' ? (
|
||||
<CrossSectionOverlay
|
||||
@@ -1524,6 +1572,43 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
nodeId={id}
|
||||
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' ? (
|
||||
<AngleMeasureOverlay
|
||||
image={data.overlay!.image ?? ''}
|
||||
|
||||
89
frontend/src/MultiProfileOverlay.tsx
Normal file
89
frontend/src/MultiProfileOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/PerspectiveOverlay.tsx
Normal file
150
frontend/src/PerspectiveOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/RadialProfileOverlay.tsx
Normal file
125
frontend/src/RadialProfileOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
frontend/src/StraightenPathOverlay.tsx
Normal file
213
frontend/src/StraightenPathOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -133,6 +133,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
const pointerEnteredAtRef = useRef(0);
|
||||
const lastWheelAtRef = useRef(0);
|
||||
const gestureStartedInsideRef = useRef(false);
|
||||
const scheduleViewportSyncRef = useRef<(delay?: number, scheduleRun?: boolean) => void>(() => {});
|
||||
const updateDiagnosticsRef = useRef<(patch: Partial<DiagnosticsState>) => void>(() => {});
|
||||
const [diagnostics, setDiagnostics] = useState<DiagnosticsState>({
|
||||
status: meshData ? 'initializing' : 'waiting for mesh',
|
||||
webgl: 'pending',
|
||||
@@ -239,6 +241,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
scheduleViewportSync(0, true);
|
||||
}, [applyCameraState, scheduleViewportSync]);
|
||||
|
||||
scheduleViewportSyncRef.current = scheduleViewportSync;
|
||||
updateDiagnosticsRef.current = updateDiagnostics;
|
||||
|
||||
// Initialize Three.js scene once
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -256,8 +261,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setClearColor(0x0f172a);
|
||||
container.appendChild(renderer.domElement);
|
||||
updateDiagnostics({
|
||||
status: meshData ? 'renderer ready' : 'waiting for mesh',
|
||||
updateDiagnosticsRef.current({
|
||||
status: 'renderer ready',
|
||||
webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`,
|
||||
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}`,
|
||||
@@ -266,13 +271,13 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
updateDiagnostics({
|
||||
updateDiagnosticsRef.current({
|
||||
status: 'webgl context lost',
|
||||
error: 'WebGL context lost',
|
||||
});
|
||||
};
|
||||
const handleContextRestored = () => {
|
||||
updateDiagnostics({
|
||||
updateDiagnosticsRef.current({
|
||||
status: 'webgl context restored',
|
||||
error: '',
|
||||
});
|
||||
@@ -303,7 +308,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
TWO: THREE.TOUCH.DOLLY_ROTATE,
|
||||
};
|
||||
renderer.domElement.style.touchAction = 'none';
|
||||
const handleControlsEnd = () => scheduleViewportSync(120, true);
|
||||
const handleControlsEnd = () => scheduleViewportSyncRef.current(120, true);
|
||||
controls.addEventListener('end', handleControlsEnd);
|
||||
|
||||
// Lighting
|
||||
@@ -341,7 +346,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
r.setSize(w, w);
|
||||
c.aspect = 1;
|
||||
c.updateProjectionMatrix();
|
||||
updateDiagnostics({
|
||||
updateDiagnosticsRef.current({
|
||||
canvas: `${r.domElement.width}x${r.domElement.height} px`,
|
||||
});
|
||||
});
|
||||
@@ -361,7 +366,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
}
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, [scheduleViewportSync, updateDiagnostics]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [applyCameraState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshData) {
|
||||
|
||||
@@ -56,6 +56,34 @@ async function sessionFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, withSessionHeaders(init));
|
||||
}
|
||||
|
||||
/**
|
||||
* XHR wrapper used for file uploads. Unlike fetch(), XHR exposes upload
|
||||
* progress events, which the toast UI uses to draw a progress bar.
|
||||
*/
|
||||
function xhrRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: XMLHttpRequestBodyInit | null,
|
||||
{
|
||||
headers = {},
|
||||
onProgress,
|
||||
}: { headers?: Record<string, string>; onProgress?: (fraction: number) => void } = {},
|
||||
): Promise<{ status: number; text: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
|
||||
if (onProgress && xhr.upload) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||
};
|
||||
}
|
||||
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText });
|
||||
xhr.onerror = () => reject(new Error('Network error'));
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getNodes() {
|
||||
const r = await sessionFetch('/nodes');
|
||||
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
|
||||
@@ -84,30 +112,40 @@ export async function createUploadFolder(relativePath: string) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, { relativePath = '' } = {}) {
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
{
|
||||
relativePath = '',
|
||||
onProgress,
|
||||
}: { relativePath?: string; onProgress?: (fraction: number) => void } = {},
|
||||
) {
|
||||
const fd = new FormData();
|
||||
if (relativePath) fd.append('relative_path', relativePath);
|
||||
fd.append('file', file);
|
||||
const r = await sessionFetch('/upload', { method: 'POST', body: fd });
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
throw new Error(`Upload failed (${r.status}): ${text}`);
|
||||
const { status, text } = await xhrRequest('POST', '/upload', fd, {
|
||||
headers: { 'X-Argonode-Session': getSessionId() },
|
||||
onProgress,
|
||||
});
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(`Upload failed (${status}): ${text}`);
|
||||
}
|
||||
return r.json();
|
||||
try { return JSON.parse(text); } catch { return {}; }
|
||||
}
|
||||
|
||||
export async function uploadPlugin(file: File) {
|
||||
export async function uploadPlugin(
|
||||
file: File,
|
||||
{ onProgress }: { onProgress?: (fraction: number) => void } = {},
|
||||
) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch('/upload-plugin', { method: 'POST', body: fd });
|
||||
if (r.status === 404) {
|
||||
const { status, text } = await xhrRequest('POST', '/upload-plugin', fd, { onProgress });
|
||||
if (status === 404) {
|
||||
throw new Error('Plugin upload is not available in this build.');
|
||||
}
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
throw new Error(text || `Upload failed (${r.status})`);
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(text || `Upload failed (${status})`);
|
||||
}
|
||||
return r.json();
|
||||
try { return JSON.parse(text); } catch { return {}; }
|
||||
}
|
||||
|
||||
export async function getChannels(filepath: string) {
|
||||
|
||||
@@ -11,34 +11,34 @@ export const DATA_TYPES = new Set([
|
||||
export const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
|
||||
|
||||
export const TYPE_COLORS: Record<string, string> = {
|
||||
DATA_FIELD: '#3a7abf',
|
||||
IMAGE: '#00ff08a0',
|
||||
LINE: '#ffbe5c',
|
||||
RECORD_TABLE: '#35e2fd',
|
||||
DATA_TABLE: '#ff7474',
|
||||
COORD: '#e91ed1',
|
||||
COORDPAIR: '#5cb861',
|
||||
FLOAT: '#ab3197',
|
||||
INT: '#ffffff',
|
||||
ANNOTATION_SOURCE: '#06b6d4',
|
||||
COLORMAP: '#f472b6',
|
||||
MESH_MODEL: '#14b8a6',
|
||||
FONT: '#fb7185',
|
||||
FILE_PATH: '#f59e0b',
|
||||
DIRECTORY: '#f97316',
|
||||
DATA_FIELD: '#7d8bdc',
|
||||
IMAGE: '#69cc6c',
|
||||
LINE: '#ffb300',
|
||||
RECORD_TABLE: '#cf6868',
|
||||
DATA_TABLE: '#cbcd67',
|
||||
COORD: '#bb65c2',
|
||||
COORDPAIR: '#bababa',
|
||||
FLOAT: '#76bcd4',
|
||||
INT: '#cf8e8e',
|
||||
ANNOTATION_SOURCE: '#79cab6',
|
||||
COLORMAP: '#905454',
|
||||
MESH_MODEL: '#6e659e',
|
||||
FONT: '#cccf7f',
|
||||
FILE_PATH: '#b87f7f',
|
||||
DIRECTORY: '#90d294',
|
||||
};
|
||||
|
||||
export const CAT_COLORS: Record<string, string> = {
|
||||
Input: '#37474f',
|
||||
Display: '#212121',
|
||||
Overlay: '#0f766e',
|
||||
Geometry: '#0d9488',
|
||||
Filter: '#1a237e',
|
||||
Spectral: '#4c1d95',
|
||||
'Level & Correct': '#1b5e20',
|
||||
Measure: '#4a148c',
|
||||
Mask: '#7c2d12',
|
||||
Grains: '#bf360c',
|
||||
Input: '#2c4b31',
|
||||
Display: '#5f4e35',
|
||||
Overlay: '#214844',
|
||||
Geometry: '#3c2a46',
|
||||
Filter: '#34375a',
|
||||
Spectral: '#5f4938',
|
||||
'Level & Correct': '#553636',
|
||||
Measure: '#382f43',
|
||||
Mask: '#4d3c2a',
|
||||
Grains: '#5a4703',
|
||||
};
|
||||
|
||||
export const SOCKET_COMPATIBILITY: Record<string, Set<string>> = {
|
||||
|
||||
68
frontend/src/favorites.ts
Normal file
68
frontend/src/favorites.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initTheme } from './theme';
|
||||
import './styles.css';
|
||||
|
||||
initTheme();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
|
||||
46
frontend/src/nodeUsage.ts
Normal file
46
frontend/src/nodeUsage.ts
Normal 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];
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
/* ── Theme tokens ──────────────────────────────────────────────────── */
|
||||
/*
|
||||
* The default :root block defines the dark palette. A light override
|
||||
* lives in :root[data-theme="light"] below. The active theme is selected
|
||||
* at runtime by theme.ts, which sets data-theme on <html> to either
|
||||
* "light" or "dark" (auto mode resolves via prefers-color-scheme).
|
||||
*/
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--bg-app: #1a1a2e;
|
||||
@@ -22,6 +28,7 @@
|
||||
--text-primary: #e0e0e0;
|
||||
--text-bright: #e2e8f0;
|
||||
--text-heading: #ffffff;
|
||||
--text-node-title: #ffffff;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-faint: #475569;
|
||||
@@ -45,8 +52,13 @@
|
||||
--danger: #e94560;
|
||||
--danger-hover: #ff6b81;
|
||||
--danger-locked: #e91e63;
|
||||
--danger-outline: #ef4444;
|
||||
--danger-outline-glow: rgba(239, 68, 68, 0.35);
|
||||
--error-text: #ef9a9a;
|
||||
--error-bg: rgba(183, 28, 28, 0.2);
|
||||
--error-text-2: #fca5a5;
|
||||
--error-bg-2: rgba(239, 68, 68, 0.12);
|
||||
--error-border-2: rgba(239, 68, 68, 0.3);
|
||||
|
||||
/* Warning */
|
||||
--warning: #fbbf24;
|
||||
@@ -120,6 +132,212 @@
|
||||
/* Dynamic-lookup fallbacks */
|
||||
--fallback-type: #999;
|
||||
--fallback-cat: #333;
|
||||
|
||||
/* Group node extras */
|
||||
--bg-group-title: #334155;
|
||||
--bg-overlay-input: rgba(15, 23, 42, 0.7);
|
||||
--text-watermark: rgba(148, 163, 184, 0.58);
|
||||
|
||||
/* Help panel */
|
||||
--bg-help-tabs: #0a0f1a;
|
||||
--bg-help-tab: #0f172a;
|
||||
--bg-help-tab-hover: #162032;
|
||||
--bg-help-tab-active: #1e293b;
|
||||
--bg-help-panel: #1e293b;
|
||||
--bg-help-textarea: #0d1624;
|
||||
--border-help-tab-add:#334155;
|
||||
--link-color: #ff9800;
|
||||
--link-hover: #ffb74d;
|
||||
|
||||
/* Canvas overlay chips (badges, pills, floating widgets) */
|
||||
--overlay-chip-bg: rgba(15, 23, 42, 0.86);
|
||||
--overlay-chip-bg-strong: rgba(15, 23, 42, 0.9);
|
||||
--overlay-chip-bg-hover: rgba(30, 41, 59, 0.94);
|
||||
--overlay-chip-border: rgba(148, 163, 184, 0.42);
|
||||
--overlay-chip-border-hover: rgba(125, 211, 252, 0.55);
|
||||
--overlay-chip-shadow: rgba(2, 6, 23, 0.28);
|
||||
|
||||
/* Node-title help button (sits on category-coloured title bar) */
|
||||
--node-help-btn-bg: rgba(255, 255, 255, 0.12);
|
||||
--node-help-btn-bg-hover: rgba(255, 255, 255, 0.28);
|
||||
--node-help-btn-border: rgba(255, 255, 255, 0.25);
|
||||
--node-help-btn-border-hover:rgba(255, 255, 255, 0.5);
|
||||
--node-help-btn-text: rgba(255, 255, 255, 0.75);
|
||||
|
||||
/* Text note content overlays (on top of coloured text notes) */
|
||||
--note-code-bg: rgba(0, 0, 0, 0.3);
|
||||
--note-textarea-bg: rgba(0, 0, 0, 0.25);
|
||||
--note-hr: rgba(255, 255, 255, 0.1);
|
||||
--note-quote-border: rgba(255, 255, 255, 0.2);
|
||||
--note-active-ring: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Light palette — applied when <html data-theme="light"> */
|
||||
:root[data-theme="light"] {
|
||||
/* Backgrounds — warm palette: eggshell canvas, tan node bodies */
|
||||
--bg-app: #e8e0c6;
|
||||
--bg-toolbar: #ede5cc;
|
||||
--bg-canvas: #f0e9d4; /* eggshell */
|
||||
--bg-surface: #e4d6b4; /* tan (node body) */
|
||||
--bg-deep: #d4c29e; /* deeper tan (widget wells) */
|
||||
--bg-panel: #e0d7bc;
|
||||
--bg-backdrop: rgba(60, 50, 20, 0.35);
|
||||
--bg-overlay-dim: rgba(60, 50, 20, 0.18);
|
||||
|
||||
/* Borders — warm tan-gray to blend with the warm surfaces */
|
||||
--border-default: #b8ab87;
|
||||
--border-strong: #2563eb;
|
||||
--border-toolbar: #b8ab87;
|
||||
--border-subtle: rgba(120, 105, 70, 0.28);
|
||||
--border-table: rgba(120, 105, 70, 0.55);
|
||||
--border-title: rgba(60, 50, 20, 0.2);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #2a2318;
|
||||
--text-bright: #1a1710;
|
||||
--text-heading: #1a1710; /* for text on tan/eggshell surfaces */
|
||||
--text-node-title: #ffffff; /* stays white — sits on colored title bars */
|
||||
--text-secondary: #5a4e36;
|
||||
--text-muted: #726544;
|
||||
--text-faint: #9c8f6a;
|
||||
--text-disabled: #9c8f6a;
|
||||
--text-table: #3a301c;
|
||||
--text-value: #3e2e10;
|
||||
--text-value-unit: rgba(62, 46, 16, 0.78);
|
||||
|
||||
/* Accent */
|
||||
--accent: #2563eb;
|
||||
--accent-bg: #dbeafe;
|
||||
--accent-hover: #bfdbfe;
|
||||
--accent-pressed: #93c5fd;
|
||||
--accent-light: #1d4ed8;
|
||||
--accent-lighter: #0284c7;
|
||||
--accent-lightest: #0369a1;
|
||||
--accent-deep: #eff6ff;
|
||||
--accent-deep-text:#1e3a8a;
|
||||
|
||||
/* Danger */
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #ef4444;
|
||||
--danger-locked: #be123c;
|
||||
--danger-outline: #dc2626;
|
||||
--danger-outline-glow: rgba(220, 38, 38, 0.35);
|
||||
--error-text: #991b1b;
|
||||
--error-bg: rgba(220, 38, 38, 0.12);
|
||||
--error-text-2: #b91c1c;
|
||||
--error-bg-2: rgba(220, 38, 38, 0.1);
|
||||
--error-border-2: rgba(220, 38, 38, 0.35);
|
||||
|
||||
/* Warning */
|
||||
--warning: #b45309;
|
||||
--warning-bg: rgba(251, 191, 36, 0.2);
|
||||
--warning-border: rgba(180, 83, 9, 0.35);
|
||||
|
||||
/* Selection */
|
||||
--selection: #2563eb;
|
||||
--selection-glow: rgba(37, 99, 235, 0.3);
|
||||
--selection-edge: rgba(37, 99, 235, 0.55);
|
||||
|
||||
/* Marker / cursor */
|
||||
--marker: #ca8a04;
|
||||
--marker-active: #eab308;
|
||||
--marker-border: #1a1710;
|
||||
--marker-shadow: rgba(60, 50, 20, 0.4);
|
||||
--marker-shadow-light: rgba(60, 50, 20, 0.25);
|
||||
|
||||
/* Plot */
|
||||
--plot-line: #ea580c;
|
||||
|
||||
/* Linked state */
|
||||
--linked-border: rgba(190, 24, 93, 0.55);
|
||||
--linked-bg: rgba(251, 232, 220, 0.85);
|
||||
--linked-text: #9d174d;
|
||||
|
||||
/* Value display */
|
||||
--value-label: #3e2e10;
|
||||
--value-border: rgba(62, 46, 16, 0.45);
|
||||
--value-grad-top: rgba(120, 85, 30, 0.08);
|
||||
--value-grad-bot: rgba(80, 60, 20, 0.14);
|
||||
--value-grad-a: rgba(160, 120, 50, 0.08);
|
||||
--value-grad-b: rgba(200, 160, 80, 0.05);
|
||||
--value-shadow-in: rgba(255, 248, 220, 0.6);
|
||||
--value-shadow: rgba(80, 60, 20, 0.12);
|
||||
|
||||
/* Node title meta */
|
||||
--meta-bg: rgba(60, 50, 20, 0.1);
|
||||
--meta-text: rgba(255, 255, 255, 0.92);
|
||||
|
||||
/* Mask paint cursor */
|
||||
--mask-cursor-border: rgba(60, 50, 20, 0.9);
|
||||
--mask-cursor-bg: rgba(60, 50, 20, 0.08);
|
||||
--mask-cursor-ring: rgba(220, 38, 38, 0.85);
|
||||
--mask-cursor-shadow: rgba(184, 171, 135, 0.4);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-heavy: rgba(60, 50, 20, 0.22);
|
||||
|
||||
/* Gallery */
|
||||
--gallery-name-border: rgba(184, 171, 135, 0.8);
|
||||
--gallery-name-bg: rgba(240, 233, 212, 0.92);
|
||||
|
||||
/* Benchmark */
|
||||
--benchmark-border: rgba(120, 105, 70, 0.32);
|
||||
--benchmark-bg: rgba(245, 238, 219, 0.94);
|
||||
|
||||
/* Markup toolbar */
|
||||
--markup-btn-border: rgba(120, 105, 70, 0.38);
|
||||
--markup-btn-bg: rgba(245, 238, 219, 0.94);
|
||||
|
||||
/* Table */
|
||||
--table-stripe: rgba(120, 105, 70, 0.15);
|
||||
|
||||
/* Crop */
|
||||
--crop-inset: rgba(60, 50, 20, 0.28);
|
||||
|
||||
/* Shape default */
|
||||
--shape-default: #dc2626;
|
||||
|
||||
/* Dynamic-lookup fallbacks */
|
||||
--fallback-type: #9c8f6a;
|
||||
--fallback-cat: #b8ab87;
|
||||
|
||||
/* Group node extras */
|
||||
--bg-group-title: #b8ab87;
|
||||
--bg-overlay-input: rgba(240, 233, 212, 0.85);
|
||||
--text-watermark: rgba(90, 78, 54, 0.55);
|
||||
|
||||
/* Help panel */
|
||||
--bg-help-tabs: #d4c29e;
|
||||
--bg-help-tab: #e0d3ad;
|
||||
--bg-help-tab-hover: #d4c29e;
|
||||
--bg-help-tab-active: #f0e9d4;
|
||||
--bg-help-panel: #f0e9d4;
|
||||
--bg-help-textarea: #e8dcc0;
|
||||
--border-help-tab-add:#b8ab87;
|
||||
--link-color: #c2410c;
|
||||
--link-hover: #9a3412;
|
||||
|
||||
/* Canvas overlay chips */
|
||||
--overlay-chip-bg: rgba(240, 233, 212, 0.94);
|
||||
--overlay-chip-bg-strong: rgba(240, 233, 212, 0.97);
|
||||
--overlay-chip-bg-hover: rgba(228, 214, 180, 0.96);
|
||||
--overlay-chip-border: rgba(120, 105, 70, 0.42);
|
||||
--overlay-chip-border-hover: rgba(194, 65, 12, 0.55);
|
||||
--overlay-chip-shadow: rgba(60, 50, 20, 0.16);
|
||||
|
||||
/* Node-title help button (still sits on a coloured title bar) */
|
||||
--node-help-btn-bg: rgba(255, 255, 255, 0.35);
|
||||
--node-help-btn-bg-hover: rgba(255, 255, 255, 0.55);
|
||||
--node-help-btn-border: rgba(255, 255, 255, 0.5);
|
||||
--node-help-btn-border-hover:rgba(255, 255, 255, 0.75);
|
||||
--node-help-btn-text: rgba(255, 255, 255, 0.9);
|
||||
|
||||
/* Text note content overlays */
|
||||
--note-code-bg: rgba(60, 50, 20, 0.1);
|
||||
--note-textarea-bg: rgba(60, 50, 20, 0.05);
|
||||
--note-hr: rgba(60, 50, 20, 0.14);
|
||||
--note-quote-border: rgba(60, 50, 20, 0.22);
|
||||
--note-active-ring: rgba(60, 50, 20, 0.6);
|
||||
}
|
||||
|
||||
/* ── Reset & base ──────────────────────────────────────────────────── */
|
||||
@@ -279,8 +497,12 @@ html, body, #root {
|
||||
background: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-toolbar);
|
||||
max-width: 60%;
|
||||
min-width: 240px;
|
||||
text-align: center;
|
||||
animation: toast-in 0.2s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.status-toast.closing {
|
||||
animation: toast-out 0.3s ease-in forwards;
|
||||
@@ -288,6 +510,19 @@ html, body, #root {
|
||||
.status-toast.info { color: var(--accent-light); }
|
||||
.status-toast.error { color: var(--error-text); background: var(--error-bg); }
|
||||
|
||||
.status-toast-progress {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: rgba(127, 127, 127, 0.22);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-toast-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-light);
|
||||
transition: width 0.12s linear;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
@@ -343,7 +578,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.group-node-title {
|
||||
background: #334155;
|
||||
background: var(--bg-group-title);
|
||||
}
|
||||
|
||||
.group-node-title .node-title-main {
|
||||
@@ -384,7 +619,7 @@ html, body, #root {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 4px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
background: var(--bg-overlay-input);
|
||||
color: var(--text-heading);
|
||||
font: inherit;
|
||||
}
|
||||
@@ -398,7 +633,7 @@ html, body, #root {
|
||||
|
||||
.group-toggle {
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
background: var(--bg-overlay-input);
|
||||
color: var(--text-heading);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
@@ -447,7 +682,7 @@ html, body, #root {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
color: rgba(148, 163, 184, 0.58);
|
||||
color: var(--text-watermark);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: lowercase;
|
||||
@@ -497,7 +732,7 @@ html, body, #root {
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text-heading);
|
||||
color: var(--text-node-title);
|
||||
border-radius: 5px 5px 0 0;
|
||||
border-bottom: 1px solid var(--border-title);
|
||||
}
|
||||
@@ -537,9 +772,9 @@ html, body, #root {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: var(--node-help-btn-bg);
|
||||
border: 1px solid var(--node-help-btn-border);
|
||||
color: var(--node-help-btn-text);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -553,8 +788,36 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.node-help-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
background: var(--node-help-btn-bg-hover);
|
||||
border-color: var(--node-help-btn-border-hover);
|
||||
}
|
||||
|
||||
.node-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 ─────────────────────────────────────── */
|
||||
@@ -564,15 +827,15 @@ html, body, #root {
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 6px 8px 0;
|
||||
background: #0a0f1a;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: var(--bg-help-tabs);
|
||||
border-bottom: 1px solid var(--bg-help-tab-active);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-help-fold-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
@@ -581,7 +844,7 @@ html, body, #root {
|
||||
align-self: center;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.node-help-fold-btn:hover { color: #f1f5f9; }
|
||||
.node-help-fold-btn:hover { color: var(--text-heading); }
|
||||
|
||||
.node-help-tab {
|
||||
display: flex;
|
||||
@@ -589,23 +852,23 @@ html, body, #root {
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
background: var(--bg-help-tab);
|
||||
border: 1px solid var(--bg-help-tab-active);
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.12s, background 0.12s;
|
||||
user-select: none;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.node-help-tab:hover { color: #94a3b8; background: #162032; }
|
||||
.node-help-tab:hover { color: var(--text-secondary); background: var(--bg-help-tab-hover); }
|
||||
|
||||
.node-help-tab.active {
|
||||
color: #f1f5f9;
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: var(--text-heading);
|
||||
background: var(--bg-help-tab-active);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.node-help-tab-label {
|
||||
@@ -630,8 +893,8 @@ html, body, #root {
|
||||
|
||||
.node-help-tab-add {
|
||||
background: none;
|
||||
border: 1px dashed #334155;
|
||||
color: #475569;
|
||||
border: 1px dashed var(--border-help-tab-add);
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
width: 22px;
|
||||
@@ -645,7 +908,7 @@ html, body, #root {
|
||||
flex-shrink: 0;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
.node-help-tab-add:hover { color: #f1f5f9; border-color: #64748b; }
|
||||
.node-help-tab-add:hover { color: var(--text-heading); border-color: var(--text-muted); }
|
||||
|
||||
.node-help-panel {
|
||||
position: fixed;
|
||||
@@ -653,10 +916,10 @@ html, body, #root {
|
||||
right: 20px;
|
||||
width: 620px;
|
||||
max-height: calc(100vh - 32px);
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
background: var(--bg-help-panel);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 8px 32px var(--shadow-heavy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 9999;
|
||||
@@ -676,33 +939,33 @@ html, body, #root {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
border-bottom: 1px solid var(--bg-help-tab-active);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-help-journal-toggle {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
background: var(--bg-help-tab);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
.node-help-journal-toggle:hover { color: #f1f5f9; border-color: #475569; }
|
||||
.node-help-journal-toggle:hover { color: var(--text-heading); border-color: var(--text-faint); }
|
||||
|
||||
.node-help-journal-hint {
|
||||
font-size: 10px;
|
||||
color: #475569;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.node-help-journal-textarea {
|
||||
flex: 1;
|
||||
background: #0d1624;
|
||||
background: var(--bg-help-textarea);
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #e2e8f0;
|
||||
color: var(--text-bright);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
@@ -718,7 +981,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.node-help-journal-placeholder {
|
||||
color: #475569;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -727,14 +990,14 @@ html, body, #root {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font-size: 12.5px;
|
||||
color: #cbd5e1;
|
||||
color: var(--text-table);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.node-help-panel-body h1,
|
||||
.node-help-panel-body h2,
|
||||
.node-help-panel-body h3 {
|
||||
color: #f1f5f9;
|
||||
color: var(--text-heading);
|
||||
margin: 14px 0 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -754,23 +1017,23 @@ html, body, #root {
|
||||
|
||||
.node-help-panel-body th,
|
||||
.node-help-panel-body td {
|
||||
border: 1px solid #334155;
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.node-help-panel-body th {
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
background: var(--bg-help-tab);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-help-panel-body code {
|
||||
background: #0f172a;
|
||||
background: var(--bg-help-tab);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: #7dd3fc;
|
||||
color: var(--accent-lighter);
|
||||
}
|
||||
|
||||
.node-help-panel-body ul,
|
||||
@@ -781,12 +1044,12 @@ html, body, #root {
|
||||
|
||||
.node-help-panel-body li { margin: 2px 0; }
|
||||
|
||||
.node-help-panel-body em { color: #94a3b8; }
|
||||
.node-help-panel-body em { color: var(--text-secondary); }
|
||||
|
||||
.node-help-panel-body strong { color: #e2e8f0; }
|
||||
.node-help-panel-body strong { color: var(--text-bright); }
|
||||
|
||||
.node-help-panel-body a { color: #ff9800; }
|
||||
.node-help-panel-body a:hover { color: #ffb74d; }
|
||||
.node-help-panel-body a { color: var(--link-color); }
|
||||
.node-help-panel-body a:hover { color: var(--link-hover); }
|
||||
|
||||
/* ── Help panel TOC + content layout ──────────────────────────────── */
|
||||
|
||||
@@ -806,10 +1069,10 @@ html, body, #root {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #1e293b;
|
||||
border-right: 1px solid var(--bg-help-tab-active);
|
||||
padding: 8px 0;
|
||||
font-size: 11px;
|
||||
background: #0f172a;
|
||||
background: var(--bg-help-tab);
|
||||
}
|
||||
|
||||
.help-toc-root { padding: 0; }
|
||||
@@ -833,7 +1096,7 @@ html, body, #root {
|
||||
.help-toc-arrow {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
color: var(--text-faint);
|
||||
font-size: 7px;
|
||||
padding: 0;
|
||||
width: 12px;
|
||||
@@ -843,7 +1106,7 @@ html, body, #root {
|
||||
line-height: 1;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.help-toc-arrow:hover { color: #94a3b8; }
|
||||
.help-toc-arrow:hover { color: var(--text-secondary); }
|
||||
|
||||
.help-toc-arrow-spacer {
|
||||
display: inline-block;
|
||||
@@ -852,7 +1115,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.help-toc-link {
|
||||
color: #94a3b8;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 2px 6px 2px 0;
|
||||
display: block;
|
||||
@@ -862,7 +1125,7 @@ html, body, #root {
|
||||
white-space: nowrap;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.help-toc-link:hover { color: #f1f5f9; }
|
||||
.help-toc-link:hover { color: var(--text-heading); }
|
||||
|
||||
.node-body {
|
||||
padding: 4px 0;
|
||||
@@ -886,18 +1149,18 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.custom-node.node-error {
|
||||
outline: 2px solid #ef4444;
|
||||
outline: 2px solid var(--danger-outline);
|
||||
outline-offset: -1px;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.35);
|
||||
box-shadow: 0 0 8px var(--danger-outline-glow);
|
||||
}
|
||||
|
||||
.node-error-message {
|
||||
padding: 3px 10px;
|
||||
font-size: 10px;
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: var(--error-text-2);
|
||||
background: var(--error-bg-2);
|
||||
border-top: 1px solid var(--error-border-2);
|
||||
border-bottom: 1px solid var(--error-border-2);
|
||||
}
|
||||
|
||||
.node-value-display {
|
||||
@@ -1398,6 +1661,213 @@ html, body, #root {
|
||||
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-line-color: #ff9800;
|
||||
--angle-arc-color: rgb(255, 166, 77);
|
||||
@@ -1415,6 +1885,15 @@ html, body, #root {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .angle-overlay {
|
||||
--angle-line-color: #c2410c;
|
||||
--angle-arc-color: #ea580c;
|
||||
--angle-end-handle-color: #c2410c;
|
||||
--angle-mid-handle-color: #9a3412;
|
||||
--angle-badge-text-color: #9a3412;
|
||||
--angle-badge-border-color: #c2410c;
|
||||
}
|
||||
|
||||
.angle-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
@@ -1480,13 +1959,13 @@ html, body, #root {
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
background: var(--overlay-chip-bg-strong);
|
||||
border: 1px solid var(--angle-badge-border-color);
|
||||
color: var(--angle-badge-text-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.35);
|
||||
box-shadow: 0 2px 8px var(--overlay-chip-shadow);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
@@ -1560,7 +2039,15 @@ html, body, #root {
|
||||
border: 2px solid var(--accent-lighter);
|
||||
box-shadow: inset 0 0 0 1px var(--crop-inset);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.crop-rect:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.crop-rect-locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.crop-marker {
|
||||
@@ -1635,7 +2122,10 @@ html, body, #root {
|
||||
.is-panning .lineplot-overlay,
|
||||
.is-panning .crop-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;
|
||||
}
|
||||
|
||||
@@ -1720,21 +2210,21 @@ html, body, #root {
|
||||
z-index: 2;
|
||||
min-width: 54px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.42);
|
||||
border: 1px solid var(--overlay-chip-border);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
background: var(--overlay-chip-bg);
|
||||
color: var(--text-bright);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.28);
|
||||
box-shadow: 0 4px 14px var(--overlay-chip-shadow);
|
||||
}
|
||||
|
||||
.surface-view-home:hover {
|
||||
background: rgba(30, 41, 59, 0.94);
|
||||
border-color: rgba(125, 211, 252, 0.55);
|
||||
background: var(--overlay-chip-bg-hover);
|
||||
border-color: var(--overlay-chip-border-hover);
|
||||
}
|
||||
|
||||
.surface-view-diagnostics {
|
||||
@@ -1745,8 +2235,8 @@ html, body, #root {
|
||||
max-width: calc(100% - 84px);
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
background: var(--overlay-chip-bg);
|
||||
color: var(--text-bright);
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
@@ -1945,7 +2435,7 @@ html, body, #root {
|
||||
}
|
||||
.text-note-color-btn:hover { transform: scale(1.25); }
|
||||
.text-note-color-btn.active {
|
||||
border-color: rgba(255,255,255,0.7);
|
||||
border-color: var(--note-active-ring);
|
||||
}
|
||||
.text-note-fold-btn {
|
||||
background: none;
|
||||
@@ -1987,15 +2477,15 @@ html, body, #root {
|
||||
.text-note-content code {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
background: var(--note-code-bg);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.text-note-content strong { font-weight: 600; }
|
||||
.text-note-content em { font-style: italic; opacity: 0.85; }
|
||||
.text-note-content hr { border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 8px 0; }
|
||||
.text-note-content hr { border: none; border-top: 1px solid var(--note-hr); margin: 8px 0; }
|
||||
.text-note-content blockquote {
|
||||
border-left: 3px solid rgba(255,255,255,0.2);
|
||||
border-left: 3px solid var(--note-quote-border);
|
||||
margin: 4px 0;
|
||||
padding-left: 10px;
|
||||
opacity: 0.8;
|
||||
@@ -2003,7 +2493,7 @@ html, body, #root {
|
||||
.text-note-placeholder { color: var(--text-faint); font-style: italic; }
|
||||
.text-note-textarea {
|
||||
flex: 1;
|
||||
background: rgba(0,0,0,0.25);
|
||||
background: var(--note-textarea-bg);
|
||||
border: none;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
@@ -2020,7 +2510,7 @@ html, body, #root {
|
||||
/* ── Context menu ──────────────────────────────────────────────────── */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
@@ -2089,6 +2579,14 @@ html, body, #root {
|
||||
.ctx-cat-active .ctx-cat-arrow {
|
||||
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) ── */
|
||||
.ctx-submenu {
|
||||
@@ -2098,7 +2596,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.context-item {
|
||||
padding: 5px 20px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
|
||||
92
frontend/src/theme.ts
Normal file
92
frontend/src/theme.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Theme manager. Three user-visible modes:
|
||||
* - 'light' — force light palette
|
||||
* - 'dark' — force dark palette
|
||||
* - 'auto' — follow the OS's prefers-color-scheme (default)
|
||||
*
|
||||
* The active palette is selected by setting data-theme on <html> to either
|
||||
* 'light' or 'dark'. auto mode resolves via matchMedia and re-applies on
|
||||
* system changes. The user's chosen mode is persisted in localStorage.
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
const STORAGE_KEY = 'tono_theme';
|
||||
|
||||
const systemMedia = typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia('(prefers-color-scheme: light)')
|
||||
: null;
|
||||
|
||||
export function getStoredTheme(): Theme {
|
||||
if (typeof localStorage === 'undefined') return 'auto';
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function setStoredTheme(theme: Theme): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
/** Resolve a Theme (possibly 'auto') to a concrete palette. */
|
||||
export function resolveTheme(theme: Theme): 'light' | 'dark' {
|
||||
if (theme === 'auto') {
|
||||
return systemMedia?.matches ? 'light' : 'dark';
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
/** Write data-theme on <html>, which drives the CSS overrides. */
|
||||
export function applyTheme(theme: Theme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const resolved = resolveTheme(theme);
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
}
|
||||
|
||||
type ThemeListener = (theme: Theme, resolved: 'light' | 'dark') => void;
|
||||
const listeners = new Set<ThemeListener>();
|
||||
|
||||
/**
|
||||
* Initialise theming on startup. Reads the stored preference, applies it,
|
||||
* and wires up a listener so that 'auto' mode tracks OS changes at runtime.
|
||||
* Call once, as early as possible (before first paint) from the entry point.
|
||||
*/
|
||||
export function initTheme(): Theme {
|
||||
const theme = getStoredTheme();
|
||||
applyTheme(theme);
|
||||
|
||||
if (systemMedia) {
|
||||
systemMedia.addEventListener('change', () => {
|
||||
if (getStoredTheme() === 'auto') {
|
||||
applyTheme('auto');
|
||||
for (const cb of listeners) cb('auto', resolveTheme('auto'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
/** Change the theme (persist + apply + notify subscribers). */
|
||||
export function setTheme(theme: Theme): void {
|
||||
setStoredTheme(theme);
|
||||
applyTheme(theme);
|
||||
const resolved = resolveTheme(theme);
|
||||
for (const cb of listeners) cb(theme, resolved);
|
||||
}
|
||||
|
||||
/** Cycle auto → light → dark → auto. Returns the new value. */
|
||||
export function cycleTheme(): Theme {
|
||||
const order: Theme[] = ['auto', 'light', 'dark'];
|
||||
const current = getStoredTheme();
|
||||
const next = order[(order.indexOf(current) + 1) % order.length];
|
||||
setTheme(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Subscribe to theme changes. Returns an unsubscribe function. */
|
||||
export function subscribeTheme(cb: ThemeListener): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
@@ -66,8 +66,24 @@ export interface OverlayData {
|
||||
y2?: number;
|
||||
xm?: number;
|
||||
ym?: number;
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
ex?: number;
|
||||
ey?: number;
|
||||
xreal?: number;
|
||||
yreal?: number;
|
||||
square?: boolean;
|
||||
a_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;
|
||||
line?: number[];
|
||||
shape?: string;
|
||||
|
||||
@@ -11,6 +11,10 @@ export const OVERLAY_CAPTURE_SELECTORS = [
|
||||
'.crop-overlay', // CropBoxOverlay
|
||||
'.markup-overlay', // MarkupOverlay
|
||||
'.angle-overlay', // AngleMeasureOverlay
|
||||
'.radial-overlay', // RadialProfileOverlay
|
||||
'.straighten-overlay', // StraightenPathOverlay
|
||||
'.multiprofile-overlay', // MultiProfileOverlay
|
||||
'.perspective-overlay', // PerspectiveOverlay
|
||||
];
|
||||
|
||||
function encodeBase64(bytes: Uint8Array) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"clean:build": "node scripts/clean-build-artifacts.mjs",
|
||||
"clean:native": "node scripts/clean-build-artifacts.mjs --mode=native",
|
||||
"dev": "npm run clean:dev && npm --prefix frontend run dev",
|
||||
"dev:all": "bash scripts/dev.sh",
|
||||
"build": "npm run clean:build && npm --prefix frontend run build",
|
||||
"preview": "npm --prefix frontend run preview",
|
||||
"test:frontend": "npm --prefix frontend test",
|
||||
|
||||
22
scripts/dev.sh
Executable file
22
scripts/dev.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch the Python backend and the Vite frontend dev server together.
|
||||
# Press Ctrl-C to stop both.
|
||||
|
||||
set -m
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
cleanup() {
|
||||
trap - INT TERM EXIT
|
||||
for pid in $(jobs -p); do
|
||||
kill -TERM "-$pid" 2>/dev/null || true
|
||||
done
|
||||
wait 2>/dev/null
|
||||
}
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
python -m backend.main &
|
||||
npm run dev &
|
||||
|
||||
while (( $(jobs -pr | wc -l) == 2 )); do
|
||||
sleep 0.5
|
||||
done
|
||||
@@ -2,7 +2,7 @@
|
||||
Generate test images and their FFT outputs for visual comparison with Gwyddion.
|
||||
Saves PNG files to tests/output/.
|
||||
|
||||
Run: .venv/bin/python -m tests.test_fft_visual
|
||||
Run from project root: .venv/bin/python scripts/fft_visual.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
@@ -12,7 +12,7 @@ sys.path.insert(0, ".")
|
||||
from backend.data_types import DataField, datafield_to_uint8, encode_preview
|
||||
from backend.nodes.fft_2d import FFT2D
|
||||
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "output")
|
||||
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "output")
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
55
tests/node_tests/arc_revolve.py
Normal file
55
tests/node_tests/arc_revolve.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -55,3 +55,73 @@ def test_crop_resize_field():
|
||||
raise AssertionError("Expected invalid crop bounds to raise ValueError")
|
||||
except ValueError:
|
||||
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
|
||||
|
||||
568
tests/node_tests/exporters.py
Normal file
568
tests/node_tests/exporters.py
Normal file
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
Tests for the exporter registry and the round-trippable DataField formats.
|
||||
|
||||
The Save node's format-specific behavior is covered in test_save_generic
|
||||
(tests/node_tests/save.py). This module focuses on:
|
||||
|
||||
1. Registry contract — every exporter module satisfies the protocol.
|
||||
2. Dispatch — type_name_for_value classifies values correctly and
|
||||
get_exporter returns a matching module.
|
||||
3. Round-trip — GWY and TIFF (data) preserve xreal/yreal/units/data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import (
|
||||
DataField,
|
||||
DataTable,
|
||||
ImageData,
|
||||
LineData,
|
||||
MeshModel,
|
||||
RecordTable,
|
||||
)
|
||||
|
||||
|
||||
def test_exporter_registry_contract():
|
||||
"""Every registered exporter module must expose the required attributes."""
|
||||
from backend.exporters import _REGISTRY
|
||||
from backend.exporters._base import FormatSpec
|
||||
|
||||
assert _REGISTRY, "Registry must not be empty"
|
||||
seen_modules = {mod for (mod, _) in _REGISTRY.values()}
|
||||
for module in seen_modules:
|
||||
assert hasattr(module, "accepted_types")
|
||||
assert hasattr(module, "FORMATS")
|
||||
assert hasattr(module, "save")
|
||||
assert isinstance(module.accepted_types, tuple)
|
||||
assert all(isinstance(t, str) and t.isupper() for t in module.accepted_types)
|
||||
assert isinstance(module.FORMATS, dict)
|
||||
for name, spec in module.FORMATS.items():
|
||||
assert isinstance(name, str) and name
|
||||
assert isinstance(spec, FormatSpec)
|
||||
assert spec.ext.startswith(".")
|
||||
|
||||
|
||||
def test_type_name_for_value_classification():
|
||||
from backend.exporters import type_name_for_value
|
||||
|
||||
assert type_name_for_value(DataField(data=np.zeros((4, 4)))) == "DATA_FIELD"
|
||||
assert type_name_for_value(np.zeros((4, 4))) == "IMAGE"
|
||||
assert type_name_for_value(np.zeros((4, 4, 3), dtype=np.uint8)) == "IMAGE"
|
||||
assert type_name_for_value(ImageData(np.zeros((4, 4), dtype=np.uint8))) == "IMAGE"
|
||||
assert type_name_for_value(np.zeros(8)) == "LINE"
|
||||
assert type_name_for_value(LineData(data=np.zeros(8))) == "LINE"
|
||||
assert type_name_for_value(RecordTable([{"a": 1}])) == "RECORD_TABLE"
|
||||
assert type_name_for_value(DataTable([{"a": 1}])) == "DATA_TABLE"
|
||||
assert type_name_for_value(1.25) == "FLOAT"
|
||||
assert type_name_for_value(np.float64(0.5)) == "FLOAT"
|
||||
mesh = MeshModel(
|
||||
vertices=np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32),
|
||||
faces=np.array([[0, 1, 2]], dtype=np.int32),
|
||||
)
|
||||
assert type_name_for_value(mesh) == "MESH_MODEL"
|
||||
|
||||
try:
|
||||
type_name_for_value(object())
|
||||
assert False, "Expected ValueError for unsupported type"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_get_exporter_known_and_unknown():
|
||||
from backend.exporters import get_exporter
|
||||
|
||||
mod, spec = get_exporter("DATA_FIELD", "GWY")
|
||||
assert spec.ext == ".gwy"
|
||||
assert spec.round_trip is True
|
||||
|
||||
mod, spec = get_exporter("DATA_FIELD", "TIFF")
|
||||
assert spec.ext == ".tiff"
|
||||
# Legacy preview path — not round-trippable.
|
||||
assert spec.round_trip is False
|
||||
|
||||
mod, spec = get_exporter("DATA_FIELD", "TIFF (data)")
|
||||
assert spec.round_trip is True
|
||||
|
||||
try:
|
||||
get_exporter("DATA_FIELD", "DOES_NOT_EXIST")
|
||||
assert False, "Expected ValueError for unknown format"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
get_exporter("FLOAT", "GWY")
|
||||
assert False, "Expected ValueError for type/format mismatch"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_available_formats_includes_new_datafield_formats():
|
||||
from backend.exporters import available_formats
|
||||
|
||||
formats = available_formats("DATA_FIELD")
|
||||
assert "TIFF" in formats
|
||||
assert "TIFF (data)" in formats
|
||||
assert "GWY" in formats
|
||||
assert "PNG" in formats
|
||||
assert "NPZ" in formats
|
||||
assert "HDF5" in formats
|
||||
assert "HDF5 (Ergo)" in formats
|
||||
|
||||
|
||||
def test_datafield_gwy_round_trip():
|
||||
"""Writing a DataField to .gwy and reloading via the importer preserves everything."""
|
||||
from backend.importers import gwy as gwy_importer
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(7)
|
||||
data = rng.standard_normal((32, 48)).astype(np.float64) * 1e-9
|
||||
field = DataField(
|
||||
data=data,
|
||||
xreal=3.2e-6,
|
||||
yreal=2.4e-6,
|
||||
xoff=1.1e-7,
|
||||
yoff=-5.5e-7,
|
||||
si_unit_xy="m",
|
||||
si_unit_z="m",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "topo"
|
||||
Save().save(filename=str(path), format="GWY", value=field)
|
||||
out_path = path.with_suffix(".gwy")
|
||||
assert out_path.exists()
|
||||
|
||||
reloaded = gwy_importer.load(out_path)
|
||||
assert len(reloaded) == 1
|
||||
rf = reloaded[0]
|
||||
assert rf.data.shape == field.data.shape
|
||||
assert np.allclose(rf.data, field.data)
|
||||
assert np.isclose(rf.xreal, field.xreal)
|
||||
assert np.isclose(rf.yreal, field.yreal)
|
||||
assert np.isclose(rf.xoff, field.xoff)
|
||||
assert np.isclose(rf.yoff, field.yoff)
|
||||
assert rf.si_unit_xy == "m"
|
||||
assert rf.si_unit_z == "m"
|
||||
|
||||
# channel_names() should return the stem we used as the title
|
||||
names = gwy_importer.channel_names(out_path)
|
||||
assert names == ["topo"]
|
||||
|
||||
|
||||
def test_datafield_tiff_data_round_trip():
|
||||
"""TIFF (data) writes float64 pixels + JSON metadata; we verify both."""
|
||||
import tifffile
|
||||
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(11)
|
||||
data = rng.standard_normal((24, 36)).astype(np.float64) * 1e-8
|
||||
field = DataField(
|
||||
data=data,
|
||||
xreal=5e-6,
|
||||
yreal=3e-6,
|
||||
xoff=0.0,
|
||||
yoff=0.0,
|
||||
si_unit_xy="m",
|
||||
si_unit_z="V",
|
||||
colormap="viridis",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "field"
|
||||
Save().save(filename=str(path), format="TIFF (data)", value=field)
|
||||
out_path = path.with_suffix(".tiff")
|
||||
assert out_path.exists()
|
||||
|
||||
with tifffile.TiffFile(out_path) as tif:
|
||||
arr = tif.asarray()
|
||||
desc = tif.pages[0].tags["ImageDescription"].value
|
||||
|
||||
assert arr.dtype == np.float64
|
||||
assert arr.shape == field.data.shape
|
||||
assert np.allclose(arr, field.data)
|
||||
|
||||
# Per-layer metadata lives under tono.layers[*]; a single-layer save
|
||||
# still produces the same shape, just with one entry.
|
||||
meta = json.loads(desc)["tono"]
|
||||
assert meta["version"] == 1
|
||||
assert len(meta["layers"]) == 1
|
||||
layer0 = meta["layers"][0]
|
||||
assert layer0["kind"] == "data_field"
|
||||
assert layer0["xreal"] == field.xreal
|
||||
assert layer0["yreal"] == field.yreal
|
||||
assert layer0["si_unit_xy"] == "m"
|
||||
assert layer0["si_unit_z"] == "V"
|
||||
assert layer0["domain"] == "spatial"
|
||||
|
||||
|
||||
def test_datafield_hdf5_generic_round_trip():
|
||||
"""HDF5 (generic) writes /data + attrs that our hdf5 importer reads back."""
|
||||
from backend.importers import hdf5 as hdf5_importer
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(23)
|
||||
data = rng.standard_normal((20, 28)).astype(np.float64) * 1e-7
|
||||
field = DataField(
|
||||
data=data,
|
||||
xreal=4.8e-6,
|
||||
yreal=3.2e-6,
|
||||
xoff=1.5e-7,
|
||||
yoff=-2.5e-7,
|
||||
si_unit_xy="m",
|
||||
si_unit_z="V",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "topo"
|
||||
Save().save(filename=str(path), format="HDF5", value=field)
|
||||
out_path = path.with_suffix(".h5")
|
||||
assert out_path.exists()
|
||||
|
||||
reloaded = hdf5_importer.load(out_path)
|
||||
assert len(reloaded) == 1
|
||||
rf = reloaded[0]
|
||||
assert rf.data.shape == field.data.shape
|
||||
assert np.allclose(rf.data, field.data)
|
||||
assert np.isclose(rf.xreal, field.xreal)
|
||||
assert np.isclose(rf.yreal, field.yreal)
|
||||
assert np.isclose(rf.xoff, field.xoff)
|
||||
assert np.isclose(rf.yoff, field.yoff)
|
||||
assert rf.si_unit_xy == "m"
|
||||
assert rf.si_unit_z == "V"
|
||||
|
||||
|
||||
def test_datafield_hdf5_ergo_round_trip():
|
||||
"""HDF5 (Ergo) writes the Asylum sidecar layout and round-trips via ergo_hdf5."""
|
||||
import h5py
|
||||
|
||||
from backend.importers import ergo_hdf5 as ergo_importer
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(29)
|
||||
data = rng.standard_normal((16, 24)).astype(np.float64) * 1e-9
|
||||
field = DataField(
|
||||
data=data,
|
||||
xreal=2.5e-6,
|
||||
yreal=1.8e-6,
|
||||
xoff=0.5e-7,
|
||||
yoff=-1.1e-7,
|
||||
si_unit_xy="m",
|
||||
si_unit_z="N",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "topo"
|
||||
Save().save(filename=str(path), format="HDF5 (Ergo)", value=field)
|
||||
out_path = path.with_suffix(".h5")
|
||||
assert out_path.exists()
|
||||
|
||||
# Sanity-check the layout: the dataset lives under
|
||||
# Image/DataSet/Resolution 0/Frame 0/<title>/Image, and the sidecar
|
||||
# group under Image/DataSetInfo/Global/Channels/<title>/ImageDims.
|
||||
with h5py.File(str(out_path), "r") as f:
|
||||
assert "Image/DataSet/Resolution 0/Frame 0/topo/Image" in f
|
||||
dims = f["Image/DataSetInfo/Global/Channels/topo/ImageDims"]
|
||||
scaling = np.asarray(dims.attrs["DimScaling"])
|
||||
assert scaling.shape == (2, 2)
|
||||
# DimScaling is Y-first: [[y_start, y_end], [x_start, x_end]]
|
||||
assert np.isclose(scaling[1, 1] - scaling[1, 0], field.xreal)
|
||||
assert np.isclose(scaling[0, 1] - scaling[0, 0], field.yreal)
|
||||
|
||||
reloaded = ergo_importer.load(out_path)
|
||||
assert len(reloaded) == 1
|
||||
rf = reloaded[0]
|
||||
assert rf.data.shape == field.data.shape
|
||||
assert np.allclose(rf.data, field.data)
|
||||
assert np.isclose(rf.xreal, field.xreal)
|
||||
assert np.isclose(rf.yreal, field.yreal)
|
||||
assert np.isclose(rf.xoff, field.xoff)
|
||||
assert np.isclose(rf.yoff, field.yoff)
|
||||
assert rf.si_unit_xy == "m"
|
||||
assert rf.si_unit_z == "N"
|
||||
|
||||
|
||||
def test_save_multi_layer_tiff_data():
|
||||
"""TIFF (data) with extra layers writes multi-page float64 with per-layer metadata."""
|
||||
import tifffile
|
||||
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(41)
|
||||
primary = DataField(
|
||||
data=rng.standard_normal((16, 20)).astype(np.float64) * 1e-9,
|
||||
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
layer2 = DataField(
|
||||
data=rng.standard_normal((16, 20)).astype(np.float64) * 1e-12,
|
||||
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="N",
|
||||
)
|
||||
layer3 = DataField(
|
||||
data=rng.standard_normal((16, 20)).astype(np.float64),
|
||||
xreal=3e-6, yreal=2e-6, si_unit_xy="m", si_unit_z="V",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "stack"
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="TIFF (data)",
|
||||
value=primary,
|
||||
field_0=layer2,
|
||||
field_1=layer3,
|
||||
primary_name="height",
|
||||
layer_name_0="force",
|
||||
layer_name_1="potential",
|
||||
)
|
||||
out_path = path.with_suffix(".tiff")
|
||||
assert out_path.exists()
|
||||
|
||||
with tifffile.TiffFile(out_path) as tif:
|
||||
assert len(tif.pages) == 3
|
||||
meta = json.loads(tif.pages[0].tags["ImageDescription"].value)["tono"]
|
||||
assert len(meta["layers"]) == 3
|
||||
assert [layer["name"] for layer in meta["layers"]] == ["height", "force", "potential"]
|
||||
assert meta["layers"][1]["si_unit_z"] == "N"
|
||||
assert meta["layers"][2]["si_unit_z"] == "V"
|
||||
assert tif.pages[0].asarray().shape == (16, 20)
|
||||
assert tif.pages[1].asarray().shape == (16, 20)
|
||||
assert np.allclose(tif.pages[0].asarray(), primary.data)
|
||||
assert np.allclose(tif.pages[2].asarray(), layer3.data)
|
||||
|
||||
|
||||
def test_save_multi_layer_npz_named_keys():
|
||||
"""Multi-layer NPZ uses safe-identifier keys from layer names."""
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(47)
|
||||
primary = DataField(data=rng.standard_normal((8, 8)).astype(np.float64))
|
||||
layer2 = DataField(data=rng.standard_normal((8, 8)).astype(np.float64))
|
||||
annotated = np.zeros((12, 12, 3), dtype=np.uint8)
|
||||
annotated[..., 0] = 255
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "stack"
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="NPZ",
|
||||
value=primary,
|
||||
field_0=layer2,
|
||||
field_1=annotated,
|
||||
primary_name="height map",
|
||||
layer_name_0="force-retrace",
|
||||
layer_name_1="annotated overview",
|
||||
)
|
||||
out_path = path.with_suffix(".npz")
|
||||
assert out_path.exists()
|
||||
|
||||
npz = np.load(out_path)
|
||||
# Non-identifier characters collapse to underscores.
|
||||
assert set(npz.files) == {"height_map", "force_retrace", "annotated_overview"}
|
||||
assert np.allclose(npz["height_map"], primary.data)
|
||||
assert np.allclose(npz["force_retrace"], layer2.data)
|
||||
assert np.array_equal(npz["annotated_overview"], annotated)
|
||||
|
||||
|
||||
def test_save_multi_layer_tiff_preview_rejected():
|
||||
"""Single-layer-only formats must reject extra layers with a clear error."""
|
||||
from backend.nodes.save import Save
|
||||
|
||||
field_a = DataField(data=np.zeros((4, 4)))
|
||||
field_b = DataField(data=np.ones((4, 4)))
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "preview"
|
||||
try:
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="TIFF", # preview format, single-layer only
|
||||
value=field_a,
|
||||
field_0=field_b,
|
||||
)
|
||||
assert False, "TIFF preview must reject extra layers"
|
||||
except ValueError as exc:
|
||||
assert "single layer" in str(exc).lower()
|
||||
|
||||
try:
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="PNG",
|
||||
value=field_a,
|
||||
field_0=field_b,
|
||||
)
|
||||
assert False, "PNG must reject extra layers"
|
||||
except ValueError as exc:
|
||||
assert "single layer" in str(exc).lower()
|
||||
|
||||
|
||||
def test_save_multi_channel_gwy_round_trip():
|
||||
"""A multi-channel GWY save round-trips via the gwy importer."""
|
||||
from backend.importers import gwy as gwy_importer
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(53)
|
||||
primary = DataField(
|
||||
data=rng.standard_normal((24, 32)).astype(np.float64) * 1e-9,
|
||||
xreal=4e-6, yreal=3e-6, si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
layer2 = DataField(
|
||||
data=rng.standard_normal((24, 32)).astype(np.float64) * 1e-11,
|
||||
xreal=4e-6, yreal=3e-6, si_unit_xy="m", si_unit_z="N",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "topo"
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="GWY",
|
||||
value=primary,
|
||||
field_0=layer2,
|
||||
primary_name="height",
|
||||
layer_name_0="adhesion",
|
||||
)
|
||||
out_path = path.with_suffix(".gwy")
|
||||
assert out_path.exists()
|
||||
|
||||
reloaded = gwy_importer.load(out_path)
|
||||
assert len(reloaded) == 2
|
||||
names = gwy_importer.channel_names(out_path)
|
||||
assert set(names) == {"height", "adhesion"}
|
||||
# GWY does not guarantee iteration order across channels, so match
|
||||
# each input by content rather than by position.
|
||||
assert any(np.allclose(f.data, primary.data) for f in reloaded)
|
||||
assert any(np.allclose(f.data, layer2.data) for f in reloaded)
|
||||
for f in reloaded:
|
||||
assert np.isclose(f.xreal, 4e-6)
|
||||
assert np.isclose(f.yreal, 3e-6)
|
||||
|
||||
|
||||
def test_save_multi_channel_hdf5_round_trip():
|
||||
"""Multi-channel generic HDF5 round-trips via the hdf5 importer."""
|
||||
from backend.importers import hdf5 as hdf5_importer
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(59)
|
||||
primary = DataField(
|
||||
data=rng.standard_normal((12, 18)).astype(np.float64) * 1e-7,
|
||||
xreal=2e-6, yreal=1.5e-6, si_unit_xy="m", si_unit_z="V",
|
||||
)
|
||||
layer2 = DataField(
|
||||
data=rng.standard_normal((12, 18)).astype(np.float64) * 1e-9,
|
||||
xreal=2e-6, yreal=1.5e-6, si_unit_xy="m", si_unit_z="A",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "stack"
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="HDF5",
|
||||
value=primary,
|
||||
field_0=layer2,
|
||||
primary_name="potential",
|
||||
layer_name_0="current",
|
||||
)
|
||||
out_path = path.with_suffix(".h5")
|
||||
assert out_path.exists()
|
||||
|
||||
reloaded = hdf5_importer.load(out_path)
|
||||
assert len(reloaded) == 2
|
||||
# Identify the two channels by their unique z-units.
|
||||
by_unit = {rf.si_unit_z: rf for rf in reloaded}
|
||||
assert set(by_unit.keys()) == {"V", "A"}
|
||||
assert np.allclose(by_unit["V"].data, primary.data)
|
||||
assert np.allclose(by_unit["A"].data, layer2.data)
|
||||
|
||||
|
||||
def test_save_multi_channel_hdf5_ergo_round_trip():
|
||||
"""Multi-channel Ergo-layout HDF5 round-trips via the ergo_hdf5 importer."""
|
||||
from backend.importers import ergo_hdf5 as ergo_importer
|
||||
from backend.nodes.save import Save
|
||||
|
||||
rng = np.random.default_rng(61)
|
||||
primary = DataField(
|
||||
data=rng.standard_normal((10, 14)).astype(np.float64) * 1e-9,
|
||||
xreal=1.5e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
layer2 = DataField(
|
||||
data=rng.standard_normal((10, 14)).astype(np.float64) * 1e-11,
|
||||
xreal=1.5e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="N",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "topo"
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="HDF5 (Ergo)",
|
||||
value=primary,
|
||||
field_0=layer2,
|
||||
primary_name="height",
|
||||
layer_name_0="adhesion",
|
||||
)
|
||||
out_path = path.with_suffix(".h5")
|
||||
assert out_path.exists()
|
||||
|
||||
reloaded = ergo_importer.load(out_path)
|
||||
assert len(reloaded) == 2
|
||||
by_unit = {rf.si_unit_z: rf for rf in reloaded}
|
||||
assert set(by_unit.keys()) == {"m", "N"}
|
||||
assert np.allclose(by_unit["m"].data, primary.data)
|
||||
assert np.allclose(by_unit["N"].data, layer2.data)
|
||||
|
||||
|
||||
def test_save_gwy_rejects_image_layer():
|
||||
"""GWY/HDF5 formats must error cleanly on non-DataField layers."""
|
||||
from backend.nodes.save import Save
|
||||
|
||||
field = DataField(data=np.zeros((4, 4)))
|
||||
image = np.zeros((4, 4, 3), dtype=np.uint8)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "topo"
|
||||
try:
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="GWY",
|
||||
value=field,
|
||||
field_0=image,
|
||||
)
|
||||
assert False, "GWY must reject non-DataField layers"
|
||||
except ValueError as exc:
|
||||
assert "DataField" in str(exc) or "data field" in str(exc).lower()
|
||||
|
||||
|
||||
def test_save_ignores_extra_layers_for_non_stackable_types():
|
||||
"""Stray field_N kwargs must be ignored when value is a scalar/line/table."""
|
||||
from backend.nodes.save import Save
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "scalar"
|
||||
# field_0 is connected but should be silently ignored for a FLOAT value.
|
||||
Save().save(
|
||||
filename=str(path),
|
||||
format="TXT",
|
||||
value=1.25,
|
||||
field_0=DataField(data=np.zeros((4, 4))),
|
||||
)
|
||||
assert Path(tmpdir, "scalar.txt").read_text(encoding="utf-8").strip() == "1.25"
|
||||
|
||||
|
||||
def test_tiff_preview_is_still_rgb_uint8():
|
||||
"""The legacy TIFF format for DATA_FIELD must keep producing 8-bit RGB."""
|
||||
import tifffile
|
||||
|
||||
from backend.nodes.save import Save
|
||||
|
||||
field = DataField(
|
||||
data=np.array([[0.0, 1.0], [2.0, 3.0]], dtype=np.float64),
|
||||
xreal=1e-6, yreal=1e-6, si_unit_xy="m", si_unit_z="m",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "preview"
|
||||
Save().save(filename=str(path), format="TIFF", value=field)
|
||||
arr = tifffile.imread(str(path.with_suffix(".tiff")))
|
||||
assert arr.dtype == np.uint8
|
||||
assert arr.shape == (2, 2, 3)
|
||||
37
tests/node_tests/level_rotate.py
Normal file
37
tests/node_tests/level_rotate.py
Normal 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)
|
||||
143
tests/node_tests/mask_rectangular.py
Normal file
143
tests/node_tests/mask_rectangular.py
Normal 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,")
|
||||
@@ -31,3 +31,41 @@ def test_vertical_direction():
|
||||
field = make_field(shape=(80, 40))
|
||||
(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)}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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,)
|
||||
@@ -51,3 +51,57 @@ def test_output_shape():
|
||||
bottom_right_x=0.0, bottom_right_y=0.0,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_radial_profile_constant_field():
|
||||
node = RadialProfile()
|
||||
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 len(result.data) == 32
|
||||
|
||||
@@ -26,7 +26,7 @@ def test_radial_profile_units():
|
||||
node = RadialProfile()
|
||||
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.y_unit == field.si_unit_z
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_radial_profile_x_axis_monotone():
|
||||
node = RadialProfile()
|
||||
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 np.all(np.diff(result.x_axis) > 0)
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_radial_profile_off_centre():
|
||||
node = RadialProfile()
|
||||
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
|
||||
finite = result.data[np.isfinite(result.data)]
|
||||
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))
|
||||
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)]
|
||||
# The profile should vary (not constant)
|
||||
assert np.std(finite) > 0.01
|
||||
@@ -80,6 +80,25 @@ def test_radial_profile_n_bins():
|
||||
field = make_field()
|
||||
|
||||
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.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]
|
||||
|
||||
@@ -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
|
||||
41
tests/node_tests/sphere_revolve.py
Normal file
41
tests/node_tests/sphere_revolve.py
Normal 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)
|
||||
@@ -26,3 +26,46 @@ def test_statistics():
|
||||
assert const_stats["RMS"] == 0.0
|
||||
assert const_stats["skewness"] == 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
|
||||
|
||||
@@ -8,10 +8,11 @@ def test_basic_extraction():
|
||||
|
||||
node = StraightenPath()
|
||||
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",
|
||||
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 profile.data.shape == (256,)
|
||||
|
||||
|
||||
def test_thickness():
|
||||
@@ -19,10 +20,14 @@ def test_thickness():
|
||||
|
||||
node = StraightenPath()
|
||||
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",
|
||||
thickness=5, n_samples=100)
|
||||
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():
|
||||
@@ -30,8 +35,33 @@ def test_single_point_returns_input():
|
||||
|
||||
node = StraightenPath()
|
||||
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",
|
||||
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 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]
|
||||
|
||||
50
tests/node_tests/unrotate.py
Normal file
50
tests/node_tests/unrotate.py
Normal 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)
|
||||
46
tests/node_tests/zero_value.py
Normal file
46
tests/node_tests/zero_value.py
Normal 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)
|
||||
@@ -10,7 +10,6 @@ import numpy as np
|
||||
sys.path.insert(0, ".")
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.fft_2d import FFT2D
|
||||
from backend.nodes.fft_2d_inverse import FFT2DInverse
|
||||
|
||||
|
||||
def make_field(data, xreal=1e-6, yreal=1e-6):
|
||||
@@ -247,91 +246,6 @@ def test_log_magnitude_visual_range():
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
def test_inverse_fft_reconstructs_from_magnitude_and_phase():
|
||||
"""Magnitude + phase from FFT2D should reconstruct the original image."""
|
||||
print("=== Test: Inverse FFT from magnitude + phase ===")
|
||||
rng = np.random.default_rng(123)
|
||||
data = rng.standard_normal((64, 96))
|
||||
field = make_field(data, xreal=2.4e-6, yreal=1.6e-6)
|
||||
|
||||
fft_node = FFT2D()
|
||||
ifft_node = FFT2DInverse()
|
||||
|
||||
_, magnitude, phase, _ = fft_node.process(field, windowing="none", level="none")
|
||||
reconstructed, = ifft_node.process(magnitude, representation="magnitude", phase=phase)
|
||||
|
||||
max_err = np.max(np.abs(reconstructed.data - field.data))
|
||||
print(f" Reconstruction max error: {max_err:.3e}")
|
||||
assert reconstructed.domain == "spatial"
|
||||
assert reconstructed.data.shape == field.data.shape
|
||||
assert np.isclose(reconstructed.xreal, field.xreal)
|
||||
assert np.isclose(reconstructed.yreal, field.yreal)
|
||||
assert max_err < 1e-9, f"Expected near-exact reconstruction, got {max_err}"
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
def test_inverse_fft_reconstructs_from_log_magnitude_and_phase():
|
||||
"""log(|F|) + phase should also reconstruct after expm1 inversion."""
|
||||
print("=== Test: Inverse FFT from log magnitude + phase ===")
|
||||
y, x = np.mgrid[0:72, 0:80] / 80.0
|
||||
data = (
|
||||
0.8 * np.sin(2 * np.pi * 6 * x)
|
||||
+ 0.35 * np.cos(2 * np.pi * 9 * y)
|
||||
+ 0.15 * np.sin(2 * np.pi * (4 * x + 3 * y))
|
||||
)
|
||||
field = make_field(data, xreal=1.6e-6, yreal=1.44e-6)
|
||||
|
||||
fft_node = FFT2D()
|
||||
ifft_node = FFT2DInverse()
|
||||
|
||||
log_magnitude, _, phase, _ = fft_node.process(field, windowing="none", level="none")
|
||||
reconstructed, = ifft_node.process(log_magnitude, representation="log_magnitude", phase=phase)
|
||||
|
||||
rms_err = np.sqrt(np.mean((reconstructed.data - field.data) ** 2))
|
||||
print(f" Reconstruction RMS error: {rms_err:.3e}")
|
||||
assert rms_err < 1e-9, f"Expected near-exact reconstruction, got {rms_err}"
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
def test_inverse_fft_reconstructs_from_psdf_and_phase():
|
||||
"""PSDF + phase should reconstruct after undoing PSDF scaling."""
|
||||
print("=== Test: Inverse FFT from PSDF + phase ===")
|
||||
rng = np.random.default_rng(321)
|
||||
data = rng.standard_normal((48, 64))
|
||||
field = make_field(data, xreal=3.2e-6, yreal=2.4e-6)
|
||||
|
||||
fft_node = FFT2D()
|
||||
ifft_node = FFT2DInverse()
|
||||
|
||||
_, _, phase, psdf = fft_node.process(field, windowing="none", level="none")
|
||||
reconstructed, = ifft_node.process(psdf, representation="psdf", phase=phase)
|
||||
|
||||
max_err = np.max(np.abs(reconstructed.data - field.data))
|
||||
print(f" Reconstruction max error: {max_err:.3e}")
|
||||
assert reconstructed.si_unit_z == field.si_unit_z
|
||||
assert max_err < 1e-8, f"Expected near-exact reconstruction, got {max_err}"
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
def test_inverse_fft_zero_phase_mode_returns_valid_image():
|
||||
"""Spectrum-only inversion should return a finite spatial image with the right shape."""
|
||||
print("=== Test: Inverse FFT zero-phase mode ===")
|
||||
data = np.sin(2 * np.pi * 5 * np.mgrid[0:64, 0:64][1] / 64.0)
|
||||
field = make_field(data, xreal=1e-6, yreal=1e-6)
|
||||
|
||||
fft_node = FFT2D()
|
||||
ifft_node = FFT2DInverse()
|
||||
|
||||
_, magnitude, _, _ = fft_node.process(field, windowing="none", level="none")
|
||||
reconstructed, = ifft_node.process(magnitude, representation="magnitude")
|
||||
|
||||
print(f" Output shape: {reconstructed.data.shape}")
|
||||
assert reconstructed.domain == "spatial"
|
||||
assert reconstructed.data.shape == field.data.shape
|
||||
assert np.all(np.isfinite(reconstructed.data))
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dc_removal()
|
||||
test_single_frequency()
|
||||
@@ -341,8 +255,4 @@ if __name__ == "__main__":
|
||||
test_plane_subtraction()
|
||||
test_non_square()
|
||||
test_log_magnitude_visual_range()
|
||||
test_inverse_fft_reconstructs_from_magnitude_and_phase()
|
||||
test_inverse_fft_reconstructs_from_log_magnitude_and_phase()
|
||||
test_inverse_fft_reconstructs_from_psdf_and_phase()
|
||||
test_inverse_fft_zero_phase_mode_returns_valid_image()
|
||||
print("All tests passed!")
|
||||
|
||||
Reference in New Issue
Block a user