feat(api,web): delta label protocol — labelling is now O(edit), not O(cloud)
Browse filesEvery label edit (assign, undo, redo, group assigns, apply-suggested) used
to ship the full label array back and recompute + re-upload colours for
every point — ~160 MB of work per click on a 10M-point cloud. Now:
- InteractionController's label-editing methods return the touched indices.
- AnnotationService.state(touched=...) responds with labels_delta
{indices, values} instead of the full labels/grouping arrays.
- The client patches its cached labels, recolours only the touched points
(colors.js: makeLabelColorizer is the single per-point rule shared by the
full recompute and the delta path, so they cannot drift), re-evaluates
visibility only where it can change (touched + both selections), and
uploads just the coalesced dirty ranges (viewer.commitColors).
- Isolate mode falls back to the full recompute — its alpha is a function
of the whole selection, not of the edit.
tests/test_web_ui.py drives the real stack headless (worker octree, motion
cut, pick equivalence) and asserts the delta-patched buffers are
byte-identical to a from-scratch full-state re-render, with and without
the hide-labelled visibility mask, including across undo.
- tests/test_api.py +15 -5
- tests/test_controller.py +11 -11
- tests/test_web_ui.py +258 -0
- toaster/api/service.py +28 -18
- toaster/interaction/controller.py +27 -18
- toaster/web/js/app.js +81 -3
- toaster/web/js/colors.js +29 -14
- toaster/web/js/viewer.js +51 -0
|
@@ -62,9 +62,13 @@ def test_segment_then_assign_group(client):
|
|
| 62 |
sel = client.post("/api/group/select", json={"group_id": gid}).json()
|
| 63 |
assert decode_array(sel["selection"]).size == snap["segments"][0]["count"]
|
| 64 |
|
| 65 |
-
# Label that segment with class 4.
|
| 66 |
after = client.post("/api/group/assign", json={"group_id": gid, "class_id": 4}).json()
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
assert int((labels == 4).sum()) == snap["segments"][0]["count"]
|
| 69 |
|
| 70 |
|
|
@@ -88,8 +92,11 @@ def test_assign_visible_groups_endpoint(client):
|
|
| 88 |
# Uncheck one segment, then assign class 3 to the visible (checked) ones.
|
| 89 |
client.post("/api/group/visibility", json={"group_id": hide["id"], "visible": False})
|
| 90 |
after = client.post("/api/groups/assign_visible", json={"class_id": 3}).json()
|
| 91 |
-
|
| 92 |
# Exactly the visible segment's points became class 3; the hidden one did not.
|
|
|
|
|
|
|
|
|
|
| 93 |
assert int((labels == 3).sum()) == sum(s["count"] for s in segs if s["id"] != hide["id"])
|
| 94 |
|
| 95 |
|
|
@@ -126,9 +133,12 @@ def test_pick_assign_undo(client):
|
|
| 126 |
client.post("/api/active_class", json={"class_id": 2})
|
| 127 |
client.post("/api/pick", json={"index": 5})
|
| 128 |
after = client.post("/api/assign", json={}).json()
|
| 129 |
-
assert decode_array(after["
|
|
|
|
| 130 |
back = client.post("/api/undo").json()
|
| 131 |
-
assert decode_array(back["
|
|
|
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
def test_browse_lists_dir_and_flags_openable(client, tmp_path):
|
|
|
|
| 62 |
sel = client.post("/api/group/select", json={"group_id": gid}).json()
|
| 63 |
assert decode_array(sel["selection"]).size == snap["segments"][0]["count"]
|
| 64 |
|
| 65 |
+
# Label that segment with class 4 — the response is a delta, not full labels.
|
| 66 |
after = client.post("/api/group/assign", json={"group_id": gid, "class_id": 4}).json()
|
| 67 |
+
delta = after["labels_delta"]
|
| 68 |
+
assert decode_array(delta["indices"]).size == snap["segments"][0]["count"]
|
| 69 |
+
assert (decode_array(delta["values"]) == 4).all()
|
| 70 |
+
# The full state still reflects the write.
|
| 71 |
+
labels = decode_array(client.get("/api/state").json()["labels"])
|
| 72 |
assert int((labels == 4).sum()) == snap["segments"][0]["count"]
|
| 73 |
|
| 74 |
|
|
|
|
| 92 |
# Uncheck one segment, then assign class 3 to the visible (checked) ones.
|
| 93 |
client.post("/api/group/visibility", json={"group_id": hide["id"], "visible": False})
|
| 94 |
after = client.post("/api/groups/assign_visible", json={"class_id": 3}).json()
|
| 95 |
+
delta = after["labels_delta"]
|
| 96 |
# Exactly the visible segment's points became class 3; the hidden one did not.
|
| 97 |
+
assert decode_array(delta["indices"]).size == sum(s["count"] for s in segs if s["id"] != hide["id"])
|
| 98 |
+
assert (decode_array(delta["values"]) == 3).all()
|
| 99 |
+
labels = decode_array(client.get("/api/state").json()["labels"])
|
| 100 |
assert int((labels == 3).sum()) == sum(s["count"] for s in segs if s["id"] != hide["id"])
|
| 101 |
|
| 102 |
|
|
|
|
| 133 |
client.post("/api/active_class", json={"class_id": 2})
|
| 134 |
client.post("/api/pick", json={"index": 5})
|
| 135 |
after = client.post("/api/assign", json={}).json()
|
| 136 |
+
assert decode_array(after["labels_delta"]["indices"]).tolist() == [5]
|
| 137 |
+
assert decode_array(after["labels_delta"]["values"]).tolist() == [2]
|
| 138 |
back = client.post("/api/undo").json()
|
| 139 |
+
assert decode_array(back["labels_delta"]["indices"]).tolist() == [5]
|
| 140 |
+
assert decode_array(back["labels_delta"]["values"]).tolist() == [0]
|
| 141 |
+
assert decode_array(client.get("/api/state").json()["labels"])[5] == 0
|
| 142 |
|
| 143 |
|
| 144 |
def test_browse_lists_dir_and_flags_openable(client, tmp_path):
|
|
@@ -127,8 +127,8 @@ def test_assign_group_labels_the_segment(two_clusters, schema):
|
|
| 127 |
viewer = FakeViewer()
|
| 128 |
ctl = InteractionController(session, viewer)
|
| 129 |
ctl.set_active_class(1)
|
| 130 |
-
|
| 131 |
-
assert
|
| 132 |
assert (session.cloud.labels[:50] == 1).all()
|
| 133 |
assert (session.cloud.labels[50:] == 0).all()
|
| 134 |
assert viewer.recolored # labelled points are repainted in any view
|
|
@@ -138,23 +138,23 @@ def test_apply_suggested_single_and_all(two_clusters, schema):
|
|
| 138 |
session = _grouped_session(two_clusters, schema)
|
| 139 |
ctl = InteractionController(session, FakeViewer())
|
| 140 |
# Only group 1 carries a suggestion (-> class 2).
|
| 141 |
-
|
| 142 |
-
assert
|
| 143 |
assert (session.cloud.labels[50:] == 2).all()
|
| 144 |
# Group 0 has no suggestion -> no-op.
|
| 145 |
-
assert ctl.apply_suggested(0) == 0
|
| 146 |
# "All suggested" applies every group that has one.
|
| 147 |
session2 = _grouped_session(two_clusters, schema)
|
| 148 |
ctl2 = InteractionController(session2, FakeViewer())
|
| 149 |
-
assert ctl2.apply_suggested(None) == 50
|
| 150 |
|
| 151 |
|
| 152 |
def test_group_ops_noop_without_active_grouping(two_clusters, schema):
|
| 153 |
two_clusters.ensure_labels(schema.unlabeled_id)
|
| 154 |
session = Session(two_clusters, schema) # no grouping
|
| 155 |
ctl = InteractionController(session, FakeViewer())
|
| 156 |
-
assert ctl.assign_group(0) == 0
|
| 157 |
-
assert ctl.apply_suggested() == 0
|
| 158 |
|
| 159 |
|
| 160 |
def test_group_visibility_commands(two_clusters, schema):
|
|
@@ -187,9 +187,9 @@ def test_assign_visible_groups_labels_only_checked(two_clusters, schema):
|
|
| 187 |
# Uncheck (hide) group 0, then assign -> only the visible group 1 is labelled.
|
| 188 |
ctl.set_group_visibility(0, False)
|
| 189 |
ctl.set_active_class(2)
|
| 190 |
-
|
| 191 |
labels = session.cloud.labels
|
| 192 |
-
assert
|
| 193 |
assert (labels[:50] == schema.unlabeled_id).all() # hidden group untouched
|
| 194 |
assert (labels[50:] == 2).all() # checked group labelled
|
| 195 |
|
|
@@ -204,7 +204,7 @@ def test_clear_grouping_discards_segmentation_keeps_labels(two_clusters, schema)
|
|
| 204 |
ctl.set_display_mode("grouping")
|
| 205 |
# Label a segment first — its labels must survive the grouping being dropped.
|
| 206 |
labelled = ctl.assign_group(0, 1)
|
| 207 |
-
assert labelled == 50
|
| 208 |
|
| 209 |
ctl.clear_grouping()
|
| 210 |
assert session.active_grouping is None
|
|
|
|
| 127 |
viewer = FakeViewer()
|
| 128 |
ctl = InteractionController(session, viewer)
|
| 129 |
ctl.set_active_class(1)
|
| 130 |
+
touched = ctl.assign_group(0)
|
| 131 |
+
assert touched.size == 50
|
| 132 |
assert (session.cloud.labels[:50] == 1).all()
|
| 133 |
assert (session.cloud.labels[50:] == 0).all()
|
| 134 |
assert viewer.recolored # labelled points are repainted in any view
|
|
|
|
| 138 |
session = _grouped_session(two_clusters, schema)
|
| 139 |
ctl = InteractionController(session, FakeViewer())
|
| 140 |
# Only group 1 carries a suggestion (-> class 2).
|
| 141 |
+
touched = ctl.apply_suggested(1)
|
| 142 |
+
assert touched.size == 50
|
| 143 |
assert (session.cloud.labels[50:] == 2).all()
|
| 144 |
# Group 0 has no suggestion -> no-op.
|
| 145 |
+
assert ctl.apply_suggested(0).size == 0
|
| 146 |
# "All suggested" applies every group that has one.
|
| 147 |
session2 = _grouped_session(two_clusters, schema)
|
| 148 |
ctl2 = InteractionController(session2, FakeViewer())
|
| 149 |
+
assert ctl2.apply_suggested(None).size == 50
|
| 150 |
|
| 151 |
|
| 152 |
def test_group_ops_noop_without_active_grouping(two_clusters, schema):
|
| 153 |
two_clusters.ensure_labels(schema.unlabeled_id)
|
| 154 |
session = Session(two_clusters, schema) # no grouping
|
| 155 |
ctl = InteractionController(session, FakeViewer())
|
| 156 |
+
assert ctl.assign_group(0).size == 0
|
| 157 |
+
assert ctl.apply_suggested().size == 0
|
| 158 |
|
| 159 |
|
| 160 |
def test_group_visibility_commands(two_clusters, schema):
|
|
|
|
| 187 |
# Uncheck (hide) group 0, then assign -> only the visible group 1 is labelled.
|
| 188 |
ctl.set_group_visibility(0, False)
|
| 189 |
ctl.set_active_class(2)
|
| 190 |
+
touched = ctl.assign_visible_groups()
|
| 191 |
labels = session.cloud.labels
|
| 192 |
+
assert touched.size == 50
|
| 193 |
assert (labels[:50] == schema.unlabeled_id).all() # hidden group untouched
|
| 194 |
assert (labels[50:] == 2).all() # checked group labelled
|
| 195 |
|
|
|
|
| 204 |
ctl.set_display_mode("grouping")
|
| 205 |
# Label a segment first — its labels must survive the grouping being dropped.
|
| 206 |
labelled = ctl.assign_group(0, 1)
|
| 207 |
+
assert labelled.size == 50
|
| 208 |
|
| 209 |
ctl.clear_grouping()
|
| 210 |
assert session.active_grouping is None
|
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end checks of the web viewer's octree LOD/picking and delta recolour.
|
| 2 |
+
|
| 3 |
+
Drives the real stack — ``toaster-web`` served from THIS checkout, headless
|
| 4 |
+
Chromium via Playwright — against a synthetic cloud big enough to build the
|
| 5 |
+
octree and trip the motion LOD (> 1M points). Skips cleanly when Playwright
|
| 6 |
+
or its browser is unavailable.
|
| 7 |
+
|
| 8 |
+
Invariants covered:
|
| 9 |
+
- the worker-built octree is a permutation of the cloud with well-formed,
|
| 10 |
+
disjoint node slices;
|
| 11 |
+
- camera motion renders the octree cut (a strict subset), idle refines back
|
| 12 |
+
to the full cloud, and the rAF loop parks afterwards (the QtWebEngine
|
| 13 |
+
leak guard);
|
| 14 |
+
- octree pick/pickBox return exactly what the brute-force scans return;
|
| 15 |
+
- after delta-applied label edits (assign, undo), the client's colour/alpha
|
| 16 |
+
buffers are indistinguishable from a from-scratch full-state re-render.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import socket
|
| 23 |
+
import subprocess
|
| 24 |
+
import sys
|
| 25 |
+
import time
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pytest
|
| 30 |
+
|
| 31 |
+
pytest.importorskip("playwright.sync_api")
|
| 32 |
+
from playwright.sync_api import sync_playwright # noqa: E402
|
| 33 |
+
|
| 34 |
+
REPO = Path(__file__).resolve().parent.parent
|
| 35 |
+
N_POINTS = 1_200_000 # > LOD_BUDGET (1M) so the motion cut engages
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _free_port() -> int:
|
| 39 |
+
with socket.socket() as s:
|
| 40 |
+
s.bind(("127.0.0.1", 0))
|
| 41 |
+
return s.getsockname()[1]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.fixture(scope="module")
|
| 45 |
+
def server(tmp_path_factory):
|
| 46 |
+
"""toaster-web on a random port, serving a 1.2M-point synthetic terrain."""
|
| 47 |
+
cloud = tmp_path_factory.mktemp("cloud") / "big.bin"
|
| 48 |
+
rng = np.random.default_rng(7)
|
| 49 |
+
xy = rng.uniform(-50, 50, (N_POINTS, 2)).astype(np.float32)
|
| 50 |
+
z = (np.sin(xy[:, 0] * 0.2) * np.cos(xy[:, 1] * 0.15) + rng.normal(0, 0.1, N_POINTS)).astype(
|
| 51 |
+
np.float32
|
| 52 |
+
)
|
| 53 |
+
i = rng.uniform(0, 1, N_POINTS).astype(np.float32)
|
| 54 |
+
np.column_stack([xy, z, i]).astype(np.float32).tofile(cloud)
|
| 55 |
+
|
| 56 |
+
port = _free_port()
|
| 57 |
+
env = os.environ | {"PYTHONPATH": str(REPO)} # serve THIS checkout, not the installed copy
|
| 58 |
+
proc = subprocess.Popen(
|
| 59 |
+
[sys.executable, "-m", "toaster.api.server", str(cloud), "--port", str(port)],
|
| 60 |
+
env=env,
|
| 61 |
+
cwd=REPO,
|
| 62 |
+
stdout=subprocess.DEVNULL,
|
| 63 |
+
stderr=subprocess.PIPE,
|
| 64 |
+
)
|
| 65 |
+
import urllib.request
|
| 66 |
+
|
| 67 |
+
deadline = time.time() + 30
|
| 68 |
+
while True:
|
| 69 |
+
try:
|
| 70 |
+
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/meta", timeout=1)
|
| 71 |
+
break
|
| 72 |
+
except Exception:
|
| 73 |
+
if proc.poll() is not None:
|
| 74 |
+
pytest.skip(f"server died: {proc.stderr.read().decode()[-500:]}")
|
| 75 |
+
if time.time() > deadline:
|
| 76 |
+
proc.kill()
|
| 77 |
+
pytest.skip("server did not come up in 30s")
|
| 78 |
+
time.sleep(0.3)
|
| 79 |
+
yield f"http://127.0.0.1:{port}"
|
| 80 |
+
proc.terminate()
|
| 81 |
+
proc.wait(timeout=10)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@pytest.fixture(scope="module")
|
| 85 |
+
def page(server):
|
| 86 |
+
"""One loaded page (cloud + octree ready) shared by the module's tests."""
|
| 87 |
+
with sync_playwright() as p:
|
| 88 |
+
try:
|
| 89 |
+
browser = p.chromium.launch()
|
| 90 |
+
except Exception as exc: # no browser installed
|
| 91 |
+
pytest.skip(f"chromium unavailable: {exc}")
|
| 92 |
+
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
| 93 |
+
errors: list[str] = []
|
| 94 |
+
page.on("pageerror", lambda e: errors.append(f"pageerror: {e}"))
|
| 95 |
+
page.on(
|
| 96 |
+
"console",
|
| 97 |
+
lambda m: errors.append(f"console: {m.text}") if m.type == "error" else None,
|
| 98 |
+
)
|
| 99 |
+
page.goto(server)
|
| 100 |
+
page.wait_for_function(
|
| 101 |
+
f"window.__toaster && window.__toaster.viewer.geom && "
|
| 102 |
+
f"window.__toaster.viewer.geom.getAttribute('position').count === {N_POINTS}",
|
| 103 |
+
timeout=90000,
|
| 104 |
+
)
|
| 105 |
+
page.wait_for_function("window.__toaster.viewer._octree !== null", timeout=90000)
|
| 106 |
+
page._errors = errors
|
| 107 |
+
yield page
|
| 108 |
+
assert not errors, f"console/page errors: {errors}"
|
| 109 |
+
browser.close()
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_octree_is_a_permutation_with_wellformed_slices(page):
|
| 113 |
+
ok = page.evaluate(
|
| 114 |
+
f"""(() => {{
|
| 115 |
+
const o = window.__toaster.viewer._octree.data;
|
| 116 |
+
if (o.order.length !== {N_POINTS}) return "bad order length";
|
| 117 |
+
const seen = new Uint8Array({N_POINTS});
|
| 118 |
+
for (let k = 0; k < o.order.length; k++) {{
|
| 119 |
+
if (seen[o.order[k]]) return "duplicate index";
|
| 120 |
+
seen[o.order[k]] = 1;
|
| 121 |
+
}}
|
| 122 |
+
for (let i = 0; i < seen.length; i++) if (!seen[i]) return "missing index";
|
| 123 |
+
for (let ni = 0; ni < o.start.length; ni++) {{
|
| 124 |
+
if (o.start[ni] + o.count[ni] > o.order.length) return "slice out of bounds";
|
| 125 |
+
for (let c = 0; c < 8; c++) {{
|
| 126 |
+
const ch = o.children[ni * 8 + c];
|
| 127 |
+
if (ch >= 0 && o.start[ch] < o.start[ni] + o.count[ni])
|
| 128 |
+
return "child overlaps parent sample";
|
| 129 |
+
}}
|
| 130 |
+
}}
|
| 131 |
+
return "ok";
|
| 132 |
+
}})()"""
|
| 133 |
+
)
|
| 134 |
+
assert ok == "ok"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_motion_draws_octree_cut_then_idle_refines_full(page):
|
| 138 |
+
# A held fly key keeps the view "in motion" for as long as we need to
|
| 139 |
+
# observe it (a drag's 150 ms settle window is shorter than one headless
|
| 140 |
+
# software-GL frame, so the refine would race the assertion).
|
| 141 |
+
page.click("canvas", position={"x": 10, "y": 10})
|
| 142 |
+
page.keyboard.down("w")
|
| 143 |
+
time.sleep(0.4)
|
| 144 |
+
during = page.evaluate(
|
| 145 |
+
"""(() => {
|
| 146 |
+
const v = window.__toaster.viewer;
|
| 147 |
+
return { cut: v.geom.index === v._drawAttr, n: v.geom.drawRange.count };
|
| 148 |
+
})()"""
|
| 149 |
+
)
|
| 150 |
+
page.keyboard.up("w")
|
| 151 |
+
assert during["cut"], "motion frames must render the octree cut"
|
| 152 |
+
assert 0 < during["n"] < N_POINTS, "the cut must be a strict subset"
|
| 153 |
+
|
| 154 |
+
time.sleep(1.5) # settle + slow software-GL frames
|
| 155 |
+
after = page.evaluate(
|
| 156 |
+
"(() => { const v = window.__toaster.viewer;"
|
| 157 |
+
" return v.geom.index === null && !v._lodOn; })()"
|
| 158 |
+
)
|
| 159 |
+
assert after, "idle must refine back to the full cloud"
|
| 160 |
+
|
| 161 |
+
# The rAF loop must park at idle (the QtWebEngine leak guard).
|
| 162 |
+
for _ in range(4):
|
| 163 |
+
assert page.evaluate("window.__toaster.viewer._rafPending") is not True
|
| 164 |
+
time.sleep(0.4)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_octree_picking_matches_brute_force(page):
|
| 168 |
+
out = page.evaluate(
|
| 169 |
+
"""(() => {
|
| 170 |
+
const v = window.__toaster.viewer;
|
| 171 |
+
const pick1 = v.pick(640, 400);
|
| 172 |
+
const box1 = v.pickBox(610, 370, 670, 430);
|
| 173 |
+
const oct = v._octree;
|
| 174 |
+
v._octree = null; // brute-force path
|
| 175 |
+
const pick2 = v.pick(640, 400);
|
| 176 |
+
const box2 = v.pickBox(610, 370, 670, 430);
|
| 177 |
+
v._octree = oct;
|
| 178 |
+
box1.sort((a, b) => a - b);
|
| 179 |
+
box2.sort((a, b) => a - b);
|
| 180 |
+
return {
|
| 181 |
+
pickSame: pick1 === pick2,
|
| 182 |
+
picked: pick1,
|
| 183 |
+
boxSame: box1.length === box2.length && box1.every((x, i) => x === box2[i]),
|
| 184 |
+
boxN: box1.length,
|
| 185 |
+
};
|
| 186 |
+
})()"""
|
| 187 |
+
)
|
| 188 |
+
assert out["pickSame"], "octree pick must match the exact scan"
|
| 189 |
+
assert out["picked"] >= 0
|
| 190 |
+
assert out["boxSame"], "octree pickBox must match the exact scan"
|
| 191 |
+
assert out["boxN"] > 0
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_delta_recolor_matches_full_rerender(page):
|
| 195 |
+
# Box-select a screen region, label it, and compare the delta-patched
|
| 196 |
+
# colour/alpha buffers against a from-scratch full-state re-render —
|
| 197 |
+
# byte-identical or the delta path has drifted from the reference rules.
|
| 198 |
+
# Drive the real UI: box mode, rubber-band drag, double-click to label.
|
| 199 |
+
page.click("button[data-mode-pick=box]")
|
| 200 |
+
page.mouse.move(560, 320)
|
| 201 |
+
page.mouse.down()
|
| 202 |
+
page.mouse.move(720, 480, steps=5)
|
| 203 |
+
page.mouse.up()
|
| 204 |
+
page.wait_for_function(
|
| 205 |
+
"window.__toaster.debug.getState().selection.length > 0", timeout=15000
|
| 206 |
+
)
|
| 207 |
+
page.mouse.dblclick(640, 440) # viewport coords, inside the drawn box — delta path
|
| 208 |
+
page.wait_for_function(
|
| 209 |
+
"window.__toaster.debug.getState().selection.length === 0", timeout=15000
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
def buffers_equal_after_full_refresh() -> dict:
|
| 213 |
+
return page.evaluate(
|
| 214 |
+
"""(async () => {
|
| 215 |
+
const { viewer, debug } = window.__toaster;
|
| 216 |
+
const live = viewer.colorArrays();
|
| 217 |
+
const colors = live.colors.slice();
|
| 218 |
+
const alpha = live.alpha.slice();
|
| 219 |
+
await debug.refresh(); // from-scratch reference render
|
| 220 |
+
const ref = viewer.colorArrays();
|
| 221 |
+
let colorDiff = 0, alphaDiff = 0;
|
| 222 |
+
for (let i = 0; i < colors.length; i++) if (colors[i] !== ref.colors[i]) colorDiff++;
|
| 223 |
+
for (let i = 0; i < alpha.length; i++) if (alpha[i] !== ref.alpha[i]) alphaDiff++;
|
| 224 |
+
const labeled = debug.getState().labels.reduce((a, v) => a + (v !== 0 ? 1 : 0), 0);
|
| 225 |
+
return { colorDiff, alphaDiff, labeled };
|
| 226 |
+
})()"""
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
out = buffers_equal_after_full_refresh()
|
| 230 |
+
assert out["labeled"] > 0, "the double-click must have labelled the box selection"
|
| 231 |
+
assert out["colorDiff"] == 0, f"{out['colorDiff']} colour slots diverged from the reference"
|
| 232 |
+
assert out["alphaDiff"] == 0, f"{out['alphaDiff']} alpha slots diverged from the reference"
|
| 233 |
+
|
| 234 |
+
# Same equivalence with the visibility mask active (hide-labelled points):
|
| 235 |
+
# the delta must now patch alphas too, and keep the hidden-count pill exact.
|
| 236 |
+
page.check("#hide-labeled")
|
| 237 |
+
page.mouse.move(400, 250)
|
| 238 |
+
page.mouse.down()
|
| 239 |
+
page.mouse.move(560, 400, steps=5)
|
| 240 |
+
page.mouse.up()
|
| 241 |
+
page.wait_for_function(
|
| 242 |
+
"window.__toaster.debug.getState().selection.length > 0", timeout=15000
|
| 243 |
+
)
|
| 244 |
+
page.mouse.dblclick(480, 330) # viewport coords, inside the second box
|
| 245 |
+
page.wait_for_function(
|
| 246 |
+
"window.__toaster.debug.getState().selection.length === 0", timeout=15000
|
| 247 |
+
)
|
| 248 |
+
out = buffers_equal_after_full_refresh()
|
| 249 |
+
assert out["colorDiff"] == 0, "colours diverged with the visibility mask on"
|
| 250 |
+
assert out["alphaDiff"] == 0, "alphas diverged with the visibility mask on"
|
| 251 |
+
|
| 252 |
+
# Undo (a delta of the same edit, reversed) must also match the reference.
|
| 253 |
+
page.keyboard.press("Control+z")
|
| 254 |
+
time.sleep(0.5)
|
| 255 |
+
out = buffers_equal_after_full_refresh()
|
| 256 |
+
assert out["colorDiff"] == 0, "colours diverged after an undo delta"
|
| 257 |
+
assert out["alphaDiff"] == 0, "alphas diverged after an undo delta"
|
| 258 |
+
page.uncheck("#hide-labeled")
|
|
@@ -165,17 +165,33 @@ class AnnotationService:
|
|
| 165 |
features = {k: encode_array(v) for k, v in ctl.session.cloud.features.items()}
|
| 166 |
return {"xyz": encode_array(ctl.cloud_xyz()), "features": features}
|
| 167 |
|
| 168 |
-
def state(self) -> dict[str, Any]:
|
| 169 |
-
"""Everything the client needs to (re)colour: snapshot + bulk arrays.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
ctl = self._ctl()
|
| 171 |
-
|
| 172 |
-
return {
|
| 173 |
"snapshot": asdict(ctl.snapshot()),
|
| 174 |
-
"labels": encode_array(ctl.label_array()),
|
| 175 |
-
"grouping": encode_array(grouping) if grouping is not None else None,
|
| 176 |
# int32 so it maps to a JS Int32Array (int64 has no plain TypedArray).
|
| 177 |
"selection": encode_array(ctl.selection_indices().astype(np.int32)),
|
| 178 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
# -- commands ---------------------------------------------------------
|
| 181 |
|
|
@@ -188,8 +204,7 @@ class AnnotationService:
|
|
| 188 |
return self.state()
|
| 189 |
|
| 190 |
def assign(self, class_id: int | None = None) -> dict[str, Any]:
|
| 191 |
-
self._ctl().assign(class_id)
|
| 192 |
-
return self.state()
|
| 193 |
|
| 194 |
def set_active_class(self, class_id: int) -> dict[str, Any]:
|
| 195 |
self._ctl().set_active_class(class_id)
|
|
@@ -200,12 +215,10 @@ class AnnotationService:
|
|
| 200 |
return self.state()
|
| 201 |
|
| 202 |
def undo(self) -> dict[str, Any]:
|
| 203 |
-
self._ctl().undo()
|
| 204 |
-
return self.state()
|
| 205 |
|
| 206 |
def redo(self) -> dict[str, Any]:
|
| 207 |
-
self._ctl().redo()
|
| 208 |
-
return self.state()
|
| 209 |
|
| 210 |
def clear_selection(self) -> dict[str, Any]:
|
| 211 |
self._ctl().clear_selection()
|
|
@@ -242,16 +255,13 @@ class AnnotationService:
|
|
| 242 |
return self.state()
|
| 243 |
|
| 244 |
def assign_group(self, group_id: int, class_id: int | None = None) -> dict[str, Any]:
|
| 245 |
-
self._ctl().assign_group(group_id, class_id)
|
| 246 |
-
return self.state()
|
| 247 |
|
| 248 |
def apply_suggested(self, group_id: int | None = None) -> dict[str, Any]:
|
| 249 |
-
self._ctl().apply_suggested(group_id)
|
| 250 |
-
return self.state()
|
| 251 |
|
| 252 |
def assign_visible_groups(self, class_id: int | None = None) -> dict[str, Any]:
|
| 253 |
-
self._ctl().assign_visible_groups(class_id)
|
| 254 |
-
return self.state()
|
| 255 |
|
| 256 |
def set_group_visibility(self, group_id: int, visible: bool) -> dict[str, Any]:
|
| 257 |
self._ctl().set_group_visibility(group_id, visible)
|
|
|
|
| 165 |
features = {k: encode_array(v) for k, v in ctl.session.cloud.features.items()}
|
| 166 |
return {"xyz": encode_array(ctl.cloud_xyz()), "features": features}
|
| 167 |
|
| 168 |
+
def state(self, touched: np.ndarray | None = None) -> dict[str, Any]:
|
| 169 |
+
"""Everything the client needs to (re)colour: snapshot + bulk arrays.
|
| 170 |
+
|
| 171 |
+
With ``touched`` (the indices a label edit wrote), the full label and
|
| 172 |
+
grouping arrays are replaced by ``labels_delta`` — just those indices
|
| 173 |
+
and their new values — so labelling stays O(edit), not O(cloud), on
|
| 174 |
+
the wire and in the client's recolour.
|
| 175 |
+
"""
|
| 176 |
ctl = self._ctl()
|
| 177 |
+
base = {
|
|
|
|
| 178 |
"snapshot": asdict(ctl.snapshot()),
|
|
|
|
|
|
|
| 179 |
# int32 so it maps to a JS Int32Array (int64 has no plain TypedArray).
|
| 180 |
"selection": encode_array(ctl.selection_indices().astype(np.int32)),
|
| 181 |
}
|
| 182 |
+
if touched is not None:
|
| 183 |
+
labels = ctl.label_array()
|
| 184 |
+
return base | {
|
| 185 |
+
"labels_delta": {
|
| 186 |
+
"indices": encode_array(touched.astype(np.int32, copy=False)),
|
| 187 |
+
"values": encode_array(labels[touched].astype(np.int32, copy=False)),
|
| 188 |
+
},
|
| 189 |
+
}
|
| 190 |
+
grouping = ctl.grouping_array()
|
| 191 |
+
return base | {
|
| 192 |
+
"labels": encode_array(ctl.label_array()),
|
| 193 |
+
"grouping": encode_array(grouping) if grouping is not None else None,
|
| 194 |
+
}
|
| 195 |
|
| 196 |
# -- commands ---------------------------------------------------------
|
| 197 |
|
|
|
|
| 204 |
return self.state()
|
| 205 |
|
| 206 |
def assign(self, class_id: int | None = None) -> dict[str, Any]:
|
| 207 |
+
return self.state(touched=self._ctl().assign(class_id))
|
|
|
|
| 208 |
|
| 209 |
def set_active_class(self, class_id: int) -> dict[str, Any]:
|
| 210 |
self._ctl().set_active_class(class_id)
|
|
|
|
| 215 |
return self.state()
|
| 216 |
|
| 217 |
def undo(self) -> dict[str, Any]:
|
| 218 |
+
return self.state(touched=self._ctl().undo())
|
|
|
|
| 219 |
|
| 220 |
def redo(self) -> dict[str, Any]:
|
| 221 |
+
return self.state(touched=self._ctl().redo())
|
|
|
|
| 222 |
|
| 223 |
def clear_selection(self) -> dict[str, Any]:
|
| 224 |
self._ctl().clear_selection()
|
|
|
|
| 255 |
return self.state()
|
| 256 |
|
| 257 |
def assign_group(self, group_id: int, class_id: int | None = None) -> dict[str, Any]:
|
| 258 |
+
return self.state(touched=self._ctl().assign_group(group_id, class_id))
|
|
|
|
| 259 |
|
| 260 |
def apply_suggested(self, group_id: int | None = None) -> dict[str, Any]:
|
| 261 |
+
return self.state(touched=self._ctl().apply_suggested(group_id))
|
|
|
|
| 262 |
|
| 263 |
def assign_visible_groups(self, class_id: int | None = None) -> dict[str, Any]:
|
| 264 |
+
return self.state(touched=self._ctl().assign_visible_groups(class_id))
|
|
|
|
| 265 |
|
| 266 |
def set_group_visibility(self, group_id: int, visible: bool) -> dict[str, Any]:
|
| 267 |
self._ctl().set_group_visibility(group_id, visible)
|
|
@@ -128,25 +128,34 @@ class InteractionController:
|
|
| 128 |
self.session.active_class = class_id
|
| 129 |
self._changed()
|
| 130 |
|
| 131 |
-
def assign(self, class_id: int | None = None) ->
|
| 132 |
-
"""Write the active (or given) class to the current selection.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
cid = self.session.active_class if class_id is None else class_id
|
| 134 |
touched = self.session.annotation.assign(self.session.selection, cid)
|
| 135 |
if touched.size:
|
| 136 |
self._recolor_labels(touched)
|
| 137 |
self.clear_selection()
|
|
|
|
| 138 |
|
| 139 |
-
def undo(self) ->
|
|
|
|
| 140 |
touched = self.session.annotation.undo()
|
| 141 |
if touched is not None:
|
| 142 |
self._recolor_labels(touched)
|
| 143 |
self._changed()
|
|
|
|
| 144 |
|
| 145 |
-
def redo(self) ->
|
|
|
|
| 146 |
touched = self.session.annotation.redo()
|
| 147 |
if touched is not None:
|
| 148 |
self._recolor_labels(touched)
|
| 149 |
self._changed()
|
|
|
|
| 150 |
|
| 151 |
def _recolor_labels(self, indices: np.ndarray) -> None:
|
| 152 |
# Only meaningful while showing labels; other modes recolour on switch.
|
|
@@ -190,27 +199,27 @@ class InteractionController:
|
|
| 190 |
return
|
| 191 |
self._apply_selection(Selection.from_group(grouping, group_id), modifiers)
|
| 192 |
|
| 193 |
-
def assign_group(self, group_id: int, class_id: int | None = None) ->
|
| 194 |
-
"""Label a whole segment with the active (or given) class. Returns
|
| 195 |
grouping = self.session.active_grouping
|
| 196 |
if grouping is None:
|
| 197 |
-
return 0
|
| 198 |
cid = self.session.active_class if class_id is None else class_id
|
| 199 |
touched = self.session.annotation.assign(Selection.from_group(grouping, group_id), cid)
|
| 200 |
if touched.size:
|
| 201 |
self._paint_labels(touched)
|
| 202 |
self._changed()
|
| 203 |
-
return
|
| 204 |
|
| 205 |
-
def assign_visible_groups(self, class_id: int | None = None) ->
|
| 206 |
"""Label every currently-visible (checked) segment with the active/given class.
|
| 207 |
|
| 208 |
Hidden (unchecked, greyed) segments are skipped. The whole batch is one
|
| 209 |
-
undoable edit. Returns the
|
| 210 |
"""
|
| 211 |
grouping = self.session.active_grouping
|
| 212 |
if grouping is None:
|
| 213 |
-
return 0
|
| 214 |
cid = self.session.active_class if class_id is None else class_id
|
| 215 |
sel = Selection.empty(self.session.cloud.n)
|
| 216 |
for g in grouping.group_ids():
|
|
@@ -220,30 +229,30 @@ class InteractionController:
|
|
| 220 |
if touched.size:
|
| 221 |
self._paint_labels(touched)
|
| 222 |
self._changed()
|
| 223 |
-
return
|
| 224 |
|
| 225 |
-
def apply_suggested(self, group_id: int | None = None) ->
|
| 226 |
"""Accept model predictions: label group(s) with their ``suggested_labels``.
|
| 227 |
|
| 228 |
With ``group_id`` set, only that segment; otherwise every segment that
|
| 229 |
-
carries a suggestion. Returns the
|
| 230 |
"""
|
| 231 |
grouping = self.session.active_grouping
|
| 232 |
if grouping is None or not grouping.suggested_labels:
|
| 233 |
-
return 0
|
| 234 |
if group_id is not None:
|
| 235 |
suggestion = grouping.suggested_labels.get(group_id)
|
| 236 |
items = [(group_id, suggestion)] if suggestion is not None else []
|
| 237 |
else:
|
| 238 |
items = list(grouping.suggested_labels.items())
|
| 239 |
-
|
| 240 |
for gid, cid in items:
|
| 241 |
touched = self.session.annotation.assign(Selection.from_group(grouping, gid), cid)
|
| 242 |
if touched.size:
|
| 243 |
self._paint_labels(touched)
|
| 244 |
-
|
| 245 |
self._changed()
|
| 246 |
-
return
|
| 247 |
|
| 248 |
def _paint_labels(self, indices: np.ndarray) -> None:
|
| 249 |
# Recolour assigned points to their class colour in *any* view, so a
|
|
|
|
| 128 |
self.session.active_class = class_id
|
| 129 |
self._changed()
|
| 130 |
|
| 131 |
+
def assign(self, class_id: int | None = None) -> np.ndarray:
|
| 132 |
+
"""Write the active (or given) class to the current selection.
|
| 133 |
+
|
| 134 |
+
Returns the touched indices, so a remote front-end can patch labels and
|
| 135 |
+
colours in place instead of re-reading the full arrays.
|
| 136 |
+
"""
|
| 137 |
cid = self.session.active_class if class_id is None else class_id
|
| 138 |
touched = self.session.annotation.assign(self.session.selection, cid)
|
| 139 |
if touched.size:
|
| 140 |
self._recolor_labels(touched)
|
| 141 |
self.clear_selection()
|
| 142 |
+
return touched
|
| 143 |
|
| 144 |
+
def undo(self) -> np.ndarray:
|
| 145 |
+
"""Revert the last edit; returns the touched indices (empty if nothing)."""
|
| 146 |
touched = self.session.annotation.undo()
|
| 147 |
if touched is not None:
|
| 148 |
self._recolor_labels(touched)
|
| 149 |
self._changed()
|
| 150 |
+
return touched if touched is not None else np.empty(0, dtype=np.int64)
|
| 151 |
|
| 152 |
+
def redo(self) -> np.ndarray:
|
| 153 |
+
"""Re-apply the last undone edit; returns the touched indices (empty if nothing)."""
|
| 154 |
touched = self.session.annotation.redo()
|
| 155 |
if touched is not None:
|
| 156 |
self._recolor_labels(touched)
|
| 157 |
self._changed()
|
| 158 |
+
return touched if touched is not None else np.empty(0, dtype=np.int64)
|
| 159 |
|
| 160 |
def _recolor_labels(self, indices: np.ndarray) -> None:
|
| 161 |
# Only meaningful while showing labels; other modes recolour on switch.
|
|
|
|
| 199 |
return
|
| 200 |
self._apply_selection(Selection.from_group(grouping, group_id), modifiers)
|
| 201 |
|
| 202 |
+
def assign_group(self, group_id: int, class_id: int | None = None) -> np.ndarray:
|
| 203 |
+
"""Label a whole segment with the active (or given) class. Returns touched indices."""
|
| 204 |
grouping = self.session.active_grouping
|
| 205 |
if grouping is None:
|
| 206 |
+
return np.empty(0, dtype=np.int64)
|
| 207 |
cid = self.session.active_class if class_id is None else class_id
|
| 208 |
touched = self.session.annotation.assign(Selection.from_group(grouping, group_id), cid)
|
| 209 |
if touched.size:
|
| 210 |
self._paint_labels(touched)
|
| 211 |
self._changed()
|
| 212 |
+
return touched
|
| 213 |
|
| 214 |
+
def assign_visible_groups(self, class_id: int | None = None) -> np.ndarray:
|
| 215 |
"""Label every currently-visible (checked) segment with the active/given class.
|
| 216 |
|
| 217 |
Hidden (unchecked, greyed) segments are skipped. The whole batch is one
|
| 218 |
+
undoable edit. Returns the touched indices.
|
| 219 |
"""
|
| 220 |
grouping = self.session.active_grouping
|
| 221 |
if grouping is None:
|
| 222 |
+
return np.empty(0, dtype=np.int64)
|
| 223 |
cid = self.session.active_class if class_id is None else class_id
|
| 224 |
sel = Selection.empty(self.session.cloud.n)
|
| 225 |
for g in grouping.group_ids():
|
|
|
|
| 229 |
if touched.size:
|
| 230 |
self._paint_labels(touched)
|
| 231 |
self._changed()
|
| 232 |
+
return touched
|
| 233 |
|
| 234 |
+
def apply_suggested(self, group_id: int | None = None) -> np.ndarray:
|
| 235 |
"""Accept model predictions: label group(s) with their ``suggested_labels``.
|
| 236 |
|
| 237 |
With ``group_id`` set, only that segment; otherwise every segment that
|
| 238 |
+
carries a suggestion. Returns the touched indices across all of them.
|
| 239 |
"""
|
| 240 |
grouping = self.session.active_grouping
|
| 241 |
if grouping is None or not grouping.suggested_labels:
|
| 242 |
+
return np.empty(0, dtype=np.int64)
|
| 243 |
if group_id is not None:
|
| 244 |
suggestion = grouping.suggested_labels.get(group_id)
|
| 245 |
items = [(group_id, suggestion)] if suggestion is not None else []
|
| 246 |
else:
|
| 247 |
items = list(grouping.suggested_labels.items())
|
| 248 |
+
all_touched: list[np.ndarray] = []
|
| 249 |
for gid, cid in items:
|
| 250 |
touched = self.session.annotation.assign(Selection.from_group(grouping, gid), cid)
|
| 251 |
if touched.size:
|
| 252 |
self._paint_labels(touched)
|
| 253 |
+
all_touched.append(touched)
|
| 254 |
self._changed()
|
| 255 |
+
return np.concatenate(all_touched) if all_touched else np.empty(0, dtype=np.int64)
|
| 256 |
|
| 257 |
def _paint_labels(self, indices: np.ndarray) -> None:
|
| 258 |
# Recolour assigned points to their class colour in *any* view, so a
|
|
@@ -2,12 +2,24 @@
|
|
| 2 |
// wire interactions to the API, and re-render from each returned state.
|
| 3 |
|
| 4 |
import { api, decodeArray } from "./api.js";
|
| 5 |
-
import { computeColors } from "./colors.js";
|
| 6 |
import { Viewer } from "./viewer.js";
|
| 7 |
|
| 8 |
const el = (id) => document.getElementById(id);
|
| 9 |
const viewer = new Viewer(el("viewport"));
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
let cloud = { xyz: null, features: {} };
|
| 13 |
let state = null; // decoded { snapshot, labels, grouping, selection }
|
|
@@ -29,7 +41,7 @@ let voxel = { size: 0.5, showEmpty: false, map: null, centers: new Float32Array(
|
|
| 29 |
const VOXEL_GRID_CAP = 30000; // max cubes to draw / cells to enumerate
|
| 30 |
// The one point-visibility mask (class eyes ∧ hide-labelled ∧ isolate) — purely
|
| 31 |
// client-side, applied on top of the colours the semantic state produces.
|
| 32 |
-
const vis = { hideLabeled: false, hiddenClasses: new Set(), isolate: false };
|
| 33 |
|
| 34 |
// -- themes ------------------------------------------------------------------
|
| 35 |
|
|
@@ -563,6 +575,10 @@ async function doSave() {
|
|
| 563 |
}
|
| 564 |
|
| 565 |
function applyState(raw) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
state = {
|
| 567 |
snapshot: raw.snapshot,
|
| 568 |
labels: decodeArray(raw.labels),
|
|
@@ -571,6 +587,29 @@ function applyState(raw) {
|
|
| 571 |
};
|
| 572 |
if (state.selection.length === 0) vis.isolate = false; // isolate is armed per-selection
|
| 573 |
refreshColors();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
viewer.setHighlight(state.selection, cloud.xyz);
|
| 575 |
renderClasses();
|
| 576 |
renderModes();
|
|
@@ -581,6 +620,44 @@ function applyState(raw) {
|
|
| 581 |
`Sel: ${state.selection.length.toLocaleString()} · Class: ${className(s.active_class)} · View: ${s.display_mode}`;
|
| 582 |
}
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
// -- point visibility ---------------------------------------------------------
|
| 585 |
|
| 586 |
// Recompute colours from the semantic state, apply the visibility mask on top,
|
|
@@ -617,6 +694,7 @@ function applyVisibility(alpha) {
|
|
| 617 |
}
|
| 618 |
}
|
| 619 |
}
|
|
|
|
| 620 |
renderVisPill(hidden);
|
| 621 |
}
|
| 622 |
|
|
|
|
| 2 |
// wire interactions to the API, and re-render from each returned state.
|
| 3 |
|
| 4 |
import { api, decodeArray } from "./api.js";
|
| 5 |
+
import { computeColors, makeLabelColorizer } from "./colors.js";
|
| 6 |
import { Viewer } from "./viewer.js";
|
| 7 |
|
| 8 |
const el = (id) => document.getElementById(id);
|
| 9 |
const viewer = new Viewer(el("viewport"));
|
| 10 |
+
// Debug / headless-test handle (module scope is unreachable from outside).
|
| 11 |
+
// `refresh` re-pulls the full state and re-renders from scratch — the tests
|
| 12 |
+
// use it as the reference the delta path must be indistinguishable from.
|
| 13 |
+
window.__toaster = {
|
| 14 |
+
viewer,
|
| 15 |
+
debug: {
|
| 16 |
+
getState: () => state,
|
| 17 |
+
getCloud: () => cloud,
|
| 18 |
+
computeColors,
|
| 19 |
+
api,
|
| 20 |
+
refresh: async () => applyState(await api.state()),
|
| 21 |
+
},
|
| 22 |
+
};
|
| 23 |
|
| 24 |
let cloud = { xyz: null, features: {} };
|
| 25 |
let state = null; // decoded { snapshot, labels, grouping, selection }
|
|
|
|
| 41 |
const VOXEL_GRID_CAP = 30000; // max cubes to draw / cells to enumerate
|
| 42 |
// The one point-visibility mask (class eyes ∧ hide-labelled ∧ isolate) — purely
|
| 43 |
// client-side, applied on top of the colours the semantic state produces.
|
| 44 |
+
const vis = { hideLabeled: false, hiddenClasses: new Set(), isolate: false, hiddenCount: 0 };
|
| 45 |
|
| 46 |
// -- themes ------------------------------------------------------------------
|
| 47 |
|
|
|
|
| 575 |
}
|
| 576 |
|
| 577 |
function applyState(raw) {
|
| 578 |
+
// Label edits (assign / undo / redo / group assigns) come back as a delta —
|
| 579 |
+
// just the touched indices and values — so applying them stays O(edit)
|
| 580 |
+
// whatever the cloud size. Everything else carries the full arrays.
|
| 581 |
+
if (raw.labels_delta && state && cloud.xyz) return applyDelta(raw);
|
| 582 |
state = {
|
| 583 |
snapshot: raw.snapshot,
|
| 584 |
labels: decodeArray(raw.labels),
|
|
|
|
| 587 |
};
|
| 588 |
if (state.selection.length === 0) vis.isolate = false; // isolate is armed per-selection
|
| 589 |
refreshColors();
|
| 590 |
+
finishStateRender();
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
function applyDelta(raw) {
|
| 594 |
+
const indices = decodeArray(raw.labels_delta.indices);
|
| 595 |
+
const values = decodeArray(raw.labels_delta.values);
|
| 596 |
+
const oldSelection = state.selection;
|
| 597 |
+
const wasIsolate = vis.isolate && oldSelection.length > 0;
|
| 598 |
+
state.snapshot = raw.snapshot;
|
| 599 |
+
state.selection = decodeArray(raw.selection);
|
| 600 |
+
for (let k = 0; k < indices.length; k++) state.labels[indices[k]] = values[k];
|
| 601 |
+
if (state.selection.length === 0) vis.isolate = false;
|
| 602 |
+
|
| 603 |
+
// Isolate couples every point's alpha to the selection as a whole — a
|
| 604 |
+
// selection change under isolate is a wholesale visibility change, not a
|
| 605 |
+
// delta. Rare enough to just recompute fully.
|
| 606 |
+
if (wasIsolate || (vis.isolate && state.selection.length > 0)) refreshColors();
|
| 607 |
+
else patchColors(indices, oldSelection);
|
| 608 |
+
finishStateRender();
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
// The panel/status re-render both state paths share.
|
| 612 |
+
function finishStateRender() {
|
| 613 |
viewer.setHighlight(state.selection, cloud.xyz);
|
| 614 |
renderClasses();
|
| 615 |
renderModes();
|
|
|
|
| 620 |
`Sel: ${state.selection.length.toLocaleString()} · Class: ${className(s.active_class)} · View: ${s.display_mode}`;
|
| 621 |
}
|
| 622 |
|
| 623 |
+
// Delta recolour: rewrite only the touched points' colours in the live GPU
|
| 624 |
+
// arrays, re-evaluate visibility for the points it can have changed on (the
|
| 625 |
+
// touched points, plus both selections — selected points are force-visible,
|
| 626 |
+
// so selection churn shows/hides them), and upload just those ranges.
|
| 627 |
+
function patchColors(touched, oldSelection) {
|
| 628 |
+
const { colors, alpha } = viewer.colorArrays();
|
| 629 |
+
const colorize = makeLabelColorizer(state, cloud);
|
| 630 |
+
if (colorize) for (let k = 0; k < touched.length; k++) colorize(colors, touched[k]);
|
| 631 |
+
|
| 632 |
+
if (vis.hideLabeled || vis.hiddenClasses.size > 0) {
|
| 633 |
+
const labels = state.labels;
|
| 634 |
+
const unlabeled = state.snapshot.unlabeled_id;
|
| 635 |
+
const selected = new Set(state.selection);
|
| 636 |
+
// Idempotent per point, so overlap between the three sets double-counts
|
| 637 |
+
// nothing: the second visit sees before === after.
|
| 638 |
+
const reapply = (i) => {
|
| 639 |
+
const before = alpha[i];
|
| 640 |
+
const masked =
|
| 641 |
+
(vis.hideLabeled && labels[i] !== unlabeled) || vis.hiddenClasses.has(labels[i]);
|
| 642 |
+
const after = masked && !selected.has(i) ? 0 : 1;
|
| 643 |
+
alpha[i] = after;
|
| 644 |
+
vis.hiddenCount += (after === 0 ? 1 : 0) - (before === 0 ? 1 : 0);
|
| 645 |
+
};
|
| 646 |
+
for (let k = 0; k < touched.length; k++) reapply(touched[k]);
|
| 647 |
+
for (let k = 0; k < oldSelection.length; k++) reapply(oldSelection[k]);
|
| 648 |
+
for (let k = 0; k < state.selection.length; k++) reapply(state.selection[k]);
|
| 649 |
+
renderVisPill(vis.hiddenCount);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
// Selection points' alpha may have changed even when nothing is "touched"
|
| 653 |
+
// colour-wise — commit the union.
|
| 654 |
+
const union = new Uint32Array(touched.length + oldSelection.length + state.selection.length);
|
| 655 |
+
union.set(touched, 0);
|
| 656 |
+
union.set(oldSelection, touched.length);
|
| 657 |
+
union.set(state.selection, touched.length + oldSelection.length);
|
| 658 |
+
viewer.commitColors(union);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
// -- point visibility ---------------------------------------------------------
|
| 662 |
|
| 663 |
// Recompute colours from the semantic state, apply the visibility mask on top,
|
|
|
|
| 694 |
}
|
| 695 |
}
|
| 696 |
}
|
| 697 |
+
vis.hiddenCount = hidden; // the delta path adjusts this incrementally
|
| 698 |
renderVisPill(hidden);
|
| 699 |
}
|
| 700 |
|
|
@@ -37,15 +37,16 @@ function turbo(t) {
|
|
| 37 |
}
|
| 38 |
|
| 39 |
// decoded: { snapshot, labels:Int32Array, grouping:Int32Array|null }
|
| 40 |
-
//
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
const snap = decoded.snapshot;
|
| 43 |
const labels = decoded.labels;
|
| 44 |
const grouping = decoded.grouping;
|
| 45 |
-
const n = labels.length;
|
| 46 |
-
const colors = new Float32Array(n * 3);
|
| 47 |
-
const alpha = new Float32Array(n).fill(1); // every point stays visible & pickable
|
| 48 |
-
|
| 49 |
const mode = snap.display_mode;
|
| 50 |
if (mode === "grouping" && grouping) {
|
| 51 |
// Segments toggled off (Hide all / Solo / a row's checkbox) are not removed —
|
|
@@ -56,22 +57,36 @@ export function computeColors(decoded, cloud) {
|
|
| 56 |
const lut = {};
|
| 57 |
for (const c of snap.classes) lut[c.id] = c.color;
|
| 58 |
const unlabeled = snap.unlabeled_id;
|
| 59 |
-
|
| 60 |
const g = grouping[i];
|
| 61 |
if (!hidden.has(g)) setRGB(colors, i, g < 0 ? NOISE : GROUP_PALETTE[g % GROUP_PALETTE.length]);
|
| 62 |
else if (labels[i] !== unlabeled) setRGB(colors, i, lut[labels[i]] || DIM);
|
| 63 |
else setRGB(colors, i, DIM);
|
| 64 |
-
}
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const z = new Float32Array(n);
|
| 69 |
for (let i = 0; i < n; i++) z[i] = cloud.xyz[i * 3 + 2];
|
| 70 |
rampInto(colors, z);
|
| 71 |
} else {
|
| 72 |
-
|
| 73 |
-
for (const c of snap.classes) lut[c.id] = c.color;
|
| 74 |
-
for (let i = 0; i < n; i++) setRGB(colors, i, lut[labels[i]] || UNKNOWN);
|
| 75 |
}
|
| 76 |
return { colors, alpha };
|
| 77 |
}
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
// decoded: { snapshot, labels:Int32Array, grouping:Int32Array|null }
|
| 40 |
+
// Per-point colour writer for the label-dependent display modes, or null when
|
| 41 |
+
// the current mode's colours can't be changed by a label edit (intensity /
|
| 42 |
+
// height ramps). This is the single source of the per-point rules: the full
|
| 43 |
+
// recompute below and the delta recolour in app.js both call it, so they can
|
| 44 |
+
// never drift apart.
|
| 45 |
+
// decoded: { snapshot, labels:Int32Array, grouping:Int32Array|null }
|
| 46 |
+
export function makeLabelColorizer(decoded, cloud) {
|
| 47 |
const snap = decoded.snapshot;
|
| 48 |
const labels = decoded.labels;
|
| 49 |
const grouping = decoded.grouping;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
const mode = snap.display_mode;
|
| 51 |
if (mode === "grouping" && grouping) {
|
| 52 |
// Segments toggled off (Hide all / Solo / a row's checkbox) are not removed —
|
|
|
|
| 57 |
const lut = {};
|
| 58 |
for (const c of snap.classes) lut[c.id] = c.color;
|
| 59 |
const unlabeled = snap.unlabeled_id;
|
| 60 |
+
return (colors, i) => {
|
| 61 |
const g = grouping[i];
|
| 62 |
if (!hidden.has(g)) setRGB(colors, i, g < 0 ? NOISE : GROUP_PALETTE[g % GROUP_PALETTE.length]);
|
| 63 |
else if (labels[i] !== unlabeled) setRGB(colors, i, lut[labels[i]] || DIM);
|
| 64 |
else setRGB(colors, i, DIM);
|
| 65 |
+
};
|
| 66 |
+
}
|
| 67 |
+
// Same fall-through as the full recompute: "intensity" without the feature
|
| 68 |
+
// renders as labels, so it *does* depend on them.
|
| 69 |
+
if ((mode === "intensity" && cloud.features.intensity) || mode === "height") return null;
|
| 70 |
+
const lut = {};
|
| 71 |
+
for (const c of snap.classes) lut[c.id] = c.color;
|
| 72 |
+
return (colors, i) => setRGB(colors, i, lut[labels[i]] || UNKNOWN);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// cloud: { xyz:Float32Array, features:{ intensity?:Float32Array } }
|
| 76 |
+
export function computeColors(decoded, cloud) {
|
| 77 |
+
const n = decoded.labels.length;
|
| 78 |
+
const colors = new Float32Array(n * 3);
|
| 79 |
+
const alpha = new Float32Array(n).fill(1); // every point stays visible & pickable
|
| 80 |
+
|
| 81 |
+
const colorize = makeLabelColorizer(decoded, cloud);
|
| 82 |
+
if (colorize) {
|
| 83 |
+
for (let i = 0; i < n; i++) colorize(colors, i);
|
| 84 |
+
} else if (decoded.snapshot.display_mode === "height") {
|
| 85 |
const z = new Float32Array(n);
|
| 86 |
for (let i = 0; i < n; i++) z[i] = cloud.xyz[i * 3 + 2];
|
| 87 |
rampInto(colors, z);
|
| 88 |
} else {
|
| 89 |
+
rampInto(colors, cloud.features.intensity);
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
return { colors, alpha };
|
| 92 |
}
|
|
@@ -25,6 +25,27 @@ const OCTREE_MIN_POINTS = 200_000;
|
|
| 25 |
// pixels — below it, the node's own sample is already ~1 point per pixel.
|
| 26 |
const OCTREE_SPLIT_PX = 110;
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const VERT = `
|
| 29 |
attribute vec3 acolor;
|
| 30 |
attribute float aalpha;
|
|
@@ -251,6 +272,36 @@ export class Viewer {
|
|
| 251 |
this._requestRender();
|
| 252 |
}
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
setHighlight(indices, xyz) {
|
| 255 |
if (this.highlight) {
|
| 256 |
this.scene.remove(this.highlight);
|
|
|
|
| 25 |
// pixels — below it, the node's own sample is already ~1 point per pixel.
|
| 26 |
const OCTREE_SPLIT_PX = 110;
|
| 27 |
|
| 28 |
+
// Sort a delta's indices and merge them into [start, count] runs, bridging
|
| 29 |
+
// gaps below 512 slots (uploading a few unchanged points is cheaper than
|
| 30 |
+
// another bufferSubData call). Returns null when the result is still too
|
| 31 |
+
// fragmented — the caller should full-upload instead.
|
| 32 |
+
function coalesceRuns(indices) {
|
| 33 |
+
if (indices.length === 0) return [];
|
| 34 |
+
const sorted = Uint32Array.from(indices).sort();
|
| 35 |
+
const runs = [];
|
| 36 |
+
let start = sorted[0], prev = sorted[0];
|
| 37 |
+
for (let k = 1; k < sorted.length; k++) {
|
| 38 |
+
const v = sorted[k];
|
| 39 |
+
if (v - prev > 512) {
|
| 40 |
+
runs.push([start, prev - start + 1]);
|
| 41 |
+
start = v;
|
| 42 |
+
}
|
| 43 |
+
prev = v;
|
| 44 |
+
}
|
| 45 |
+
runs.push([start, prev - start + 1]);
|
| 46 |
+
return runs.length > 64 ? null : runs;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
const VERT = `
|
| 50 |
attribute vec3 acolor;
|
| 51 |
attribute float aalpha;
|
|
|
|
| 272 |
this._requestRender();
|
| 273 |
}
|
| 274 |
|
| 275 |
+
// The live GPU-side arrays, for in-place patching by the delta recolour.
|
| 276 |
+
// Write into them, then call commitColors with the touched indices.
|
| 277 |
+
colorArrays() {
|
| 278 |
+
return {
|
| 279 |
+
colors: this.geom.getAttribute("acolor").array,
|
| 280 |
+
alpha: this.geom.getAttribute("aalpha").array,
|
| 281 |
+
};
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// Upload only what a label delta touched: the indices are coalesced into a
|
| 285 |
+
// few contiguous ranges (lidar points arrive in scan order, so a labelled
|
| 286 |
+
// cluster is usually index-local too). Falls back to a full upload when the
|
| 287 |
+
// edit is too scattered for ranged updates to win.
|
| 288 |
+
commitColors(indices) {
|
| 289 |
+
const ca = this.geom.getAttribute("acolor");
|
| 290 |
+
const aa = this.geom.getAttribute("aalpha");
|
| 291 |
+
const runs = coalesceRuns(indices);
|
| 292 |
+
if (runs && ca.addUpdateRange) {
|
| 293 |
+
ca.clearUpdateRanges();
|
| 294 |
+
aa.clearUpdateRanges();
|
| 295 |
+
for (const [start, count] of runs) {
|
| 296 |
+
ca.addUpdateRange(start * 3, count * 3);
|
| 297 |
+
aa.addUpdateRange(start, count);
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
ca.needsUpdate = true;
|
| 301 |
+
aa.needsUpdate = true;
|
| 302 |
+
this._requestRender();
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
setHighlight(indices, xyz) {
|
| 306 |
if (this.highlight) {
|
| 307 |
this.scene.remove(this.highlight);
|