Files
tono/docs/plugins.md

9.8 KiB
Raw Permalink Blame History

Writing plugins

Plugins are plain Python files dropped into the plugins/ directory. Each file registers one or more nodes that appear in the Add Node menu immediately — no restart required if uploaded via UI upload.

A complete, annotated example is at plugins/example_normalize.py.

Note: The plugin system is enabled on native desktop builds and disabled on web deployments by default. Override with the TONO_PLUGINS=1 environment variable.


Minimal plugin

# plugins/my_filter.py
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField

@register_node(display_name="My Filter")
class MyFilter:
    CATEGORY = "Plugins"

    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "field": ("DATA_FIELD",),
                "strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0}),
            }
        }

    OUTPUTS = (("DATA_FIELD", "result"),)
    FUNCTION = "process"

    def process(self, field: DataField, strength: float) -> tuple:
        scaled = field.data * strength
        return (field.replace(data=scaled),)

Drop this file into plugins/ and the node appears under Plugins → My Filter in the Add Node menu.


Node class attributes

Attribute Required Description
INPUT_TYPES Yes Classmethod returning {"required": {...}, "optional": {...}}
OUTPUTS Yes Tuple of (type, name) or (type, name, meta) entries
FUNCTION Yes Name of the method to call on execution
CATEGORY No Menu category; defaults to "Unsorted" if omitted
DESCRIPTION No Human-readable description shown in the UI
OUTPUT_NODE No Set True to mark this node as a terminal output node
MANUAL_TRIGGER No Set True to require the user to click Run manually

Input types

Data types (socket connections)

These appear as connectable sockets on the node. They cannot be set inline by the user — they must be wired from another node.

Type string Python type received Description
"DATA_FIELD" DataField 2D spatial/height data with physical metadata
"IMAGE" np.ndarray (uint8) Greyscale (H×W) or RGB (H×W×3) image or mask
"LINE" LineData 1D profile data with optional X axis and units
"RECORD_TABLE" RecordTable (list of dicts) Named scalar measurements
"MESH_MODEL" MeshModel 3D triangle mesh

Widget types (inline controls)

These appear as UI controls on the node body. They can also be connected from another node's output socket.

FLOAT

"sigma": ("FLOAT", {
    "default": 1.0,
    "min": 0.0,      # optional
    "max": 10.0,     # optional
    "step": 0.1,     # optional, default step for dragging
})

Add "socket_only": True in the optional dict to suppress the widget and show only a socket:

# optional section:
"value": ("FLOAT", {"socket_only": True}),

INT

"count": ("INT", {
    "default": 5,
    "min": 1,
    "max": 100,
    "step": 1,
})

Dropdown / choice list

Pass a list as the first element of the spec tuple:

"method": (["nearest", "bilinear", "bicubic"],),
# or with a default:
"method": (["nearest", "bilinear", "bicubic"], {"default": "bilinear"}),

STRING

"label": ("STRING", {
    "default": "",
    "placeholder": "Enter text...",  # optional
    "multiline": False,              # optional
})

Optional inputs

Declare inputs under "optional" to make them not required for execution. Your process() method receives None for any unconnected optional input, so guard against it:

@classmethod
def INPUT_TYPES(cls):
    return {
        "required": {"field": ("DATA_FIELD",)},
        "optional": {"mask": ("IMAGE",)},
    }

def process(self, field, mask=None):
    if mask is not None:
        # use mask
        ...

Output types

Each entry in OUTPUTS is (type_string, display_name) or (type_string, display_name, meta_dict).

Type string Python value to return Description
"DATA_FIELD" DataField 2D spatial data
"IMAGE" np.ndarray (uint8) Greyscale or RGB image / mask
"LINE" LineData 1D profile
"RECORD_TABLE" RecordTable Named scalar measurement table
"FLOAT" float Scalar number

The return value of process() must be a tuple with one item per OUTPUTS entry, in the same order:

OUTPUTS = (
    ("DATA_FIELD", "result"),
    ("RECORD_TABLE", "stats"),
    ("FLOAT", "mean"),
)

def process(self, field):
    ...
    return (result_field, table, mean_value)   # must be a tuple

Accepting multiple input types on one output slot

Use accepted_types in the output metadata to allow wiring from additional types:

OUTPUTS = (
    ("DATA_FIELD", "output", {"accepted_types": ["IMAGE"]}),
)

Data types reference

DataField

The main SPM data container. Mirrors Gwyddion's GwyDataField.

@dataclass
class DataField:
    data: np.ndarray      # shape (yres, xres), dtype float64
    xres: int             # pixel count in X (set automatically from data)
    yres: int             # pixel count in Y (set automatically from data)
    xreal: float          # physical width in metres
    yreal: float          # physical height in metres
    xoff: float           # X position offset in metres
    yoff: float           # Y position offset in metres
    si_unit_xy: str       # lateral unit, e.g. "m"
    si_unit_z: str        # value unit, e.g. "m", "V", "A"
    domain: str           # "spatial" or "frequency"
    colormap: str | dict  # colormap name or custom dict
    display_offset: float # normalized display window offset
    display_scale: float  # normalized display window scale
    overlays: list        # list of overlay dicts (annotations etc.)

# Computed properties:
field.dx  # physical pixel size X = xreal / xres (metres)
field.dy  # physical pixel size Y = yreal / yres (metres)

Always use field.replace() instead of constructing a new DataField — it copies all metadata and only substitutes what you specify:

# Good: physical dimensions, units, colormap all preserved
result = field.replace(data=new_data)

# Also valid: change data and units together
result = field.replace(data=fft_data, si_unit_z="1/m", domain="frequency")

Available built-in colormaps: viridis, gray, hot, jet, plasma, inferno, terrain, cividis, magma, copper, afmhot.

LineData

1D profile data.

@dataclass
class LineData:
    data: np.ndarray           # 1D float64 array of Y values
    x_axis: np.ndarray | None  # optional 1D float64 array of X positions
    x_unit: str                # unit label for X axis
    y_unit: str                # unit label for Y axis

# Supports NumPy interface transparently:
np.asarray(line)    # → line.data
len(line)           # → len(line.data)
line[i]             # → line.data[i]

MeshModel

3D triangle mesh for the 3D view node.

@dataclass
class MeshModel:
    vertices: np.ndarray       # shape (N, 3), float32 — XYZ positions
    faces: np.ndarray          # shape (M, 3), int32  — triangle vertex indices
    colors: np.ndarray | None  # shape (N, 3), uint8  — per-vertex RGB (optional)

RecordTable

A measurement table: a plain list of {"quantity", "value", "unit"} dicts. Can be wired to the Print Table or Save nodes.

from backend.data_types import RecordTable

table = RecordTable([
    {"quantity": "RMS roughness", "value": 2.34e-9, "unit": "m"},
    {"quantity": "Mean",          "value": 0.12e-9, "unit": "m"},
    {"quantity": "Pixel count",   "value": 4096,    "unit": ""},
])

Use field.si_unit_z for the physical Z unit of the input field. Use "" for dimensionless quantities.


Execution context: emit functions

Import from backend.execution_context to send data to the frontend during execution — for example, to show a preview chart or a warning message.

from backend.execution_context import emit_preview, emit_table, emit_warning, emit_value
Function Description
emit_preview(data_uri) Push a preview image (base64 data URI string) to the preview panel
emit_table(rows) Push a list of dicts as a table update
emit_value(payload) Push a scalar value (or {"value": v, "unit": "m"} dict)
emit_warning(message) Show a warning banner in the UI

These functions are no-ops if called outside an active execution context, so they are safe to call unconditionally.

from backend.execution_context import emit_warning

def process(self, field, threshold):
    if threshold > field.data.max():
        emit_warning("Threshold is above the data maximum — result will be empty.")
    ...

Multi-file plugins

A directory with an __init__.py is treated as a plugin package. Private helpers (names starting with _) are ignored by the loader.

plugins/
  my_suite/
    __init__.py          # registers nodes with @register_node
    _helpers.py          # private helpers, not auto-loaded

In __init__.py:

from backend.node_registry import register_node
from backend.data_types import DataField
from my_suite._helpers import compute_something

@register_node(display_name="Suite Node A")
class SuiteNodeA:
    ...

Uploading plugins via the web interface

On native builds, plugins can be uploaded without restarting via the toolbar.

The server saves the file, hot-reloads all plugins, and broadcasts a nodes_updated WebSocket message so the frontend refreshes the node list automatically.

Security note: Uploading a .py file is equivalent to executing arbitrary code inside the server process. Only expose this endpoint on trusted local networks.