support standalone library

This commit is contained in:
2026-04-04 01:24:06 -07:00
parent b2ddd81286
commit d9218bf28c
13 changed files with 610 additions and 26 deletions

View File

@@ -26,7 +26,7 @@ jobs:
set -e
cd /opt/tono
git pull --ff-only
.venv/bin/pip install -e . --quiet
.venv/bin/pip install -e ".[server]" --quiet
cd frontend && npm ci --ignore-scripts && npm run build && cd ..
sudo systemctl restart tono
REMOTE

View File

@@ -22,7 +22,7 @@ jobs:
cache: npm
- name: Install Python dependencies
run: pip install -e ".[dev]"
run: pip install -e ".[server,dev]"
- name: Install Node dependencies
run: npm install --ignore-scripts

View File

@@ -16,7 +16,7 @@ Install a local binary from the Releases section, or run locally:
```bash
# Installation
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
pip install -e ".[server,dev]"
npm install
# Running the servers
@@ -29,29 +29,46 @@ npm run dev # terminal 2 — Vite dev server, open the URL it prints
```bash
git clone https://github.com/VIPQualityPost/tono.git && cd tono
python -m venv .venv && source .venv/bin/activate
pip install -e .
pip install -e ".[server]"
cd frontend && npm ci && npm run build && cd ..
TONO_HOST=0.0.0.0 python -m backend.main
```
See [Self-Hosting](docs/self-hosting.md) for reverse proxy setup, environment variables, and configuration.
## Python library
tono's processing nodes can also be used as a standalone Python library — no server needed:
```bash
pip install -e .
```
```python
import tono
fields = tono.load("scan.gwy")
leveled = tono.apply("PlaneLevelField", fields[0])
filtered = tono.apply("GaussianFilter", leveled, sigma=2.0)
```
See [Library Usage](docs/library.md) for the full API and more examples.
## Docs
- [Building](docs/building.md) — setup, dev mode, web deployment, and native desktop builds for macOS, Linux, and Windows
- [Self-Hosting](docs/self-hosting.md) — deploying tono on a server
- [Library Usage](docs/library.md) — using tono as a Python signal processing library
- [Plugins](docs/plugins.md) — writing and uploading custom node plugins
- [Testing](docs/testing.md) — running tests and writing new ones
## Project layout
## Project plans
```text
tono/
backend/ Python server, execution engine, nodes
frontend/ React/Vite app
plugins/ User plugin files (.py)
tests/ Python tests
docs/ Documentation
desktop.py Desktop launcher
scripts/ Build scripts (macOS, Linux, Windows)
```
- Please help with providing demo files for validating importers!
- Please try making weird workflows to see what breaks or does not flow nicely
- Adding support for force curves
- Adding general support for spectroscopic data
- Adding general support for spectroscopic volumes
- Adding more generic numerical operations and visualisations

233
backend/api.py Normal file
View File

@@ -0,0 +1,233 @@
"""
Standalone library API for tono signal processing nodes.
Usage::
import tono
# Load an SPM file
fields = tono.load("scan.gwy")
field = fields[0]
# Apply a processing node
result = tono.apply("GaussianFilter", field, sigma=3.0)
# Chain operations
result = tono.apply("PlaneLevelField", field, mode="subtract_mean")
result = tono.apply("GaussianFilter", result, sigma=2.0)
# Get available nodes
print(tono.nodes())
# Direct node access
node = tono.get_node("GaussianFilter")
(filtered,) = node.process(field=field, sigma=3.0)
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from backend.data_types import DataField, LineData, RecordTable, DataTable, MeshModel
def _ensure_registry() -> None:
"""Import all node modules so @register_node decorators fire."""
from backend.node_registry import NODE_CLASS_MAPPINGS
if NODE_CLASS_MAPPINGS:
return
import backend.nodes # noqa: F401 — triggers auto-discovery
def load(path: str | Path) -> list[DataField]:
"""Load an SPM data file and return a list of DataField channels.
Supported formats: .gwy, .sxm, .ibw, .hdf5, .png, .tif, .jpg, and more.
Parameters
----------
path : str or Path
Path to the data file.
Returns
-------
list[DataField]
One DataField per channel in the file.
Raises
------
ValueError
If the file extension is not supported.
"""
from backend.importers import get_importer
p = Path(path)
ext = p.suffix.lower()
importer = get_importer(ext)
if importer is None:
from backend.importers import all_extensions
supported = ", ".join(sorted(all_extensions()))
raise ValueError(
f"Unsupported file extension {ext!r}. Supported: {supported}"
)
return importer.load(str(p))
def channel_names(path: str | Path) -> list[str]:
"""Return the channel names in a data file without loading the full data."""
from backend.importers import get_importer
p = Path(path)
importer = get_importer(p.suffix.lower())
if importer is None:
raise ValueError(f"Unsupported file extension: {p.suffix!r}")
return importer.channel_names(str(p))
def supported_formats() -> frozenset[str]:
"""Return all supported file extensions (e.g. {'.gwy', '.sxm', ...})."""
from backend.importers import all_extensions
return all_extensions()
def nodes() -> list[str]:
"""Return a sorted list of all registered node class names."""
from backend.node_registry import NODE_CLASS_MAPPINGS
_ensure_registry()
return sorted(NODE_CLASS_MAPPINGS.keys())
def get_node(class_name: str) -> Any:
"""Return a fresh instance of the named node class.
Parameters
----------
class_name : str
The node class name, e.g. ``"GaussianFilter"``.
Returns
-------
object
A node instance. Call its ``process(**kwargs)`` method to run it.
Raises
------
KeyError
If no node with that name is registered.
"""
from backend.node_registry import NODE_CLASS_MAPPINGS
_ensure_registry()
if class_name not in NODE_CLASS_MAPPINGS:
raise KeyError(
f"Unknown node {class_name!r}. "
f"Use tono.nodes() to list available nodes."
)
return NODE_CLASS_MAPPINGS[class_name]()
def apply(class_name: str, *args: Any, **kwargs: Any) -> Any:
"""Run a node's process function and return the result.
Positional arguments are mapped to the node's required inputs in order.
Keyword arguments are passed directly.
If the node returns a single output, it is unwrapped from the tuple.
If the node returns multiple outputs, the full tuple is returned.
Parameters
----------
class_name : str
The node class name, e.g. ``"GaussianFilter"``.
*args
Positional arguments mapped to required inputs in declaration order.
**kwargs
Keyword arguments passed directly to the node's process function.
Returns
-------
DataField or tuple
Single output unwrapped, or tuple of outputs if multiple.
Examples
--------
>>> result = tono.apply("GaussianFilter", field, sigma=3.0)
>>> leveled, mask = tono.apply("PlaneLevelField", field, mode="subtract_mean")
"""
from backend.node_registry import NODE_CLASS_MAPPINGS
from backend.execution_context import execution_callbacks, active_node
_ensure_registry()
if class_name not in NODE_CLASS_MAPPINGS:
raise KeyError(
f"Unknown node {class_name!r}. "
f"Use tono.nodes() to list available nodes."
)
cls = NODE_CLASS_MAPPINGS[class_name]
instance = cls()
# Map positional args to required input names in declaration order
if args:
input_types = cls.INPUT_TYPES()
required_names = list(input_types.get("required", {}).keys())
for i, arg in enumerate(args):
if i >= len(required_names):
raise TypeError(
f"{class_name} has {len(required_names)} required inputs, "
f"but {len(args)} positional arguments were given"
)
name = required_names[i]
if name in kwargs:
raise TypeError(
f"{class_name}: input {name!r} specified both as positional "
f"argument and keyword argument"
)
kwargs[name] = arg
func = getattr(instance, cls.FUNCTION)
# Run inside an execution context so emit_* calls are no-ops
with execution_callbacks(), active_node("api"):
result = func(**kwargs)
if not isinstance(result, tuple):
result = (result,)
return result[0] if len(result) == 1 else result
def field(data: Any, xreal: float = 1e-6, yreal: float = 1e-6,
si_unit_xy: str = "m", si_unit_z: str = "m", **kwargs: Any) -> DataField:
"""Create a DataField from a 2D array.
A convenience wrapper around ``DataField(...)`` with sensible defaults.
Parameters
----------
data : array_like
2D numpy array or anything convertible to one.
xreal : float
Physical width in metres (default 1 um).
yreal : float
Physical height in metres (default 1 um).
si_unit_xy : str
Lateral unit string (default "m").
si_unit_z : str
Height/value unit string (default "m").
**kwargs
Additional DataField keyword arguments.
Returns
-------
DataField
"""
import numpy as np
return DataField(
data=np.asarray(data, dtype=np.float64),
xreal=xreal,
yreal=yreal,
si_unit_xy=si_unit_xy,
si_unit_z=si_unit_z,
**kwargs,
)

View File

@@ -8,17 +8,17 @@
### First-time setup
```bash
# Install Python dependencies
pip install -e .
# Install Python dependencies (server extra required for the web app)
pip install -e ".[server]"
# Install frontend dependencies
npm install
# For running tests
pip install -e ".[dev]"
pip install -e ".[server,dev]"
# For building desktop executables
pip install -e ".[desktop]"
pip install -e ".[server,desktop]"
```
Using a virtual environment is recommended:
@@ -26,7 +26,7 @@ Using a virtual environment is recommended:
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev,desktop]"
pip install -e ".[server,dev,desktop]"
```
---

212
docs/library.md Normal file
View File

@@ -0,0 +1,212 @@
# Using tono as a Python library
tono's processing nodes can be used as a standalone Python library for scripting, batch processing, and integration into other tools — no web server required.
## Installation
```bash
pip install -e .
```
This installs the core library with all signal processing dependencies (numpy, scipy, scikit-image, etc.) but **not** the web server (aiohttp). To install the full app with the server, use `pip install -e ".[server]"`.
## Quick start
```python
import tono
# Load an SPM data file
fields = tono.load("scan.gwy")
height = fields[0]
# Apply a processing node
leveled = tono.apply("PlaneLevelField", height)
filtered = tono.apply("GaussianFilter", leveled, sigma=2.0)
# Access the raw numpy array
print(filtered.data.shape) # (256, 256)
print(filtered.data.mean()) # height in metres
```
## API reference
### Loading data
#### `tono.load(path) -> list[DataField]`
Load an SPM data file. Returns one `DataField` per channel.
```python
fields = tono.load("scan.gwy") # Gwyddion
fields = tono.load("image.sxm") # Nanonis
fields = tono.load("data.ibw") # Igor Binary Wave
fields = tono.load("scan.hdf5") # HDF5
fields = tono.load("photo.png") # Standard images
```
#### `tono.channel_names(path) -> list[str]`
Get channel names without loading the full data.
```python
names = tono.channel_names("scan.gwy")
# ['Height', 'Phase', 'Amplitude']
```
#### `tono.supported_formats() -> frozenset[str]`
List all supported file extensions.
### Processing
#### `tono.apply(node_name, *args, **kwargs)`
Run a processing node. Positional arguments are mapped to required inputs in declaration order. Returns a single output if the node has one output, or a tuple if it has multiple.
```python
# Positional: first required input is `field`
result = tono.apply("GaussianFilter", my_field, sigma=3.0)
# All keyword arguments
result = tono.apply("GaussianFilter", field=my_field, sigma=3.0)
# Nodes with multiple outputs return a tuple
field_out, mask_out = tono.apply("ThresholdMask", my_field, method="otsu")
```
#### `tono.get_node(name) -> node_instance`
Get a node instance for direct use. This gives full control over the node's `process()` method.
```python
gauss = tono.get_node("GaussianFilter")
(result,) = gauss.process(field=my_field, sigma=3.0)
```
#### `tono.nodes() -> list[str]`
List all available node class names.
```python
for name in tono.nodes():
print(name)
```
### Creating data
#### `tono.field(data, xreal=1e-6, yreal=1e-6, ...) -> DataField`
Create a `DataField` from a numpy array.
```python
import numpy as np
# From a numpy array
f = tono.field(np.random.randn(256, 256))
# With physical dimensions (10 um x 10 um scan)
f = tono.field(data, xreal=10e-6, yreal=10e-6, si_unit_z="V")
```
### Data types
#### `DataField`
The core 2D data container, analogous to Gwyddion's `GwyDataField`.
| Attribute | Type | Description |
|---|---|---|
| `data` | `np.ndarray` | 2D float64 array (yres x xres) |
| `xres`, `yres` | `int` | Pixel dimensions |
| `xreal`, `yreal` | `float` | Physical dimensions in metres |
| `dx`, `dy` | `float` | Pixel size in metres (property) |
| `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"` |
Key methods:
- `field.copy()` — deep copy
- `field.replace(data=..., si_unit_z=...)` — copy with selected fields replaced
#### Other types
- `LineData` — 1D data with optional x-axis and units
- `RecordTable` — list of `{quantity, value, unit}` dicts (scalar measurements)
- `DataTable` — list of row dicts (tabular data like grain statistics)
## Batch processing example
```python
import tono
from pathlib import Path
input_dir = Path("scans/")
results = {}
for path in input_dir.glob("*.gwy"):
fields = tono.load(path)
height = fields[0]
# Standard processing pipeline
leveled = tono.apply("PlaneLevelField", height)
filtered = tono.apply("GaussianFilter", leveled, sigma=1.5)
# Extract statistics
stats_node = tono.get_node("Statistics")
(table,) = stats_node.process(field=filtered)
results[path.name] = table
print(f"{path.name}: processed {height.xres}x{height.yres} "
f"({height.xreal*1e6:.1f} x {height.yreal*1e6:.1f} um)")
```
## Integration with matplotlib
```python
import tono
import matplotlib.pyplot as plt
import numpy as np
fields = tono.load("scan.gwy")
field = fields[0]
# Convert physical coordinates for axis labels
extent = [0, field.xreal * 1e6, 0, field.yreal * 1e6] # in micrometres
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
axes[0].imshow(field.data * 1e9, extent=extent, cmap="afmhot")
axes[0].set_title("Raw")
axes[0].set_xlabel("x (um)")
axes[0].set_ylabel("y (um)")
leveled = tono.apply("PlaneLevelField", field)
axes[1].imshow(leveled.data * 1e9, extent=extent, cmap="afmhot")
axes[1].set_title("Leveled")
filtered = tono.apply("GaussianFilter", leveled, sigma=2.0)
axes[2].imshow(filtered.data * 1e9, extent=extent, cmap="afmhot")
axes[2].set_title("Filtered")
for ax in axes:
ax.set_xlabel("x (um)")
plt.tight_layout()
plt.savefig("pipeline.png", dpi=150)
```
## Available nodes
Use `tono.nodes()` to list all nodes. Major categories include:
| Category | Example nodes |
|---|---|
| **Level & Correct** | PlaneLevelField, PolyLevelField, FacetLevelField, LineCorrection, DriftCorrection |
| **Filter** | GaussianFilter, MedianFilter, KuwaharaFilter, WaveletDenoise, EdgeDetect |
| **Spectral** | FFT2D, FFT2DInverse, FFTFilter, PSDF, ACF2D, CrossCorrelate |
| **Measure** | Statistics, Histogram, CrossSection, Curvature, FractalDimension |
| **Detect** | FeatureDetection, HoughTransform, TemplateMatch, PixelClassification |
| **Mask** | ThresholdMask, GrainMark, DrawMask, MaskMorphology |
| **Grains** | GrainAnalysis, WatershedSegmentation, GrainDistributions |
| **Geometry** | CropResizeField, RotateField, Resample, AffineCorrection |
| **Tip** | TipModel, TipDeconvolution, BlindTipEstimate |

View File

@@ -7,7 +7,7 @@ tono can be self-hosted on any server with Python 3.10+ and Node.js 18+.
```bash
git clone https://github.com/VIPQualityPost/tono.git && cd tono
python -m venv .venv && source .venv/bin/activate
pip install -e .
pip install -e ".[server]"
cd frontend && npm ci && npm run build && cd ..
TONO_HOST=0.0.0.0 python -m backend.main
```
@@ -31,7 +31,7 @@ sudo chown tono:tono /var/lib/tono
```bash
sudo git clone https://github.com/VIPQualityPost/tono.git /opt/tono
sudo chown -R tono:tono /opt/tono
sudo -u tono bash -c 'cd /opt/tono && python3 -m venv .venv && source .venv/bin/activate && pip install -e .'
sudo -u tono bash -c 'cd /opt/tono && python3 -m venv .venv && source .venv/bin/activate && pip install -e ".[server]"'
sudo -u tono bash -c 'cd /opt/tono/frontend && npm ci && npm run build'
```

77
examples/library_usage.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
Example: using tono as a standalone signal processing library.
Run from the repo root:
python examples/library_usage.py
"""
import numpy as np
import tono
# ── 1. Generate a synthetic surface ──────────────────────────────────
print("Generating synthetic surface...")
surface = tono.apply(
"SyntheticSurface",
pattern="fbm",
xres=256,
yres=256,
xreal=10e-6, # 10 um
yreal=10e-6,
amplitude=50e-9, # 50 nm height range
seed=42,
)
print(f" Shape: {surface.data.shape}")
print(f" Physical size: {surface.xreal*1e6:.1f} x {surface.yreal*1e6:.1f} um")
print(f" Height range: {np.ptp(surface.data)*1e9:.1f} nm")
# ── 2. Level the surface ─────────────────────────────────────────────
print("\nLeveling...")
leveled = tono.apply("PlaneLevelField", surface)
print(f" Mean after leveling: {leveled.data.mean()*1e9:.4f} nm")
# ── 3. Apply a Gaussian filter ───────────────────────────────────────
print("\nFiltering...")
filtered = tono.apply("GaussianFilter", leveled, sigma=2.0)
print(f" Height range after filtering: {np.ptp(filtered.data)*1e9:.1f} nm")
# ── 4. Compute statistics ────────────────────────────────────────────
print("\nStatistics:")
stats_node = tono.get_node("Statistics")
(table,) = stats_node.process(field=filtered)
for row in table:
print(f" {row['quantity']}: {row['value']:.6g} {row.get('unit', '')}")
# ── 5. Edge detection ────────────────────────────────────────────────
print("\nEdge detection...")
edges = tono.apply("EdgeDetect", filtered, method="sobel", sigma=1.0)
print(f" Edge map range: [{edges.data.min():.2f}, {edges.data.max():.2f}]")
# ── 6. Create a DataField from a numpy array ─────────────────────────
print("\nCreating field from numpy array...")
data = np.sin(np.linspace(0, 4 * np.pi, 128).reshape(1, -1)) * \
np.cos(np.linspace(0, 6 * np.pi, 128).reshape(-1, 1))
custom_field = tono.field(data, xreal=5e-6, yreal=5e-6, si_unit_z="V")
print(f" Shape: {custom_field.data.shape}")
print(f" Unit: {custom_field.si_unit_z}")
# ── 7. FFT analysis ──────────────────────────────────────────────────
print("\nFFT of the filtered surface...")
fft_log_mag, fft_mag, fft_phase, fft_psdf = tono.apply("FFT2D", filtered, windowing="hann", level="mean")
print(f" FFT shape: {fft_mag.data.shape}")
print(f" Domain: {fft_mag.domain}")
# ── 8. List available nodes ──────────────────────────────────────────
all_nodes = tono.nodes()
print(f"\nTotal available nodes: {len(all_nodes)}")
print("First 10:", ", ".join(all_nodes[:10]))
print("\nDone!")

View File

@@ -9,7 +9,6 @@ description = "topographical node-based image analysis."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.9,<4",
"gwyfile>=0.2",
"h5py>=3.10,<4",
"igor>=0.3",
@@ -22,6 +21,9 @@ dependencies = [
]
[project.optional-dependencies]
server = [
"aiohttp>=3.9,<4",
]
dev = [
"pytest>=8,<9",
"pytest-cov>=7,<8",
@@ -38,6 +40,9 @@ icons = [
[project.scripts]
tono = "backend.main:main"
[tool.setuptools]
py-modules = ["tono"]
[tool.setuptools.packages.find]
include = ["backend*"]

View File

@@ -28,7 +28,7 @@ echo "Building frontend bundle..."
npm run build
echo "Installing desktop build dependencies..."
uv pip install -e ".[desktop]"
uv pip install -e ".[server,desktop]"
echo "Packaging desktop app with PyInstaller..."
$PYTHON -m PyInstaller \

View File

@@ -28,7 +28,7 @@ echo "Building frontend bundle..."
npm run build
echo "Installing desktop build dependencies..."
uv pip install -e ".[desktop]"
uv pip install -e ".[server,desktop]"
echo "Packaging desktop app with PyInstaller..."
$PYTHON -m PyInstaller \

View File

@@ -46,7 +46,7 @@ npm run build
Assert-LastExitCode "Frontend build"
Write-Host "Installing desktop build dependencies..."
& uv pip install -e ".[desktop]"
& uv pip install -e ".[server,desktop]"
Assert-LastExitCode "Desktop dependency installation"
$pyInstallerArgs = @(

40
tono.py Normal file
View File

@@ -0,0 +1,40 @@
"""
tono — topographical signal processing library.
This module provides a convenient ``import tono`` entry point for using
tono's processing nodes as a standalone Python library, without running
the web server.
Quick start::
import tono
# Load SPM data
fields = tono.load("scan.gwy")
# Process
result = tono.apply("GaussianFilter", fields[0], sigma=3.0)
# Create a field from a numpy array
import numpy as np
f = tono.field(np.random.randn(256, 256))
See ``backend.api`` for the full API documentation.
"""
from backend.api import ( # noqa: F401
apply,
channel_names,
field,
get_node,
load,
nodes,
supported_formats,
)
from backend.data_types import ( # noqa: F401
DataField,
DataTable,
LineData,
MeshModel,
RecordTable,
)