Skip to content

Commit 9fc80a2

Browse files
add depth outline mode (#127)
1 parent 3c9cd7b commit 9fc80a2

38 files changed

Lines changed: 3130 additions & 187 deletions

‎src/cellier/controller.py‎

Lines changed: 149 additions & 50 deletions
Large diffs are not rendered by default.

‎src/cellier/convenience/_viewer.py‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,7 @@ def add_labels(
13871387
pick_write: bool = True,
13881388
outline_selected_labels: dict[int, int] | None = None,
13891389
outline_mode: OutlineMode = "per_label",
1390+
outline_depth_range: tuple[float, float] | None = None,
13901391
clipping_planes: Sequence[ClippingPlane] = (),
13911392
render_planes: Sequence[RenderPlane] = (),
13921393
) -> LabelMemoryVisual:
@@ -1429,12 +1430,22 @@ def add_labels(
14291430
Maps a label value to the palette slot the selection layer draws
14301431
it in. ``None`` (default) selects no label, so an outlined
14311432
labels visual shows boundaries only.
1432-
outline_mode : {"per_label", "whole_object", "all_boundaries"}
1433-
How the labels are outlined. ``"per_label"`` (default) outlines
1434-
the label values in ``outline_selected_labels``, each in its own
1435-
slot's colour. ``"whole_object"`` outlines the volume as one
1436-
silhouette and ``"all_boundaries"`` every label's boundary, both
1437-
in the colour of the ``outline`` slot.
1433+
outline_mode : str
1434+
How the labels are outlined: ``"per_label"``, ``"whole_object"``,
1435+
``"all_boundaries"`` or ``"depth_threshold"``. ``"per_label"``
1436+
(default) outlines the label values in
1437+
``outline_selected_labels``, each in its own slot's colour.
1438+
``"whole_object"`` outlines the volume as one silhouette and
1439+
``"all_boundaries"`` every label's boundary, both in the colour
1440+
of the ``outline`` slot. ``"depth_threshold"`` is
1441+
``"all_boundaries"`` and also a line where one part of a label
1442+
passes in front of another part of the same label.
1443+
outline_depth_range : tuple[float, float] or None
1444+
``(start, solid)`` of the depth lines of ``"depth_threshold"``
1445+
mode, as fractions of the distance from the camera: a jump in
1446+
depth of ``start`` is where a line begins and from ``solid`` up
1447+
it is drawn in full. ``None`` (default) keeps the model's
1448+
default, ``(0.01, 0.06)``. No effect in the other modes.
14381449
14391450
clipping_planes : Sequence[ClippingPlane]
14401451
Clipping planes, in the store's level-0 data coordinates: the
@@ -1467,6 +1478,7 @@ def add_labels(
14671478
pick_write=pick_write,
14681479
outline_selected_labels=outline_selected_labels,
14691480
outline_mode=outline_mode,
1481+
outline_depth_range=outline_depth_range,
14701482
clipping_planes=clipping_planes,
14711483
render_planes=render_planes,
14721484
)
@@ -1979,6 +1991,7 @@ def add_labels_multiscale(
19791991
pick_write: bool = True,
19801992
outline_selected_labels: dict[int, int] | None = None,
19811993
outline_mode: OutlineMode = "per_label",
1994+
outline_depth_range: tuple[float, float] | None = None,
19821995
clipping_planes: Sequence[ClippingPlane] = (),
19831996
render_planes: Sequence[RenderPlane] = (),
19841997
) -> MultiscaleLabelVisual:
@@ -2023,12 +2036,22 @@ def add_labels_multiscale(
20232036
Maps a label value to the palette slot the selection layer draws
20242037
it in. ``None`` (default) selects no label, so an outlined
20252038
labels visual shows boundaries only.
2026-
outline_mode : {"per_label", "whole_object", "all_boundaries"}
2027-
How the labels are outlined. ``"per_label"`` (default) outlines
2028-
the label values in ``outline_selected_labels``, each in its own
2029-
slot's colour. ``"whole_object"`` outlines the volume as one
2030-
silhouette and ``"all_boundaries"`` every label's boundary, both
2031-
in the colour of the ``outline`` slot.
2039+
outline_mode : str
2040+
How the labels are outlined: ``"per_label"``, ``"whole_object"``,
2041+
``"all_boundaries"`` or ``"depth_threshold"``. ``"per_label"``
2042+
(default) outlines the label values in
2043+
``outline_selected_labels``, each in its own slot's colour.
2044+
``"whole_object"`` outlines the volume as one silhouette and
2045+
``"all_boundaries"`` every label's boundary, both in the colour
2046+
of the ``outline`` slot. ``"depth_threshold"`` is
2047+
``"all_boundaries"`` and also a line where one part of a label
2048+
passes in front of another part of the same label.
2049+
outline_depth_range : tuple[float, float] or None
2050+
``(start, solid)`` of the depth lines of ``"depth_threshold"``
2051+
mode, as fractions of the distance from the camera: a jump in
2052+
depth of ``start`` is where a line begins and from ``solid`` up
2053+
it is drawn in full. ``None`` (default) keeps the model's
2054+
default, ``(0.01, 0.06)``. No effect in the other modes.
20322055
20332056
clipping_planes : Sequence[ClippingPlane]
20342057
Clipping planes, in the store's level-0 data coordinates: the
@@ -2062,6 +2085,7 @@ def add_labels_multiscale(
20622085
pick_write=pick_write,
20632086
outline_selected_labels=outline_selected_labels,
20642087
outline_mode=outline_mode,
2088+
outline_depth_range=outline_depth_range,
20652089
clipping_planes=clipping_planes,
20662090
render_planes=render_planes,
20672091
)

‎src/cellier/convenience/layout/_shared.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,10 @@ def _outline_specs(
502502
# ``per_label`` without it -- which misdrew every visual in
503503
# another mode.
504504
values["outline_mode"] = visual.outline_mode
505+
# What the depth range row shows in ``depth_threshold`` mode. It
506+
# travels in every mode, because the mode can be entered from the
507+
# panel.
508+
values["outline_depth_range"] = tuple(visual.outline_depth_range)
505509
return [ControlSpec(kind, _CONTROL_TITLES[kind], values)]
506510

507511

‎src/cellier/events/_events.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -914,8 +914,8 @@ class VisualRenderChangedEvent(NamedTuple):
914914
visual_id : UUID
915915
Model-layer ID of the visual whose settings changed.
916916
field_name : str
917-
``"outline.slot"``, ``"outline.placement"``,
918-
``"ambient_occlusion"`` or ``"outline_selected_labels"``.
917+
One of ``VISUAL_RENDER_FIELDS``, such as ``"outline.slot"``,
918+
``"ambient_occlusion"`` or ``"outline_depth_range"``.
919919
new_value : Any
920920
The new field value.
921921
"""

‎src/cellier/events/_update_events.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,8 @@ class VisualRenderUpdateEvent(NamedTuple):
312312
visual_id :
313313
Target visual.
314314
field :
315-
``"outline.slot"``, ``"outline.placement"``,
316-
``"ambient_occlusion"`` or ``"outline_selected_labels"``.
315+
One of ``VISUAL_RENDER_FIELDS``, such as ``"outline.slot"``,
316+
``"ambient_occlusion"`` or ``"outline_depth_range"``.
317317
value :
318318
New value for the field.
319319
"""

‎src/cellier/gui/_render_controls.py‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ class RenderControl(NamedTuple):
101101
What the panel calls this control.
102102
kind : str
103103
``"bool"``, ``"int"``, ``"float"``, ``"color"``, ``"palette"``, or --
104-
for the per-visual controls -- ``"slot"``, ``"choice"`` or
105-
``"label_selection"``.
104+
for the per-visual controls -- ``"slot"``, ``"choice"``,
105+
``"label_selection"`` or ``"range"``. A range is a pair of
106+
numbers, the second never below the first, set as one value.
106107
A palette is a fixed-length row of RGB swatches; its length is a
107108
config decision rather than something a panel lets a user change.
108109
minimum, maximum : float or None
@@ -129,6 +130,7 @@ class RenderControl(NamedTuple):
129130
"slot",
130131
"choice",
131132
"label_selection",
133+
"range",
132134
]
133135
minimum: float | None = None
134136
maximum: float | None = None
@@ -369,12 +371,48 @@ def _layer_controls(layer: str, group: str, *, with_color: bool):
369371
("Per label", "per_label"),
370372
("Whole volume", "whole_object"),
371373
("All boundaries", "all_boundaries"),
374+
("Depth threshold", "depth_threshold"),
372375
)
373376

374377
#: Modes in which ``outline.slot`` chooses a colour rather than mere
375378
#: participation, so the panel shows the swatch row and drops the per-label
376379
#: rows. Shared by both toolkits so they cannot drift apart.
377-
SLOT_IS_COLOUR_MODES: frozenset[str] = frozenset({"whole_object", "all_boundaries"})
380+
SLOT_IS_COLOUR_MODES: frozenset[str] = frozenset(
381+
{"whole_object", "all_boundaries", "depth_threshold"}
382+
)
383+
384+
#: Modes that draw depth bands, so the panel shows the depth range row.
385+
DEPTH_RANGE_MODES: frozenset[str] = frozenset({"depth_threshold"})
386+
387+
#: The two boxes of a depth range row, in the order of the pair.
388+
DEPTH_RANGE_LABELS: tuple[str, str] = ("Start", "Solid")
389+
390+
#: Smallest start a panel offers. Below this the jump is inside the noise
391+
#: of the depth buffer at the far end of a wide depth range.
392+
DEPTH_RANGE_MINIMUM: float = 0.001
393+
394+
#: Largest solid value a panel offers. A jump is a fraction of the distance
395+
#: to the nearer surface, so it has no upper bound; past a few times that
396+
#: distance every line is already solid.
397+
DEPTH_RANGE_SOLID_MAXIMUM: float = 10.0
398+
399+
#: What a click on a spin arrow changes a depth range value by.
400+
DEPTH_RANGE_STEP: float = 0.005
401+
402+
403+
def coerce_depth_range(start: float, solid: float) -> tuple[float, float]:
404+
"""Return the pair a depth range row holds for what its boxes say.
405+
406+
The start is kept inside what the row offers, and the solid value is
407+
never below it: raising the start past the solid value carries the solid
408+
value up with it, and the solid value cannot be set below the start. So
409+
the row never holds a pair the model would reject. Shared by both
410+
toolkits so they cannot drift apart.
411+
"""
412+
start = min(max(float(start), DEPTH_RANGE_MINIMUM), 1.0)
413+
solid = min(float(solid), DEPTH_RANGE_SOLID_MAXIMUM)
414+
return start, max(solid, start)
415+
378416

379417
_OUTLINE_MODE_TOOLTIP = (
380418
"Per label gives each label its own band, so touching labels keep a "
@@ -384,7 +422,22 @@ def _layer_controls(layer: str, group: str, *, with_color: bool):
384422
"coloured by the slot above. All boundaries bands every label, "
385423
"boundaries between touching labels included, all in that same single "
386424
"colour -- whole volume's colour with per label's boundaries. The "
387-
"selected labels below do not apply in it."
425+
"selected labels below do not apply in it. Depth threshold is all "
426+
"boundaries and one thing more: a line where one part of a label passes "
427+
"in front of another part of the same label, as where two branches of a "
428+
"tree cross. It adds lines in a 3D view only."
429+
)
430+
431+
_DEPTH_RANGE_TOOLTIP = (
432+
"How large a jump in depth inside one label is outlined, and how "
433+
"strongly. Both values are fractions of the distance from the camera "
434+
"to the nearer surface. A jump of Start is where a line begins, at no "
435+
"alpha; from Solid up the line is drawn in full; between the two it "
436+
"fades in. Equal values give a hard edge. The steps between voxels "
437+
"are jumps in depth too: zoomed in until a voxel covers several pixels, "
438+
"or while a coarse level is on screen, they are outlined as well, and a "
439+
"larger Start removes them. A Start near 0.001 is inside the noise of "
440+
"the depth buffer when the scene's depth range is very wide."
388441
)
389442

390443

@@ -451,6 +504,16 @@ def _layer_controls(layer: str, group: str, *, with_color: bool):
451504
"labels_outline": (
452505
RenderControl("outline_mode", "Mode", "choice", tooltip=_OUTLINE_MODE_TOOLTIP),
453506
RenderControl("outline.slot", "Outline", "slot", tooltip=_SLOT_TOOLTIP),
507+
# Drawn only in the modes of ``DEPTH_RANGE_MODES``.
508+
RenderControl(
509+
"outline_depth_range",
510+
"Depth range",
511+
"range",
512+
minimum=DEPTH_RANGE_MINIMUM,
513+
maximum=DEPTH_RANGE_SOLID_MAXIMUM,
514+
decimals=3,
515+
tooltip=_DEPTH_RANGE_TOOLTIP,
516+
),
454517
RenderControl(
455518
"outline.placement", "Placement", "choice", tooltip=_PLACEMENT_TOOLTIP
456519
),

‎src/cellier/gui/anywidget/render/_per_visual.py‎

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,15 @@
2828
)
2929
from cellier.gui._render_controls import (
3030
AMBIENT_OCCLUSION_CHOICES,
31+
DEPTH_RANGE_LABELS,
32+
DEPTH_RANGE_MODES,
33+
DEPTH_RANGE_STEP,
3134
OUTLINE_MODE_CHOICES,
3235
PLACEMENT_CHOICES,
3336
SLOT_IS_COLOUR_MODES,
3437
VISUAL_RENDER_CONTROLS,
3538
VISUAL_RENDER_TITLES,
39+
coerce_depth_range,
3640
visual_path,
3741
with_api_path,
3842
)
@@ -132,6 +136,15 @@ def _describe_controls(self, values: dict[str, Any]) -> list[dict]:
132136
entry["options"] = _choice_options(OUTLINE_MODE_CHOICES)
133137
elif control.kind == "label_selection":
134138
entry["max_rows"] = MAX_LABEL_ROWS
139+
elif control.kind == "range":
140+
# The first box runs from the minimum to 1, the second from
141+
# the first box's value to the maximum.
142+
entry["labels"] = list(DEPTH_RANGE_LABELS)
143+
entry["minimum"] = control.minimum
144+
entry["maximum"] = control.maximum
145+
entry["first_maximum"] = 1.0
146+
entry["decimals"] = control.decimals
147+
entry["step"] = DEPTH_RANGE_STEP
135148
described.append(self._adjust_for_mode(entry, values))
136149
return [entry for entry in described if entry is not None]
137150

@@ -148,6 +161,9 @@ def _serialise(values: dict[str, Any]) -> dict:
148161
out["outline_selected_labels"] = {
149162
str(k): int(v) for k, v in selection.items()
150163
}
164+
depth_range = out.get("outline_depth_range")
165+
if depth_range is not None:
166+
out["outline_depth_range"] = [float(v) for v in depth_range]
151167
return out
152168

153169
# ------------------------------------------------------------------
@@ -190,6 +206,10 @@ def _on_values_change(self, change) -> None:
190206
value = {int(k): int(v) for k, v in value.items()}
191207
elif field == "outline.slot":
192208
value = int(value)
209+
elif field == "outline_depth_range":
210+
value = self._depth_range_from_widget(value)
211+
if value is None:
212+
continue
193213
for visual_id in self._visual_ids:
194214
self.changed.emit(
195215
VisualRenderUpdateEvent(
@@ -200,6 +220,28 @@ def _on_values_change(self, change) -> None:
200220
)
201221
)
202222

223+
def _depth_range_from_widget(self, value) -> tuple[float, float] | None:
224+
"""The pair to send for a range the front end wrote, or ``None``.
225+
226+
The script keeps the pair in order itself; this is the same rule
227+
applied again, so that nothing a front end writes reaches the model
228+
as a pair it would refuse. A value that is not a pair of numbers
229+
is dropped.
230+
"""
231+
try:
232+
start, solid = (float(part) for part in value)
233+
except (TypeError, ValueError):
234+
return None
235+
pair = coerce_depth_range(start, solid)
236+
if pair != (start, solid):
237+
# Show what was sent.
238+
self._applying = True
239+
try:
240+
self.values = {**self.values, "outline_depth_range": list(pair)}
241+
finally:
242+
self._applying = False
243+
return pair
244+
203245
# ------------------------------------------------------------------
204246
# model -> widget
205247
# ------------------------------------------------------------------
@@ -214,6 +256,8 @@ def _apply(self, field: str, value: Any) -> None:
214256
return # a field this panel does not display
215257
if field == "outline_selected_labels":
216258
value = {str(k): int(v) for k, v in (value or {}).items()}
259+
elif field == "outline_depth_range":
260+
value = [float(v) for v in value]
217261
self._applying = True
218262
try:
219263
self.values = {**self.values, field: value}
@@ -254,18 +298,29 @@ class AnywidgetLabelsOutlineControls(_PaletteFollowingPanel):
254298
"""Outline mode, slot and per-label selection for a labels visual.
255299
256300
The panel changes shape with its mode, because ``outline.slot`` means
257-
two different things. In whole-volume and all-boundaries mode it
258-
chooses the colour, so it gets the swatch row every other visual has; in
259-
per-label mode it only decides whether the volume participates, so it
260-
gets a checkbox and the per-label rows appear. Re-describing the
261-
controls is enough -- the ESM rebuilds on ``change:controls``.
301+
two different things. In whole-volume, all-boundaries and
302+
depth-threshold mode it chooses the colour, so it gets the swatch row
303+
every other visual has; in per-label mode it only decides whether the
304+
volume participates, so it gets a checkbox and the per-label rows
305+
appear. The depth range row is drawn in depth-threshold mode only.
306+
Re-describing the controls is enough -- the ESM rebuilds on
307+
``change:controls``.
262308
"""
263309

264310
section: ClassVar[str] = "labels_outline"
265311
DEFAULT_TITLE: ClassVar[str] = VISUAL_RENDER_TITLES["labels_outline"]
266312

313+
def __init__(self, visual_ids, values, **kwargs) -> None:
314+
from cellier.visuals._label_memory import DEFAULT_OUTLINE_DEPTH_RANGE
315+
316+
# A panel built with no range shows the model's default.
317+
values = {"outline_depth_range": DEFAULT_OUTLINE_DEPTH_RANGE, **values}
318+
super().__init__(visual_ids, values, **kwargs)
319+
267320
def _adjust_for_mode(self, entry: dict, values: dict[str, Any]) -> dict | None:
268321
mode = values.get("outline_mode") or "per_label"
322+
if entry["field"] == "outline_depth_range" and mode not in DEPTH_RANGE_MODES:
323+
return None # no depth bands in this mode
269324
if mode in SLOT_IS_COLOUR_MODES:
270325
if entry["field"] == "outline_selected_labels":
271326
return None # no per-label colours in these modes
@@ -277,6 +332,17 @@ def _adjust_for_mode(self, entry: dict, values: dict[str, Any]) -> dict | None:
277332
return entry
278333

279334
def _apply(self, field: str, value: Any) -> None:
335+
if field == "outline_depth_range" and field not in {
336+
c["field"] for c in self.controls
337+
}:
338+
# The row is not drawn in this mode, but the value is what it
339+
# shows when the mode is next entered.
340+
self._applying = True
341+
try:
342+
self.values = {**self.values, field: [float(v) for v in value]}
343+
finally:
344+
self._applying = False
345+
return
280346
super()._apply(field, value)
281347
if field == "outline_mode":
282348
self.controls = self._describe_controls(self.values)

0 commit comments

Comments
 (0)