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

[![CI](https://github.com/augustin-bresset/toaster/actions/workflows/ci.yml/badge.svg)](https://github.com/augustin-bresset/toaster/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](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).