9.8 KiB
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=1environment 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
.pyfile is equivalent to executing arbitrary code inside the server process. Only expose this endpoint on trusted local networks.