update readme and add icons
204
README.md
@@ -1,200 +1,40 @@
|
|||||||
# tono
|
# tono
|
||||||
|
|
||||||
tono is a node-based image analysis application with:
|

|
||||||
|
|
||||||
- a Python backend built on `aiohttp`
|
tono is a node-based image processing and analysis application.
|
||||||
- a React + Vite frontend
|
|
||||||
- an optional desktop wrapper built with `pywebview`
|
|
||||||
|
|
||||||
The backend serves node definitions, runs workflows, manages file I/O, and streams previews/results over WebSocket. The frontend provides the graph editor and UI. The desktop build packages both together as a Windows application.
|
It is heavily inspired by [Gwyddion](https://gwyddion.net/), one of the best scientific FOSS programs on the web.
|
||||||
|
|
||||||
## Project Layout
|
## Project layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
tono/
|
tono/
|
||||||
backend/ Python server, execution engine, nodes
|
backend/ Python server, execution engine, nodes
|
||||||
frontend/ React/Vite app
|
frontend/ React/Vite app
|
||||||
|
plugins/ User plugin files (.py)
|
||||||
tests/ Python tests
|
tests/ Python tests
|
||||||
desktop.py Local desktop launcher
|
docs/ Documentation
|
||||||
scripts/ Build helpers, including Windows exe packaging
|
desktop.py Desktop launcher
|
||||||
|
scripts/ Build scripts (macOS, Linux, Windows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Quick start
|
||||||
|
Install a local binary from the Releases section, or run locally:
|
||||||
|
|
||||||
- Python `3.10+`
|
```bash
|
||||||
- Node.js `18+`
|
# Installation
|
||||||
- npm `9+`
|
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
- Windows is recommended for the desktop `.exe` packaging flow
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
## First-Time Setup
|
|
||||||
|
|
||||||
Create a virtual environment if you do not already have one:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m venv .venv
|
|
||||||
```
|
|
||||||
|
|
||||||
Install Python dependencies:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Install Node dependencies from the repo root:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm install
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional extras:
|
## Docs
|
||||||
|
|
||||||
```powershell
|
- [Building](docs/building.md) — setup, dev mode, web deployment, and native desktop builds for macOS, Linux, and Windows
|
||||||
.\.venv\Scripts\python.exe -m pip install -e .[dev]
|
- [Plugins](docs/plugins.md) — writing and uploading custom node plugins
|
||||||
.\.venv\Scripts\python.exe -m pip install -e .[spm]
|
- [Testing](docs/testing.md) — running tests and writing new ones
|
||||||
.\.venv\Scripts\python.exe -m pip install -e .[desktop]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `dev`: test tooling
|
|
||||||
- `spm`: optional SPM/AFM file readers like `gwyfile`, `nanonispy`, and `igor`
|
|
||||||
- `desktop`: desktop launcher and PyInstaller packaging tools
|
|
||||||
|
|
||||||
## Running the Local Web Version
|
|
||||||
|
|
||||||
This is the normal browser-based development flow.
|
|
||||||
|
|
||||||
In terminal 1, start the backend:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run backend
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the Python server at `http://127.0.0.1:8188`.
|
|
||||||
|
|
||||||
In terminal 2, start the Vite frontend:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the Vite URL shown in the terminal, typically:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- The frontend dev server proxies API and WebSocket requests to the backend.
|
|
||||||
- `npm run dev` now clears Vite's local cache and stale Python bytecode first, then starts Vite with `--force`.
|
|
||||||
- If you open the backend directly in a browser instead of the Vite dev server, tono now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`.
|
|
||||||
- If you want the frontend accessible from other devices on your LAN, run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run dev -- --host 0.0.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Local Desktop Version
|
|
||||||
|
|
||||||
The desktop launcher starts the Python server internally and opens a native window with `pywebview`.
|
|
||||||
`npm run desktop` now rebuilds the frontend first so the native app always uses a fresh `frontend/dist`.
|
|
||||||
|
|
||||||
Launch the desktop app from source:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- `npm run build` clears stale frontend output, Vite cache, and Python bytecode before producing `frontend/dist`.
|
|
||||||
|
|
||||||
## Building the Windows `.exe`
|
|
||||||
|
|
||||||
The repo includes a packaging script that:
|
|
||||||
|
|
||||||
1. builds the frontend
|
|
||||||
2. installs desktop build dependencies
|
|
||||||
3. runs PyInstaller
|
|
||||||
|
|
||||||
Build the desktop bundle:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run build:desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
Or run the script directly:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File scripts\build-desktop.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
The packaged app is written to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
desktop-dist/tono/
|
|
||||||
```
|
|
||||||
|
|
||||||
Main executable:
|
|
||||||
|
|
||||||
```text
|
|
||||||
desktop-dist/tono/tono.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
### One-File Build
|
|
||||||
|
|
||||||
The default build uses PyInstaller `--onedir`, which is more reliable for scientific Python packages like NumPy, SciPy, and Matplotlib.
|
|
||||||
|
|
||||||
If you still want to try a single-file executable:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File scripts\build-desktop.ps1 -OneFile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Directories
|
|
||||||
|
|
||||||
During normal source-based development, input/output folders live under the repo root.
|
|
||||||
|
|
||||||
In the packaged desktop app, writable data is stored under:
|
|
||||||
|
|
||||||
```text
|
|
||||||
%LOCALAPPDATA%\tono\
|
|
||||||
```
|
|
||||||
|
|
||||||
Specifically:
|
|
||||||
|
|
||||||
```text
|
|
||||||
%LOCALAPPDATA%\tono\input
|
|
||||||
%LOCALAPPDATA%\tono\output
|
|
||||||
```
|
|
||||||
|
|
||||||
You can override the packaged app data directory with:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TONO_APPDATA="C:\path\to\custom\data"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Useful Commands
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run dev
|
|
||||||
npm run build
|
|
||||||
npm run preview
|
|
||||||
npm run backend
|
|
||||||
npm run desktop
|
|
||||||
npm run build:desktop
|
|
||||||
.\.venv\Scripts\python.exe -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the Python test suite with:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## Known Notes
|
|
||||||
|
|
||||||
- The frontend production build currently emits a large chunk warning from Vite. This does not block builds.
|
|
||||||
- The desktop app relies on WebView2 on Windows through `pywebview`.
|
|
||||||
- Optional SPM readers are not installed unless you explicitly install the `spm` extra.
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def main() -> None:
|
|||||||
app = create_app(loop)
|
app = create_app(loop)
|
||||||
|
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
log.info(" tono — Node-based image analysis")
|
log.info(" tono - topographical node-based image analysis")
|
||||||
log.info(" Open your browser at http://%s:%d", HOST, PORT)
|
log.info(" Open your browser at http://%s:%d", HOST, PORT)
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|||||||
167
docs/building.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Building
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
**Python** ≥ 3.10
|
||||||
|
**Node.js** ≥ 18, **npm** ≥ 9
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Python dependencies
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Install frontend dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# For running tests
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# For building desktop executables
|
||||||
|
pip install -e ".[desktop]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Using a virtual environment is recommended:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -e ".[dev,desktop]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Python backend (http://127.0.0.1:8188)
|
||||||
|
npm run backend
|
||||||
|
|
||||||
|
# Terminal 2 — Vite frontend with hot-reload
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the URL printed by Vite (typically `http://localhost:5173`).
|
||||||
|
|
||||||
|
Changes to Python files take effect after restarting the backend. Changes to frontend files hot-reload automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web deployment
|
||||||
|
|
||||||
|
Build the frontend bundle and serve the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build frontend to frontend/dist/
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start the server (serves the built frontend at /)
|
||||||
|
python -m backend.main
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens on `http://127.0.0.1:8188` by default. It serves the built frontend from `frontend/dist/` and exposes the REST + WebSocket API.
|
||||||
|
|
||||||
|
The web mode is a multi-session server: each browser tab gets its own session and isolated file workspace. Local filesystem access is disabled (users upload files through the browser).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop app
|
||||||
|
|
||||||
|
The desktop app uses pywebview to embed the frontend in a native window. The Python server runs in a background thread; the app picks a free port automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build frontend + launch desktop app
|
||||||
|
npm run desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
This is equivalent to `npm run build && python desktop.py`.
|
||||||
|
|
||||||
|
In desktop mode `allow_local_filesystem=True`, which means:
|
||||||
|
- Users can open files directly from their filesystem
|
||||||
|
- The plugin system is enabled (see [plugins.md](plugins.md))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building desktop executables
|
||||||
|
|
||||||
|
The build scripts use PyInstaller to produce a self-contained executable. They build the frontend first, then package everything (Python runtime, backend, `frontend/dist/`, `demo/`) into a single distributable.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
Produces a `.app` bundle and a `.dmg` installer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:mac
|
||||||
|
# Output: desktop-dist/tono.dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
```bash
|
||||||
|
bash scripts/build-mac.sh --onefile # Single executable instead of bundle
|
||||||
|
bash scripts/build-mac.sh --no-dmg # Skip DMG creation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Produces a `.tar.gz` archive containing the app directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:linux
|
||||||
|
# Output: desktop-dist/tono-linux.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
```bash
|
||||||
|
bash scripts/build-linux.sh --onefile # Single executable
|
||||||
|
bash scripts/build-linux.sh --no-tar # Skip archive creation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Produces a `tono.exe` inside an output folder.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build:windows
|
||||||
|
# Output: desktop-dist\tono\tono.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\build-windows.ps1 -OneFile
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Run the build scripts from the repo root. They expect a `.venv` at the repo root; if not found, they fall back to the system `python` / `python3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime data directories
|
||||||
|
|
||||||
|
| Mode | Directory |
|
||||||
|
|---|---|
|
||||||
|
| Development | Repo root (`input/`, `output/`, `plugins/`) |
|
||||||
|
| macOS packaged | `~/Library/Application Support/tono/` |
|
||||||
|
| Linux packaged | `~/.local/share/tono/` |
|
||||||
|
| Windows packaged | `%LOCALAPPDATA%\tono\` |
|
||||||
|
|
||||||
|
Override with the `TONO_APPDATA` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TONO_APPDATA=/my/data/dir python desktop.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key npm scripts summary
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Start Vite dev server + Python backend |
|
||||||
|
| `npm run backend` | Start 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 |
|
||||||
|
| `npm run build:mac` | Build macOS `.dmg` |
|
||||||
|
| `npm run build:linux` | Build Linux `.tar.gz` |
|
||||||
|
| `npm run build:windows` | Build Windows `.exe` |
|
||||||
331
docs/plugins.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# 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.
|
||||||
148
docs/testing.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
python -m pytest -q
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
python -m pytest -q --cov=backend --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
python -m pytest tests/node_tests/gaussian_filter.py -v
|
||||||
|
|
||||||
|
# Run a single test by name
|
||||||
|
python -m pytest tests/test_grains.py::test_threshold_otsu_bimodal -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite is configured in `pytest.ini` at the repo root. All tests under `tests/` are collected automatically, with the exception of private files (names starting with `_`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
node_tests/ # One file per node or node group
|
||||||
|
_shared.py # Shared helpers (not collected as tests)
|
||||||
|
gaussian_filter.py
|
||||||
|
fft_2d.py
|
||||||
|
...
|
||||||
|
test_grains.py # Integration tests
|
||||||
|
test_fft.py
|
||||||
|
test_session_runtime.py
|
||||||
|
test_frontend_build.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**`tests/node_tests/`** contains per-node unit tests. Each file exercises a single node class or closely related group using the execution engine. Files are auto-collected by pytest; files whose names start with `_` are excluded.
|
||||||
|
|
||||||
|
**`tests/`** (top level) contains broader integration tests that cut across multiple nodes or test server-level behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writing tests
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import backend.nodes # registers all built-in nodes as a side-effect
|
||||||
|
from backend.execution import ExecutionEngine
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
```
|
||||||
|
|
||||||
|
`import backend.nodes` must appear before any test that uses a built-in node, because node registration happens at import time via `@register_node`.
|
||||||
|
|
||||||
|
### The `make_field` helper
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tests.node_tests._shared import make_field
|
||||||
|
|
||||||
|
# Default: 64×64 random field, xreal=yreal=1e-6 m, units "m"/"m"
|
||||||
|
field = make_field()
|
||||||
|
|
||||||
|
# Custom shape and physical size
|
||||||
|
field = make_field(shape=(128, 256), xreal=5e-6, yreal=5e-6)
|
||||||
|
|
||||||
|
# Custom data
|
||||||
|
field = make_field(data=np.zeros((32, 32)))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Executing a node
|
||||||
|
|
||||||
|
Use the `ExecutionEngine` with the prompt format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_my_node():
|
||||||
|
engine = ExecutionEngine()
|
||||||
|
prompt = {
|
||||||
|
"1": {
|
||||||
|
"class_type": "GaussianFilter",
|
||||||
|
"inputs": {
|
||||||
|
"field": make_field(), # pass objects directly in tests
|
||||||
|
"sigma": 1.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputs = engine.execute(prompt)
|
||||||
|
result = outputs["1"][0] # first output of node "1"
|
||||||
|
assert result.data.shape == (64, 64)
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs are returned as a dict mapping node id → tuple of output values, in the same order as `OUTPUTS`.
|
||||||
|
|
||||||
|
### Linking nodes
|
||||||
|
|
||||||
|
To chain nodes, use a `[node_id, slot_index]` link in the inputs dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
prompt = {
|
||||||
|
"1": {"class_type": "GaussianFilter", "inputs": {"field": make_field(), "sigma": 1.5}},
|
||||||
|
"2": {"class_type": "PlaneLevelField", "inputs": {"field": ["1", 0]}},
|
||||||
|
}
|
||||||
|
outputs = engine.execute(prompt)
|
||||||
|
result = outputs["2"][0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing your own node class directly
|
||||||
|
|
||||||
|
You can also instantiate and call a node class directly without the engine:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.node_registry import register_node
|
||||||
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
def test_process_directly():
|
||||||
|
field = make_field()
|
||||||
|
node = MyNode()
|
||||||
|
result, = node.process(field=field, sigma=2.0)
|
||||||
|
assert isinstance(result, DataField)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assertions on DataField
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = outputs["1"][0]
|
||||||
|
|
||||||
|
assert isinstance(result, DataField)
|
||||||
|
assert result.data.shape == (64, 64)
|
||||||
|
assert result.si_unit_z == "m"
|
||||||
|
assert np.isfinite(result.data).all()
|
||||||
|
|
||||||
|
# Physical dimensions are preserved by field.replace()
|
||||||
|
assert result.xreal == field.xreal
|
||||||
|
assert result.yreal == field.yreal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
Coverage is configured in `pyproject.toml` under `[tool.coverage]`. It measures the `backend` package and excludes `backend/nodes/__init__.py`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q --cov=backend --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
The report shows which lines are not exercised by any test.
|
||||||
@@ -2130,6 +2130,24 @@ function Flow() {
|
|||||||
input.click();
|
input.click();
|
||||||
}, [applyWorkflowData]);
|
}, [applyWorkflowData]);
|
||||||
|
|
||||||
|
const uploadPlugin = useCallback(() => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.py';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
setStatus({ text: 'Uploading plugin…', level: 'info' });
|
||||||
|
try {
|
||||||
|
await api.uploadPlugin(file);
|
||||||
|
// Node list refresh is handled by the nodes_updated WebSocket message.
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({ text: err.message, level: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── Drag-and-drop workflow image loading ───────────────────────────
|
// ── Drag-and-drop workflow image loading ───────────────────────────
|
||||||
|
|
||||||
const onDropFile = useCallback(async (event) => {
|
const onDropFile = useCallback(async (event) => {
|
||||||
@@ -2820,6 +2838,9 @@ function Flow() {
|
|||||||
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
|
<button className="btn" onClick={copySnapshot} title="Copy workflow screenshot to clipboard">
|
||||||
⎘ Snapshot
|
⎘ Snapshot
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn" onClick={uploadPlugin} title="Upload a plugin (.py)">
|
||||||
|
⊕ Plugin
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`status-bar ${status.level}`}>{status.text}</div>
|
<div className={`status-bar ${status.level}`}>{status.text}</div>
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ export async function uploadFile(file, { relativePath = '' } = {}) {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadPlugin(file) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const r = await fetch('/upload-plugin', { method: 'POST', body: fd });
|
||||||
|
if (r.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})`);
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getChannels(filepath) {
|
export async function getChannels(filepath) {
|
||||||
const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`);
|
const r = await sessionFetch(`/channels?file=${encodeURIComponent(filepath)}`);
|
||||||
if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }];
|
if (!r.ok) return [{ name: 'field', type: 'DATA_FIELD' }];
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "tono"
|
name = "tono"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Node-based image analysis app with a Python backend and React frontend."
|
description = "topographical node-based image analysis."
|
||||||
readme = "GWYDDION_FEATURE_GAP.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp>=3.9,<4",
|
"aiohttp>=3.9,<4",
|
||||||
@@ -29,6 +29,10 @@ desktop = [
|
|||||||
"pyinstaller>=6,<7",
|
"pyinstaller>=6,<7",
|
||||||
"pywebview>=5,<6",
|
"pywebview>=5,<6",
|
||||||
]
|
]
|
||||||
|
icons = [
|
||||||
|
"svglib>=1.6.0",
|
||||||
|
"reportlab>=4.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["backend*"]
|
include = ["backend*"]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 46 KiB |
BIN
resources/icon_1024.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
142
resources/make_icons.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate icon files from an SVG source.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
SVG → 1024×1024 PNG (master, saved as icon_1024.png)
|
||||||
|
→ scaled PNGs: 512, 256, 128, 64, 32, 16
|
||||||
|
→ resources/icon.icns (macOS, via iconutil)
|
||||||
|
→ resources/icon.ico (Windows, via Pillow)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python resources/make_icons.py path/to/icon.svg
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
pip install pillow
|
||||||
|
brew install librsvg # provides rsvg-convert (SVG → PNG)
|
||||||
|
macOS: iconutil # pre-installed
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Pillow is required: pip install pillow")
|
||||||
|
|
||||||
|
|
||||||
|
RESOURCES = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Sizes derived from the 1024 master. 16 is added for the .icns iconset.
|
||||||
|
SCALED_SIZES = [512, 256, 128, 64, 32, 16]
|
||||||
|
|
||||||
|
# macOS iconset: filename → source pixel size
|
||||||
|
ICONSET_MAP = {
|
||||||
|
"icon_16x16.png": 16,
|
||||||
|
"icon_16x16@2x.png": 32,
|
||||||
|
"icon_32x32.png": 32,
|
||||||
|
"icon_32x32@2x.png": 64,
|
||||||
|
"icon_128x128.png": 128,
|
||||||
|
"icon_128x128@2x.png": 256,
|
||||||
|
"icon_256x256.png": 256,
|
||||||
|
"icon_256x256@2x.png": 512,
|
||||||
|
"icon_512x512.png": 512,
|
||||||
|
"icon_512x512@2x.png": 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sizes embedded in the .ico file (Windows; standard ICO max is 256)
|
||||||
|
ICO_SIZES = [256, 128, 64, 32, 16]
|
||||||
|
|
||||||
|
|
||||||
|
def find_rsvg_convert() -> str | None:
|
||||||
|
if path := shutil.which("rsvg-convert"):
|
||||||
|
return path
|
||||||
|
# Homebrew puts it here even when not on PATH
|
||||||
|
for prefix in ("/opt/homebrew", "/usr/local"):
|
||||||
|
candidate = Path(prefix) / "bin" / "rsvg-convert"
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def svg_to_png(rsvg: str, svg_path: Path, out_path: Path, size: int) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
[rsvg, "-w", str(size), "-h", str(size), str(svg_path), "-o", str(out_path)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate .icns and .ico from an SVG.")
|
||||||
|
parser.add_argument("svg", type=Path, help="Source SVG file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
svg_path = args.svg.resolve()
|
||||||
|
if not svg_path.exists():
|
||||||
|
sys.exit(f"SVG not found: {svg_path}")
|
||||||
|
|
||||||
|
rsvg = find_rsvg_convert()
|
||||||
|
if rsvg is None:
|
||||||
|
sys.exit(
|
||||||
|
"rsvg-convert not found.\n"
|
||||||
|
"Install it with: brew install librsvg"
|
||||||
|
)
|
||||||
|
|
||||||
|
RESOURCES.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as _tmp:
|
||||||
|
tmp = Path(_tmp)
|
||||||
|
|
||||||
|
# ── 1. Render SVG → 1024×1024 master PNG ──────────────────────
|
||||||
|
master = tmp / "icon_1024.png"
|
||||||
|
print("Rendering SVG → 1024×1024 PNG…")
|
||||||
|
svg_to_png(rsvg, svg_path, master, 1024)
|
||||||
|
shutil.copy(master, RESOURCES / "icon_1024.png")
|
||||||
|
print(" saved icon_1024.png")
|
||||||
|
|
||||||
|
# ── 2. Scale down to all needed sizes ─────────────────────────
|
||||||
|
print("Scaling…")
|
||||||
|
pngs: dict[int, Path] = {1024: master}
|
||||||
|
with Image.open(master) as base:
|
||||||
|
for size in SCALED_SIZES:
|
||||||
|
out = tmp / f"icon_{size}.png"
|
||||||
|
base.resize((size, size), Image.LANCZOS).save(out)
|
||||||
|
pngs[size] = out
|
||||||
|
print(f" {size:>4}×{size}")
|
||||||
|
|
||||||
|
# ── 3. Build .icns (macOS) ─────────────────────────────────────
|
||||||
|
icns_out = RESOURCES / "icon.icns"
|
||||||
|
if shutil.which("iconutil"):
|
||||||
|
iconset = tmp / "icon.iconset"
|
||||||
|
iconset.mkdir()
|
||||||
|
for filename, size in ICONSET_MAP.items():
|
||||||
|
shutil.copy(pngs[size], iconset / filename)
|
||||||
|
subprocess.run(
|
||||||
|
["iconutil", "-c", "icns", str(iconset), "-o", str(icns_out)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(" saved icon.icns")
|
||||||
|
else:
|
||||||
|
print(" iconutil not found — skipping icon.icns (run on macOS to generate it)")
|
||||||
|
|
||||||
|
# ── 4. Build .ico (Windows) ────────────────────────────────────
|
||||||
|
ico_out = RESOURCES / "icon.ico"
|
||||||
|
images = [Image.open(pngs[s]).convert("RGBA") for s in ICO_SIZES]
|
||||||
|
images[0].save(
|
||||||
|
ico_out,
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(s, s) for s in ICO_SIZES],
|
||||||
|
append_images=images[1:],
|
||||||
|
)
|
||||||
|
print(" saved icon.ico")
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
version="1.1"
|
version="1.1"
|
||||||
id="svg1"
|
id="svg1"
|
||||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||||
sodipodi:docname="tono.svg"
|
sodipodi:docname="argonode.svg"
|
||||||
inkscape:export-filename="tono.png"
|
inkscape:export-filename="tono.png"
|
||||||
inkscape:export-xdpi="130.05"
|
inkscape:export-xdpi="130.05"
|
||||||
inkscape:export-ydpi="130.05"
|
inkscape:export-ydpi="130.05"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
inkscape:document-units="mm"
|
inkscape:document-units="mm"
|
||||||
inkscape:clip-to-page="false"
|
inkscape:clip-to-page="false"
|
||||||
inkscape:zoom="0.52383534"
|
inkscape:zoom="0.52383534"
|
||||||
inkscape:cx="272.03205"
|
inkscape:cx="272.98655"
|
||||||
inkscape:cy="333.11995"
|
inkscape:cy="333.11995"
|
||||||
inkscape:window-width="1470"
|
inkscape:window-width="1470"
|
||||||
inkscape:window-height="890"
|
inkscape:window-height="890"
|
||||||
@@ -49,28 +49,28 @@
|
|||||||
height="194.43169"
|
height="194.43169"
|
||||||
x="2.5203834"
|
x="2.5203834"
|
||||||
y="2.9819231"
|
y="2.9819231"
|
||||||
rx="60.325001"
|
rx="52.916668"
|
||||||
ry="60.325001" />
|
ry="52.916668" />
|
||||||
<path
|
<path
|
||||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:3.95095;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:8.251;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
d="m 78.923283,116.954 85.556777,-87.012977 4.46561,16.665896 -16.7032,-4.47561"
|
d="m 80.438549,119.47945 85.556771,-87.012984 4.46561,16.665896 -16.7032,-4.47561"
|
||||||
id="path6"
|
id="path6"
|
||||||
sodipodi:nodetypes="cccc" />
|
sodipodi:nodetypes="cccc" />
|
||||||
<path
|
<path
|
||||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:3.95096;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:3.95096;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
d="M 130.73507,63.406954 H 100.71909 L 47.805771,117.40532 77.48181,116.89278"
|
d="M 133.7656,68.457841 H 103.74962 L 50.836303,122.45621 80.512342,121.94367"
|
||||||
id="path7"
|
id="path7"
|
||||||
sodipodi:nodetypes="cccc" />
|
sodipodi:nodetypes="cccc" />
|
||||||
<text
|
<text
|
||||||
xml:space="preserve"
|
xml:space="preserve"
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:68.9567px;line-height:0;font-family:Futura;-inkscape-font-specification:'Futura, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2.75827;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:80px;line-height:0;font-family:Futura;-inkscape-font-specification:'Futura, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2.75827;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
x="22.05061"
|
x="18.009901"
|
||||||
y="162.24162"
|
y="172.8485"
|
||||||
id="text8"><tspan
|
id="text8"><tspan
|
||||||
sodipodi:role="line"
|
sodipodi:role="line"
|
||||||
id="tspan8"
|
id="tspan8"
|
||||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:68.9567px;font-family:Futura;-inkscape-font-specification:'Futura, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:2.75827;stroke-dasharray:none"
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:80px;font-family:Futura;-inkscape-font-specification:'Futura, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:2.75827;stroke-dasharray:none"
|
||||||
x="22.05061"
|
x="18.009901"
|
||||||
y="162.24162">argo</tspan></text>
|
y="172.8485">tono</tspan></text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |