| --- |
| title: Toaster Demo |
| emoji: π₯ |
| colorFrom: red |
| colorTo: gray |
| sdk: docker |
| app_port: 7860 |
| pinned: false |
| --- |
| |
| <!-- The YAML block above is Hugging Face Space metadata. It lives ONLY on the |
| hf-deploy branch (pushed to the Space), so it never appears on GitHub's main. --> |
| |
| # Toaster |
|
|
| [](https://github.com/augustin-bresset/toaster/actions/workflows/ci.yml) |
| [](LICENSE) |
| [](pyproject.toml) |
|
|
| Annotate lidar **point clouds** in 3D β walk through them, select points one by |
| one or by zone, assign semantic classes β and, its headline feature, **plug in |
| any model that groups points together** (clustering like DBSCAN, or neural-net |
| inference) so that **clicking one cluster labels the whole group at once**. |
|
|
| ## The idea in one picture |
|
|
| A clustering/segmentation model and a manual zone selection are the *same thing*: |
| both produce **groups of points**. So Toaster keeps two layers strictly apart: |
|
|
| | Layer | Object | Nature | |
| |---|---|---| |
| | Grouping | `Grouping` (`group_id`, `-1` = noise) | **transient**, produced by a model, disposable | |
| | Annotation | `labels` (one class per point) | **persistent** β the only thing saved | |
|
|
| `Selection` is the bridge: `Grouping β Selection β labels`. Run a segmenter to |
| get a grouping, click a cluster to select its whole group, assign a class. |
|
|
| ## Install |
|
|
| ```bash |
| git clone https://github.com/augustin-bresset/toaster && cd toaster |
| uv venv && uv pip install -e ".[dev]" # or: pip install -e ".[dev]" |
| ``` |
|
|
| Optional extras: `csf` (CSF ground detection), `hdbscan`, `open3d` (robust |
| `.pcd`), `apairo` (load apairo datasets), `models` (ONNX), `torch`, `viewer3d` |
| (legacy PyVista backend). |
|
|
| ## Run the app |
|
|
| `toaster` opens a **native desktop window** (the web UI in a pywebview shell); |
| `toaster-web` serves the **same UI for a plain browser**. |
|
|
| ```bash |
| python examples/make_sample.py # writes examples/sample.bin |
| toaster examples/sample.bin # native window β or .ply / .las / .laz / .pcd |
| toaster-web # no file? a file browser opens; UI at http://127.0.0.1:8000 |
| ``` |
|
|
| Launched **without a path**, a built-in file browser opens on the working |
| directory: click into folders, or type a path with **Tab**-completion. |
|
|
| ### Select and label |
|
|
| - **Point** mode: click a point to select it β the whole cluster if a grouping |
| is active. **Shift** adds, **Ctrl** subtracts. |
| - **Box** mode: drag a box; it stays drawn so you can **double-click inside it** |
| to label the whole box. (Right-drag still orbits the camera.) |
| - **Voxel** mode: a transparent grid of occupied cells; click one to select its |
| points (cell size is configurable). |
| - **Label in one gesture**: **double-click** (left *or* right) a cluster, point, |
| voxel, or box to stamp the **active class** β no separate Assign step. |
| - Or select, then **Assign** (toolbar) / **Enter** / the number key shown beside |
| the class. **Ctrl+Z / Ctrl+Shift+Z** undo/redo. **Save** writes labels beside |
| the cloud (`<cloud>.toaster.npy`), restored on reopen. |
|
|
| ### Segment, then label whole clusters |
|
|
| The *Segmenter* panel runs a model (optionally scoped to the current selection); |
| the result becomes the active **grouping**. The *Segments* window lists each |
| group β toggle a group's visibility (hidden ones grey out, while points you have |
| **already labelled keep their class colour**), **Assign checked** labels every |
| visible group at once, or double-click a group to label just it. Closing the |
| window discards the grouping; the labels it helped produce stay. |
|
|
| Built-in segmenters: clustering β `dbscan`, `hdbscan`, `kmeans`, `kmedoids`, |
| `agglomerative`, `optics`, `meanshift`; ground detection β `ransac_ground`, |
| `ground_grid`, `csf` (with the `csf` extra). Heavy clusterers stay usable on |
| large clouds by clustering a bounded subsample, then assigning the rest to the |
| nearest cluster. |
|
|
| ### Classes, display, themes |
|
|
| - The *Classes* panel (+ its β manager) adds / renames / recolours / removes |
| classes; the highlighted one is the active brush. |
| - Colour the cloud by **Labels / Grouping / Intensity / Height**; tune point size. |
| - Three themes, top-right β **Toaster**, **CafΓ© Toaster**, **Arcade Quest** β each |
| with its own animated logo. |
|
|
| ## Use it as a library (headless) |
|
|
| `toaster.core` is numpy-only and never imports a GUI, so it works in a script or |
| a pipeline: |
|
|
| ```python |
| import numpy as np |
| from toaster.io import load_cloud |
| from toaster.core import Selection, AnnotationController |
| from toaster.segment import get_segmenter |
| |
| cloud = load_cloud("scan.ply") |
| cloud.ensure_labels() |
| |
| # Cluster, then label whole clusters programmatically. |
| grouping = get_segmenter("dbscan", eps=0.4, min_samples=12).segment(cloud) |
| ann = AnnotationController(cloud) # the single writer of cloud.labels |
| for gid in grouping.group_ids(): |
| ann.assign(Selection.from_group(grouping, gid), class_id=4) # e.g. "vehicle" |
| |
| np.save("scan.labels.npy", cloud.labels) |
| ``` |
|
|
| ## Extend it β the two seams |
|
|
| **A custom segmenter** (anything that groups points): |
|
|
| ```python |
| from toaster.segment import register_segmenter, scatter |
| from toaster.segment.base import resolve_points |
| |
| @register_segmenter |
| class SliceByHeight: |
| name = "height_slices" |
| def __init__(self, step: float = 1.0): |
| self.step = step |
| def segment(self, cloud, selection=None): |
| xyz, indices = resolve_points(cloud, selection) |
| group_ids = (xyz[:, 2] / self.step).astype(int) |
| return scatter(group_ids, indices, cloud.n, source=self.name) |
| ``` |
|
|
| **I already have a Python model that labels points.** One call registers it as a |
| named segmenter β its predicted classes become groups *and* `suggested_labels`: |
|
|
| ```python |
| # my_segmenters.py |
| from toaster.segment import register_model |
| import my_net |
| |
| def predict(points): # points is (M, 3+F); returns (M,) class ids |
| return my_net.run(points) # torch / ONNX / sklearn β anything |
| |
| register_model("my_net", predict, feature_keys=["intensity"], ignore_id=0) |
| ``` |
|
|
| In a script, import the module then `get_segmenter("my_net")`. To surface it in |
| the app, import it at launch with `--plugin`: |
|
|
| ```bash |
| toaster scan.ply --plugin my_segmenters # native window |
| toaster-web --plugin my_segmenters # browser |
| ``` |
|
|
| **A custom loader** (a new file format): |
|
|
| ```python |
| from toaster.io import register_loader |
| from toaster.core import PointCloud |
| |
| class XyzLoader: |
| extensions = (".xyz",) |
| def load(self, path): |
| import numpy as np |
| return PointCloud(xyz=np.loadtxt(path, dtype="float32")[:, :3], source=path) |
| |
| register_loader(XyzLoader()) |
| ``` |
|
|
| ## Architecture |
|
|
| ``` |
| toaster/ |
| core/ domain β numpy-only, headless, 100% unit-tested |
| io/ pluggable loaders (registry): .ply/.bin/.las/.laz/.pcd (+apairo) |
| segment/ pluggable segmenters (registry): clustering + ground detection |
| persistence/ label / schema / session sidecars |
| interaction/ headless controller (select -> assign workflow) + flat snapshot |
| api/ FastAPI service + REST app + numpy wire codec # toaster-web |
| web/ vanilla Three.js front-end (no build step) |
| desktop.py native window via pywebview # toaster |
| viewer/ optional PyVista backend behind a Viewer protocol # viewer3d extra |
| ``` |
|
|
| Dependency rule: `core` depends on nothing; `io / segment / persistence` depend |
| only on `core`; `interaction` glues `core` to a `Viewer` protocol but stays |
| headless (the web build drives it through a `NullViewer`); `api` + `web` are the |
| front-end. The browser only ever receives numpy arrays and a flat snapshot β |
| never colour buffers β so the renderer is fully client-side and replaceable. |
|
|
| ## Development |
|
|
| ```bash |
| make check # ruff (lint + format) + pytest β the same checks CI runs |
| ``` |
|
|
| CI runs lint, the format check and the test suite on Python 3.11 and 3.12 for |
| every push and pull request. Contributions are welcome β see |
| [CONTRIBUTING.md](CONTRIBUTING.md). |
|
|