Files
tono/docs/plugins.md

332 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../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
```python
# 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
```python
"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:
```python
# optional section:
"value": ("FLOAT", {"socket_only": True}),
```
#### INT
```python
"count": ("INT", {
"default": 5,
"min": 1,
"max": 100,
"step": 1,
})
```
#### Dropdown / choice list
Pass a list as the first element of the spec tuple:
```python
"method": (["nearest", "bilinear", "bicubic"],),
# or with a default:
"method": (["nearest", "bilinear", "bicubic"], {"default": "bilinear"}),
```
#### STRING
```python
"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:
```python
@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:
```python
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:
```python
OUTPUTS = (
("DATA_FIELD", "output", {"accepted_types": ["IMAGE"]}),
)
```
---
## Data types reference
### DataField
The main SPM data container. Mirrors Gwyddion's `GwyDataField`.
```python
@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:
```python
# 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.
```python
@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.
```python
@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.
```python
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.
```python
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.
```python
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`:
```python
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.