fix(core): labelling after an apairo resume silently did nothing
Browse filesAnnotationController cached cloud.labels at construction, but resume_apairo
replaces the array wholesale — every later assign wrote into the orphaned
pre-resume array while the UI (and saves) read the new one. The controller
now reads cloud.labels live, and resume clears the undo history so edits
recorded against the old array can't be spliced into the resumed labels.
- tests/test_annotation_history.py +11 -0
- tests/test_apairo_channel.py +23 -0
- toaster/api/service.py +4 -0
- toaster/core/annotation.py +8 -1
tests/test_annotation_history.py
CHANGED
|
@@ -50,3 +50,14 @@ def test_new_edit_clears_redo():
|
|
| 50 |
ann.undo()
|
| 51 |
ann.assign(Selection.from_indices([1], 6), 2)
|
| 52 |
assert ann.redo() is None # redo stack was cleared by the new edit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
ann.undo()
|
| 51 |
ann.assign(Selection.from_indices([1], 6), 2)
|
| 52 |
assert ann.redo() is None # redo stack was cleared by the new edit
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_assign_survives_a_wholesale_labels_swap():
|
| 56 |
+
# Resuming a previous session replaces cloud.labels with a fresh array;
|
| 57 |
+
# the controller must write into the new one, not a cached reference to
|
| 58 |
+
# the old (the "labelling silently does nothing after resume" bug).
|
| 59 |
+
cloud = _cloud()
|
| 60 |
+
ann = AnnotationController(cloud)
|
| 61 |
+
cloud.labels = np.array([7, 7, 0, 0, 0, 0], np.int32)
|
| 62 |
+
ann.assign(Selection.from_indices([2], 6), class_id=5)
|
| 63 |
+
assert cloud.labels.tolist() == [7, 7, 5, 0, 0, 0]
|
tests/test_apairo_channel.py
CHANGED
|
@@ -286,3 +286,26 @@ def test_apairo_info_false_for_loose_cloud(tmp_path):
|
|
| 286 |
svc = AnnotationService()
|
| 287 |
svc.open_cloud(str(path))
|
| 288 |
assert svc.apairo_info()["is_apairo"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
svc = AnnotationService()
|
| 287 |
svc.open_cloud(str(path))
|
| 288 |
assert svc.apairo_info()["is_apairo"] is False
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def test_assign_after_resume_reaches_the_resumed_labels(tmp_path):
|
| 292 |
+
# Resume replaces cloud.labels wholesale; a later assign must land in the
|
| 293 |
+
# resumed array (regression: the controller kept writing into the
|
| 294 |
+
# pre-resume array, so labelling after a resume silently did nothing).
|
| 295 |
+
from toaster.api.service import AnnotationService
|
| 296 |
+
|
| 297 |
+
seq = _apairo_seq(tmp_path)
|
| 298 |
+
frame = seq / "ouster_points" / "000000.npy"
|
| 299 |
+
np.save(frame, np.zeros((4, 4), np.float32))
|
| 300 |
+
gt = seq / "ground_truth"
|
| 301 |
+
gt.mkdir()
|
| 302 |
+
np.save(gt / "000000.npy", np.array([1, 1, 0, 0], np.int32))
|
| 303 |
+
|
| 304 |
+
svc = AnnotationService()
|
| 305 |
+
svc.open_cloud(str(frame))
|
| 306 |
+
assert svc.apairo_resume_info()["available"] is True
|
| 307 |
+
svc.resume_apairo()
|
| 308 |
+
assert not svc._ctl().session.history.can_undo() # pre-resume edits dropped
|
| 309 |
+
svc.box([2])
|
| 310 |
+
svc.assign(class_id=5)
|
| 311 |
+
assert svc._ctl().label_array().tolist() == [1, 1, 5, 0]
|
toaster/api/service.py
CHANGED
|
@@ -368,6 +368,10 @@ class AnnotationService:
|
|
| 368 |
f"'{channel}' frame {target.stem} has {full.shape[0]} labels, expected {expected}"
|
| 369 |
)
|
| 370 |
cloud.labels = cloud.from_source_frame(full)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
return self.state()
|
| 372 |
|
| 373 |
def apairo_nav(self) -> dict[str, Any]:
|
|
|
|
| 368 |
f"'{channel}' frame {target.stem} has {full.shape[0]} labels, expected {expected}"
|
| 369 |
)
|
| 370 |
cloud.labels = cloud.from_source_frame(full)
|
| 371 |
+
# The resume replaced the labels wholesale: edits recorded before it
|
| 372 |
+
# refer to values from the pre-resume array — undoing across the swap
|
| 373 |
+
# would splice stale values into the resumed labels.
|
| 374 |
+
session.history.clear()
|
| 375 |
return self.state()
|
| 376 |
|
| 377 |
def apairo_nav(self) -> dict[str, Any]:
|
toaster/core/annotation.py
CHANGED
|
@@ -94,7 +94,14 @@ class AnnotationController:
|
|
| 94 |
) -> None:
|
| 95 |
self.cloud = cloud
|
| 96 |
self.history = history if history is not None else EditHistory()
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
def assign(self, selection: Selection, class_id: int) -> np.ndarray:
|
| 100 |
"""Write ``class_id`` to every selected point; return the touched indices.
|
|
|
|
| 94 |
) -> None:
|
| 95 |
self.cloud = cloud
|
| 96 |
self.history = history if history is not None else EditHistory()
|
| 97 |
+
cloud.ensure_labels(unlabeled_id)
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def _labels(self) -> np.ndarray:
|
| 101 |
+
# Always read through the cloud: anything that swaps the array wholesale
|
| 102 |
+
# (e.g. resuming a previous session's labels) must not leave this
|
| 103 |
+
# controller writing into the orphaned old array.
|
| 104 |
+
return self.cloud.labels
|
| 105 |
|
| 106 |
def assign(self, selection: Selection, class_id: int) -> np.ndarray:
|
| 107 |
"""Write ``class_id`` to every selected point; return the touched indices.
|