Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 149 additions & 50 deletions src/cellier/controller.py

Large diffs are not rendered by default.

48 changes: 36 additions & 12 deletions src/cellier/convenience/_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,7 @@ def add_labels(
pick_write: bool = True,
outline_selected_labels: dict[int, int] | None = None,
outline_mode: OutlineMode = "per_label",
outline_depth_range: tuple[float, float] | None = None,
clipping_planes: Sequence[ClippingPlane] = (),
render_planes: Sequence[RenderPlane] = (),
) -> LabelMemoryVisual:
Expand Down Expand Up @@ -1429,12 +1430,22 @@ def add_labels(
Maps a label value to the palette slot the selection layer draws
it in. ``None`` (default) selects no label, so an outlined
labels visual shows boundaries only.
outline_mode : {"per_label", "whole_object", "all_boundaries"}
How the labels are outlined. ``"per_label"`` (default) outlines
the label values in ``outline_selected_labels``, each in its own
slot's colour. ``"whole_object"`` outlines the volume as one
silhouette and ``"all_boundaries"`` every label's boundary, both
in the colour of the ``outline`` slot.
outline_mode : str
How the labels are outlined: ``"per_label"``, ``"whole_object"``,
``"all_boundaries"`` or ``"depth_threshold"``. ``"per_label"``
(default) outlines the label values in
``outline_selected_labels``, each in its own slot's colour.
``"whole_object"`` outlines the volume as one silhouette and
``"all_boundaries"`` every label's boundary, both in the colour
of the ``outline`` slot. ``"depth_threshold"`` is
``"all_boundaries"`` and also a line where one part of a label
passes in front of another part of the same label.
outline_depth_range : tuple[float, float] or None
``(start, solid)`` of the depth lines of ``"depth_threshold"``
mode, as fractions of the distance from the camera: a jump in
depth of ``start`` is where a line begins and from ``solid`` up
it is drawn in full. ``None`` (default) keeps the model's
default, ``(0.01, 0.06)``. No effect in the other modes.

clipping_planes : Sequence[ClippingPlane]
Clipping planes, in the store's level-0 data coordinates: the
Expand Down Expand Up @@ -1467,6 +1478,7 @@ def add_labels(
pick_write=pick_write,
outline_selected_labels=outline_selected_labels,
outline_mode=outline_mode,
outline_depth_range=outline_depth_range,
clipping_planes=clipping_planes,
render_planes=render_planes,
)
Expand Down Expand Up @@ -1979,6 +1991,7 @@ def add_labels_multiscale(
pick_write: bool = True,
outline_selected_labels: dict[int, int] | None = None,
outline_mode: OutlineMode = "per_label",
outline_depth_range: tuple[float, float] | None = None,
clipping_planes: Sequence[ClippingPlane] = (),
render_planes: Sequence[RenderPlane] = (),
) -> MultiscaleLabelVisual:
Expand Down Expand Up @@ -2023,12 +2036,22 @@ def add_labels_multiscale(
Maps a label value to the palette slot the selection layer draws
it in. ``None`` (default) selects no label, so an outlined
labels visual shows boundaries only.
outline_mode : {"per_label", "whole_object", "all_boundaries"}
How the labels are outlined. ``"per_label"`` (default) outlines
the label values in ``outline_selected_labels``, each in its own
slot's colour. ``"whole_object"`` outlines the volume as one
silhouette and ``"all_boundaries"`` every label's boundary, both
in the colour of the ``outline`` slot.
outline_mode : str
How the labels are outlined: ``"per_label"``, ``"whole_object"``,
``"all_boundaries"`` or ``"depth_threshold"``. ``"per_label"``
(default) outlines the label values in
``outline_selected_labels``, each in its own slot's colour.
``"whole_object"`` outlines the volume as one silhouette and
``"all_boundaries"`` every label's boundary, both in the colour
of the ``outline`` slot. ``"depth_threshold"`` is
``"all_boundaries"`` and also a line where one part of a label
passes in front of another part of the same label.
outline_depth_range : tuple[float, float] or None
``(start, solid)`` of the depth lines of ``"depth_threshold"``
mode, as fractions of the distance from the camera: a jump in
depth of ``start`` is where a line begins and from ``solid`` up
it is drawn in full. ``None`` (default) keeps the model's
default, ``(0.01, 0.06)``. No effect in the other modes.

clipping_planes : Sequence[ClippingPlane]
Clipping planes, in the store's level-0 data coordinates: the
Expand Down Expand Up @@ -2062,6 +2085,7 @@ def add_labels_multiscale(
pick_write=pick_write,
outline_selected_labels=outline_selected_labels,
outline_mode=outline_mode,
outline_depth_range=outline_depth_range,
clipping_planes=clipping_planes,
render_planes=render_planes,
)
Expand Down
4 changes: 4 additions & 0 deletions src/cellier/convenience/layout/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@ def _outline_specs(
# ``per_label`` without it -- which misdrew every visual in
# another mode.
values["outline_mode"] = visual.outline_mode
# What the depth range row shows in ``depth_threshold`` mode. It
# travels in every mode, because the mode can be entered from the
# panel.
values["outline_depth_range"] = tuple(visual.outline_depth_range)
return [ControlSpec(kind, _CONTROL_TITLES[kind], values)]


Expand Down
4 changes: 2 additions & 2 deletions src/cellier/events/_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,8 +914,8 @@ class VisualRenderChangedEvent(NamedTuple):
visual_id : UUID
Model-layer ID of the visual whose settings changed.
field_name : str
``"outline.slot"``, ``"outline.placement"``,
``"ambient_occlusion"`` or ``"outline_selected_labels"``.
One of ``VISUAL_RENDER_FIELDS``, such as ``"outline.slot"``,
``"ambient_occlusion"`` or ``"outline_depth_range"``.
new_value : Any
The new field value.
"""
Expand Down
4 changes: 2 additions & 2 deletions src/cellier/events/_update_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,8 @@ class VisualRenderUpdateEvent(NamedTuple):
visual_id :
Target visual.
field :
``"outline.slot"``, ``"outline.placement"``,
``"ambient_occlusion"`` or ``"outline_selected_labels"``.
One of ``VISUAL_RENDER_FIELDS``, such as ``"outline.slot"``,
``"ambient_occlusion"`` or ``"outline_depth_range"``.
value :
New value for the field.
"""
Expand Down
71 changes: 67 additions & 4 deletions src/cellier/gui/_render_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ class RenderControl(NamedTuple):
What the panel calls this control.
kind : str
``"bool"``, ``"int"``, ``"float"``, ``"color"``, ``"palette"``, or --
for the per-visual controls -- ``"slot"``, ``"choice"`` or
``"label_selection"``.
for the per-visual controls -- ``"slot"``, ``"choice"``,
``"label_selection"`` or ``"range"``. A range is a pair of
numbers, the second never below the first, set as one value.
A palette is a fixed-length row of RGB swatches; its length is a
config decision rather than something a panel lets a user change.
minimum, maximum : float or None
Expand All @@ -129,6 +130,7 @@ class RenderControl(NamedTuple):
"slot",
"choice",
"label_selection",
"range",
]
minimum: float | None = None
maximum: float | None = None
Expand Down Expand Up @@ -369,12 +371,48 @@ def _layer_controls(layer: str, group: str, *, with_color: bool):
("Per label", "per_label"),
("Whole volume", "whole_object"),
("All boundaries", "all_boundaries"),
("Depth threshold", "depth_threshold"),
)

#: Modes in which ``outline.slot`` chooses a colour rather than mere
#: participation, so the panel shows the swatch row and drops the per-label
#: rows. Shared by both toolkits so they cannot drift apart.
SLOT_IS_COLOUR_MODES: frozenset[str] = frozenset({"whole_object", "all_boundaries"})
SLOT_IS_COLOUR_MODES: frozenset[str] = frozenset(
{"whole_object", "all_boundaries", "depth_threshold"}
)

#: Modes that draw depth bands, so the panel shows the depth range row.
DEPTH_RANGE_MODES: frozenset[str] = frozenset({"depth_threshold"})

#: The two boxes of a depth range row, in the order of the pair.
DEPTH_RANGE_LABELS: tuple[str, str] = ("Start", "Solid")

#: Smallest start a panel offers. Below this the jump is inside the noise
#: of the depth buffer at the far end of a wide depth range.
DEPTH_RANGE_MINIMUM: float = 0.001

#: Largest solid value a panel offers. A jump is a fraction of the distance
#: to the nearer surface, so it has no upper bound; past a few times that
#: distance every line is already solid.
DEPTH_RANGE_SOLID_MAXIMUM: float = 10.0

#: What a click on a spin arrow changes a depth range value by.
DEPTH_RANGE_STEP: float = 0.005


def coerce_depth_range(start: float, solid: float) -> tuple[float, float]:
"""Return the pair a depth range row holds for what its boxes say.

The start is kept inside what the row offers, and the solid value is
never below it: raising the start past the solid value carries the solid
value up with it, and the solid value cannot be set below the start. So
the row never holds a pair the model would reject. Shared by both
toolkits so they cannot drift apart.
"""
start = min(max(float(start), DEPTH_RANGE_MINIMUM), 1.0)
solid = min(float(solid), DEPTH_RANGE_SOLID_MAXIMUM)
return start, max(solid, start)


_OUTLINE_MODE_TOOLTIP = (
"Per label gives each label its own band, so touching labels keep a "
Expand All @@ -384,7 +422,22 @@ def _layer_controls(layer: str, group: str, *, with_color: bool):
"coloured by the slot above. All boundaries bands every label, "
"boundaries between touching labels included, all in that same single "
"colour -- whole volume's colour with per label's boundaries. The "
"selected labels below do not apply in it."
"selected labels below do not apply in it. Depth threshold is all "
"boundaries and one thing more: a line where one part of a label passes "
"in front of another part of the same label, as where two branches of a "
"tree cross. It adds lines in a 3D view only."
)

_DEPTH_RANGE_TOOLTIP = (
"How large a jump in depth inside one label is outlined, and how "
"strongly. Both values are fractions of the distance from the camera "
"to the nearer surface. A jump of Start is where a line begins, at no "
"alpha; from Solid up the line is drawn in full; between the two it "
"fades in. Equal values give a hard edge. The steps between voxels "
"are jumps in depth too: zoomed in until a voxel covers several pixels, "
"or while a coarse level is on screen, they are outlined as well, and a "
"larger Start removes them. A Start near 0.001 is inside the noise of "
"the depth buffer when the scene's depth range is very wide."
)


Expand Down Expand Up @@ -451,6 +504,16 @@ def _layer_controls(layer: str, group: str, *, with_color: bool):
"labels_outline": (
RenderControl("outline_mode", "Mode", "choice", tooltip=_OUTLINE_MODE_TOOLTIP),
RenderControl("outline.slot", "Outline", "slot", tooltip=_SLOT_TOOLTIP),
# Drawn only in the modes of ``DEPTH_RANGE_MODES``.
RenderControl(
"outline_depth_range",
"Depth range",
"range",
minimum=DEPTH_RANGE_MINIMUM,
maximum=DEPTH_RANGE_SOLID_MAXIMUM,
decimals=3,
tooltip=_DEPTH_RANGE_TOOLTIP,
),
RenderControl(
"outline.placement", "Placement", "choice", tooltip=_PLACEMENT_TOOLTIP
),
Expand Down
76 changes: 71 additions & 5 deletions src/cellier/gui/anywidget/render/_per_visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@
)
from cellier.gui._render_controls import (
AMBIENT_OCCLUSION_CHOICES,
DEPTH_RANGE_LABELS,
DEPTH_RANGE_MODES,
DEPTH_RANGE_STEP,
OUTLINE_MODE_CHOICES,
PLACEMENT_CHOICES,
SLOT_IS_COLOUR_MODES,
VISUAL_RENDER_CONTROLS,
VISUAL_RENDER_TITLES,
coerce_depth_range,
visual_path,
with_api_path,
)
Expand Down Expand Up @@ -132,6 +136,15 @@ def _describe_controls(self, values: dict[str, Any]) -> list[dict]:
entry["options"] = _choice_options(OUTLINE_MODE_CHOICES)
elif control.kind == "label_selection":
entry["max_rows"] = MAX_LABEL_ROWS
elif control.kind == "range":
# The first box runs from the minimum to 1, the second from
# the first box's value to the maximum.
entry["labels"] = list(DEPTH_RANGE_LABELS)
entry["minimum"] = control.minimum
entry["maximum"] = control.maximum
entry["first_maximum"] = 1.0
entry["decimals"] = control.decimals
entry["step"] = DEPTH_RANGE_STEP
described.append(self._adjust_for_mode(entry, values))
return [entry for entry in described if entry is not None]

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

# ------------------------------------------------------------------
Expand Down Expand Up @@ -190,6 +206,10 @@ def _on_values_change(self, change) -> None:
value = {int(k): int(v) for k, v in value.items()}
elif field == "outline.slot":
value = int(value)
elif field == "outline_depth_range":
value = self._depth_range_from_widget(value)
if value is None:
continue
for visual_id in self._visual_ids:
self.changed.emit(
VisualRenderUpdateEvent(
Expand All @@ -200,6 +220,28 @@ def _on_values_change(self, change) -> None:
)
)

def _depth_range_from_widget(self, value) -> tuple[float, float] | None:
"""The pair to send for a range the front end wrote, or ``None``.

The script keeps the pair in order itself; this is the same rule
applied again, so that nothing a front end writes reaches the model
as a pair it would refuse. A value that is not a pair of numbers
is dropped.
"""
try:
start, solid = (float(part) for part in value)
except (TypeError, ValueError):
return None
pair = coerce_depth_range(start, solid)
if pair != (start, solid):
# Show what was sent.
self._applying = True
try:
self.values = {**self.values, "outline_depth_range": list(pair)}
finally:
self._applying = False
return pair

# ------------------------------------------------------------------
# model -> widget
# ------------------------------------------------------------------
Expand All @@ -214,6 +256,8 @@ def _apply(self, field: str, value: Any) -> None:
return # a field this panel does not display
if field == "outline_selected_labels":
value = {str(k): int(v) for k, v in (value or {}).items()}
elif field == "outline_depth_range":
value = [float(v) for v in value]
self._applying = True
try:
self.values = {**self.values, field: value}
Expand Down Expand Up @@ -254,18 +298,29 @@ class AnywidgetLabelsOutlineControls(_PaletteFollowingPanel):
"""Outline mode, slot and per-label selection for a labels visual.

The panel changes shape with its mode, because ``outline.slot`` means
two different things. In whole-volume and all-boundaries mode it
chooses the colour, so it gets the swatch row every other visual has; in
per-label mode it only decides whether the volume participates, so it
gets a checkbox and the per-label rows appear. Re-describing the
controls is enough -- the ESM rebuilds on ``change:controls``.
two different things. In whole-volume, all-boundaries and
depth-threshold mode it chooses the colour, so it gets the swatch row
every other visual has; in per-label mode it only decides whether the
volume participates, so it gets a checkbox and the per-label rows
appear. The depth range row is drawn in depth-threshold mode only.
Re-describing the controls is enough -- the ESM rebuilds on
``change:controls``.
"""

section: ClassVar[str] = "labels_outline"
DEFAULT_TITLE: ClassVar[str] = VISUAL_RENDER_TITLES["labels_outline"]

def __init__(self, visual_ids, values, **kwargs) -> None:
from cellier.visuals._label_memory import DEFAULT_OUTLINE_DEPTH_RANGE

# A panel built with no range shows the model's default.
values = {"outline_depth_range": DEFAULT_OUTLINE_DEPTH_RANGE, **values}
super().__init__(visual_ids, values, **kwargs)

def _adjust_for_mode(self, entry: dict, values: dict[str, Any]) -> dict | None:
mode = values.get("outline_mode") or "per_label"
if entry["field"] == "outline_depth_range" and mode not in DEPTH_RANGE_MODES:
return None # no depth bands in this mode
if mode in SLOT_IS_COLOUR_MODES:
if entry["field"] == "outline_selected_labels":
return None # no per-label colours in these modes
Expand All @@ -277,6 +332,17 @@ def _adjust_for_mode(self, entry: dict, values: dict[str, Any]) -> dict | None:
return entry

def _apply(self, field: str, value: Any) -> None:
if field == "outline_depth_range" and field not in {
c["field"] for c in self.controls
}:
# The row is not drawn in this mode, but the value is what it
# shows when the mode is next entered.
self._applying = True
try:
self.values = {**self.values, field: [float(v) for v in value]}
finally:
self._applying = False
return
super()._apply(field, value)
if field == "outline_mode":
self.controls = self._describe_controls(self.values)
Expand Down
Loading
Loading