File size: 8,156 Bytes
16760fa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | ---
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).
|