diff --git a/src/cellier/controller.py b/src/cellier/controller.py index 26a1270b..605b0a5c 100644 --- a/src/cellier/controller.py +++ b/src/cellier/controller.py @@ -642,6 +642,7 @@ def _apply_palette(controller, value) -> None: "ambient_occlusion", "outline_selected_labels", "outline_mode", + "outline_depth_range", "pick_write", ) """``pick_write`` is here because both features depend on it. @@ -678,15 +679,28 @@ def _resolve_render_config_route(section: str, field: str) -> _RenderConfigRoute ) -#: ``outline_mode`` -> the LUT ``kind`` that implements it. Spelled out -#: rather than tested against one mode, so a mode added to the model without -#: a kind here fails loudly instead of silently outlining as a silhouette. -_LABELS_OUTLINE_KINDS: dict[str, int] = { - "per_label": KIND_LABEL, - "whole_object": KIND_WHOLE_OBJECT, - "all_boundaries": KIND_LABEL_ALL, +#: ``outline_mode`` -> the LUT ``kind`` that implements it, and whether the +#: visual's ``outline_depth_range`` is sent with it. Spelled out rather than +#: tested against one mode, so a mode added to the model without an entry +#: here fails loudly instead of silently outlining as a silhouette. +_LABELS_OUTLINE_KINDS: dict[str, tuple[int, bool]] = { + "per_label": (KIND_LABEL, False), + "whole_object": (KIND_WHOLE_OBJECT, False), + "all_boundaries": (KIND_LABEL_ALL, False), + "depth_threshold": (KIND_LABEL_ALL, True), } +#: The modes in which every band takes the colour of the visual's own slot, +#: so that the per-label selection is suppressed on the GPU. +_LABELS_SLOT_COLOUR_MODES: frozenset[str] = frozenset( + {"all_boundaries", "depth_threshold"} +) + +#: The render fields only a labels visual has. +_LABELS_ONLY_RENDER_FIELDS: frozenset[str] = frozenset( + {"outline_selected_labels", "outline_mode", "outline_depth_range"} +) + def _render_mode_for(displayed_axes: Sequence[int]) -> str: """The render mode a displayed-axes tuple selects: 3 axes is 3D, 2 is 2D.""" @@ -710,10 +724,43 @@ def _outline_kind(visual) -> int: from cellier.visuals._label_memory import BaseLabelsVisual if isinstance(visual, BaseLabelsVisual): - return _LABELS_OUTLINE_KINDS[visual.outline_mode] + return _LABELS_OUTLINE_KINDS[visual.outline_mode][0] return KIND_WHOLE_OBJECT +def _outline_depth_range(visual) -> tuple[float, float] | None: + """Return the depth range the outline pass should use for *visual*. + + ``None`` unless the visual is a labels visual in a mode that draws depth + bands: the range is a field of every labels visual and is sent only then. + """ + from cellier.visuals._label_memory import BaseLabelsVisual + + if ( + isinstance(visual, BaseLabelsVisual) + and _LABELS_OUTLINE_KINDS[visual.outline_mode][1] + ): + return tuple(visual.outline_depth_range) + return None + + +def _check_outline_mode(outline_mode) -> None: + """Raise unless *outline_mode* is a mode of the labels model. + + The labels model does not validate on assignment, so an unknown mode + would otherwise be stored silently. + """ + from typing import get_args + + from cellier.visuals._label_memory import OutlineMode + + valid = get_args(OutlineMode) + if outline_mode not in valid: + raise ValueError( + f"outline_mode must be one of {list(valid)}; got {outline_mode!r}." + ) + + def _default_placement(visual) -> str: """Return the default outline placement for one visual. @@ -739,6 +786,7 @@ def _apply_render_settings( outline_selected_labels: dict[int, int] | None = None, pick_write: bool | None = None, outline_mode: str | None = None, + outline_depth_range: tuple[float, float] | None = None, ): """Apply the screen-space render settings an ``add_*`` call carried. @@ -757,18 +805,15 @@ def _apply_render_settings( if pick_write is not None: visual_model.pick_write = pick_write if outline_mode is not None: - from typing import get_args - - from cellier.visuals._label_memory import OutlineMode - - # Checked here because the labels model does not validate on - # assignment: an unknown mode would otherwise be stored silently. - valid = get_args(OutlineMode) - if outline_mode not in valid: - raise ValueError( - f"outline_mode must be one of {list(valid)}; got {outline_mode!r}." - ) + _check_outline_mode(outline_mode) visual_model.outline_mode = outline_mode + if outline_depth_range is not None: + from cellier.visuals._label_memory import validate_outline_depth_range + + # Checked here for the same reason as the mode. + visual_model.outline_depth_range = validate_outline_depth_range( + outline_depth_range + ) return visual_model @@ -1716,6 +1761,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: @@ -1754,12 +1800,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 @@ -1799,6 +1855,7 @@ def add_labels( pick_write=pick_write, outline_selected_labels=outline_selected_labels, outline_mode=outline_mode, + outline_depth_range=outline_depth_range, ) return self.add_visual(scene_id, visual_model, data_store=data) @@ -2347,6 +2404,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: @@ -2388,12 +2446,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 @@ -2433,6 +2501,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, ) return self.add_visual(scene_id, visual_model, data_store=data) @@ -5627,7 +5696,8 @@ def _wire_visual_render(self, visual: BaseVisual) -> None: ``ambient_occlusion`` field, and -- on labels visuals only -- the ``outline_selected_labels`` map. The sub-model reports which of its fields changed, so it gets the catch-all handler; the two plain - fields each know their own name. + fields each know their own name. A labels visual's ``outline_mode`` + and ``outline_depth_range`` are plain fields too. """ handlers = self._visual_psygnal_handlers.setdefault(visual.id, []) @@ -5650,6 +5720,12 @@ def _wire_visual_render(self, visual: BaseVisual) -> None: visual.events.outline_mode.connect(mode_handler) handlers.append((visual.events.outline_mode, mode_handler)) + range_handler = self._make_visual_render_handler( + visual.id, "outline_depth_range" + ) + visual.events.outline_depth_range.connect(range_handler) + handlers.append((visual.events.outline_depth_range, range_handler)) + def _make_visual_outline_handler(self, visual_id: UUID) -> Callable: """Return a catch-all handler for a visual's ``VisualOutline``.""" @@ -5756,8 +5832,11 @@ def update_visual_render_field( visual_id : Target visual. field : - ``"outline.slot"``, ``"outline.placement"``, - ``"ambient_occlusion"`` or ``"outline_selected_labels"``. + One of ``VISUAL_RENDER_FIELDS``: ``"outline.slot"``, + ``"outline.placement"``, ``"ambient_occlusion"``, + ``"pick_write"`` and, on a labels visual, + ``"outline_selected_labels"``, ``"outline_mode"`` and + ``"outline_depth_range"``. value : New value for the field. source_id : @@ -5768,7 +5847,10 @@ def update_visual_render_field( Raises ------ ValueError - If *field* is not a settable per-visual render field. + If *field* is not a settable per-visual render field, if it is + a labels field and the visual is not a labels visual, or if + *value* is not a valid ``outline_mode`` or + ``outline_depth_range``. """ if field not in VISUAL_RENDER_FIELDS: close = difflib.get_close_matches(field, VISUAL_RENDER_FIELDS, n=1) @@ -5778,13 +5860,21 @@ def update_visual_render_field( f"{suggestion} Valid fields: {list(VISUAL_RENDER_FIELDS)}." ) visual = self._get_visual_model(visual_id) - if field in {"outline_selected_labels", "outline_mode"} and not isinstance( + if field in _LABELS_ONLY_RENDER_FIELDS and not isinstance( visual, BaseLabelsVisual ): raise ValueError( f"{field} is only available on labels visuals; " f"a {type(visual).__name__} is outlined as one silhouette." ) + # The labels model does not validate on assignment, and a bad value + # would fail later, inside the handler that pushes it to the renderer. + if field == "outline_mode": + _check_outline_mode(value) + elif field == "outline_depth_range": + from cellier.visuals._label_memory import validate_outline_depth_range + + value = validate_outline_depth_range(value) target = visual.outline if field.startswith("outline.") else visual leaf = field.split(".")[-1] token = _visual_render_source_id_override.set(source_id) @@ -5972,13 +6062,18 @@ def _apply_visual_render_field( elif field_name == "outline_selected_labels": self._push_label_selection(visual) elif field_name == "outline_mode": - # The mode is carried in the LUT entry's ``kind``, so re-pushing - # the outline covers the shader side -- no material rebuild. The - # selection goes with it because ``all_boundaries`` suppresses it - # on the GPU: entering the mode has to clear it and leaving has to - # put it back. + # The mode is carried in the LUT entry (its ``kind``, and whether + # it has a depth range), so re-pushing the outline covers the + # shader side -- no material rebuild. The selection goes with it + # because ``all_boundaries`` and ``depth_threshold`` suppress it + # on the GPU: entering such a mode has to clear it and leaving + # has to put it back. self._push_visual_outline(visual) self._push_label_selection(visual) + elif field_name == "outline_depth_range": + # Carried in the LUT entry too. In a mode that does not send it + # the entry comes out unchanged and nothing is uploaded. + self._push_visual_outline(visual) def _push_visual_outline(self, visual: BaseVisual) -> None: """Send a visual's outline assignment to the render layer. @@ -6015,23 +6110,27 @@ def _push_visual_outline(self, visual: BaseVisual) -> None: placement = visual.outline.placement or _default_placement(visual) kind = _outline_kind(visual) self._render_manager.set_visual_outline( - visual.id, slot=slot, placement=placement, kind=kind + visual.id, + slot=slot, + placement=placement, + kind=kind, + depth_range=_outline_depth_range(visual), ) def _push_label_selection(self, visual: BaseVisual) -> None: """Send a labels visual's per-label selection to the render layer. - In ``all_boundaries`` mode the selection is suppressed on the GPU - rather than pushed. It is not only a colour there: a selected - label's outline key *is* its slot number, so two touching labels - sharing a slot would share a key and lose the boundary between them - -- in the one mode whose whole purpose is showing every boundary. - The model field is left untouched, so switching back to - ``per_label`` restores the selection exactly. + In ``all_boundaries`` and ``depth_threshold`` mode the selection is + suppressed on the GPU rather than pushed. It is not only a colour + there: a selected label's outline key *is* its slot number, so two + touching labels sharing a slot would share a key and lose the + boundary between them -- in the modes whose purpose is showing + every boundary. The model field is left untouched, so switching + back to ``per_label`` restores the selection exactly. """ selection = ( {} - if visual.outline_mode == "all_boundaries" + if visual.outline_mode in _LABELS_SLOT_COLOUR_MODES else dict(visual.outline_selected_labels) ) self._render_manager.set_label_selection(visual.id, selection) diff --git a/src/cellier/convenience/_viewer.py b/src/cellier/convenience/_viewer.py index b0a6b559..b9540124 100644 --- a/src/cellier/convenience/_viewer.py +++ b/src/cellier/convenience/_viewer.py @@ -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: @@ -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 @@ -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, ) @@ -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: @@ -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 @@ -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, ) diff --git a/src/cellier/convenience/layout/_shared.py b/src/cellier/convenience/layout/_shared.py index 69682dd5..3a8dbad7 100644 --- a/src/cellier/convenience/layout/_shared.py +++ b/src/cellier/convenience/layout/_shared.py @@ -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)] diff --git a/src/cellier/events/_events.py b/src/cellier/events/_events.py index 84e19f7c..d9d864b2 100644 --- a/src/cellier/events/_events.py +++ b/src/cellier/events/_events.py @@ -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. """ diff --git a/src/cellier/events/_update_events.py b/src/cellier/events/_update_events.py index 770cf1d5..361e522a 100644 --- a/src/cellier/events/_update_events.py +++ b/src/cellier/events/_update_events.py @@ -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. """ diff --git a/src/cellier/gui/_render_controls.py b/src/cellier/gui/_render_controls.py index 262d160d..0c9c9ba7 100644 --- a/src/cellier/gui/_render_controls.py +++ b/src/cellier/gui/_render_controls.py @@ -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 @@ -129,6 +130,7 @@ class RenderControl(NamedTuple): "slot", "choice", "label_selection", + "range", ] minimum: float | None = None maximum: float | None = None @@ -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 " @@ -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." ) @@ -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 ), diff --git a/src/cellier/gui/anywidget/render/_per_visual.py b/src/cellier/gui/anywidget/render/_per_visual.py index 067b3ee6..c269dc73 100644 --- a/src/cellier/gui/anywidget/render/_per_visual.py +++ b/src/cellier/gui/anywidget/render/_per_visual.py @@ -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, ) @@ -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] @@ -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 # ------------------------------------------------------------------ @@ -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( @@ -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 # ------------------------------------------------------------------ @@ -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} @@ -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 @@ -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) diff --git a/src/cellier/gui/anywidget/render/static/render_panel.css b/src/cellier/gui/anywidget/render/static/render_panel.css index 1fd602da..22e5cac8 100644 --- a/src/cellier/gui/anywidget/render/static/render_panel.css +++ b/src/cellier/gui/anywidget/render/static/render_panel.css @@ -46,6 +46,23 @@ flex: 1 1 auto; } +.cellier-render-range { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; +} + +.cellier-render-range-part { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.cellier-render-range-part input[type="number"] { + width: 5.5em; +} + .cellier-render-value { min-width: 3.5em; text-align: left; diff --git a/src/cellier/gui/anywidget/render/static/visual_render_panel.js b/src/cellier/gui/anywidget/render/static/visual_render_panel.js index f8e56099..2d2c72aa 100644 --- a/src/cellier/gui/anywidget/render/static/visual_render_panel.js +++ b/src/cellier/gui/anywidget/render/static/visual_render_panel.js @@ -1,7 +1,7 @@ // Per-visual render-settings ESM. One file drives all four panels -- the -// outline slot picker, the labels variant with its per-label rows, the -// occlusion menu and the picking checkbox -- from a `controls` description -// the Python side syncs. +// outline slot picker, the labels variant with its per-label rows and its +// depth range, the occlusion menu and the picking checkbox -- from a +// `controls` description the Python side syncs. // // State is a single `values` dict keyed by the dotted field name, rather // than a trait per field: these panels are small and fixed, and it avoids @@ -143,6 +143,55 @@ function render({ model, el }) { return { row: makeRow(spec, wrap), apply: draw, redrawOnPalette: draw }; } + function buildRange(spec) { + // Two number boxes for a pair [first, second], written as one value. + // The second is never below the first: raising the first carries the + // second up with it, and the second cannot be set under the first. + const wrap = document.createElement("div"); + wrap.className = "cellier-render-range"; + if (spec.tooltip) wrap.title = spec.tooltip; + + const inputs = (spec.labels || ["", ""]).map((text) => { + const label = document.createElement("label"); + label.className = "cellier-render-range-part"; + label.appendChild(document.createTextNode(text + " ")); + const input = document.createElement("input"); + input.type = "number"; + input.min = String(spec.minimum); + input.step = String(spec.step); + label.appendChild(input); + wrap.appendChild(label); + return input; + }); + inputs[0].max = String(spec.first_maximum); + inputs[1].max = String(spec.maximum); + + function show(pair) { + if (!pair) return; + inputs[0].value = Number(pair[0]).toFixed(spec.decimals); + inputs[1].value = Number(pair[1]).toFixed(spec.decimals); + } + + function commit() { + const shown = values()[spec.field] || [spec.minimum, spec.minimum]; + // An emptied box is not a number (Number("") is 0): keep what it had. + const read = (input) => + input.value.trim() === "" ? NaN : Number(input.value); + let first = read(inputs[0]); + let second = read(inputs[1]); + if (!Number.isFinite(first)) first = Number(shown[0]); + if (!Number.isFinite(second)) second = Number(shown[1]); + first = Math.min(Math.max(first, spec.minimum), spec.first_maximum); + second = Math.min(Math.max(second, first), spec.maximum); + show([first, second]); + setValue(spec.field, [first, second]); + } + + inputs.forEach((input) => input.addEventListener("change", commit)); + show(values()[spec.field]); + return { row: makeRow(spec, wrap), apply: show }; + } + function buildLabelSelection(spec) { // One row per selected label: its value, the slot swatch it draws in, // and a remove button. @@ -250,6 +299,7 @@ function render({ model, el }) { if (spec.kind === "slot") made = buildSlot(spec); else if (spec.kind === "choice") made = buildChoice(spec); else if (spec.kind === "label_selection") made = buildLabelSelection(spec); + else if (spec.kind === "range") made = buildRange(spec); else made = buildBool(spec); built.push({ spec, ...made }); body.appendChild(made.row); diff --git a/src/cellier/gui/qt/render/_per_visual.py b/src/cellier/gui/qt/render/_per_visual.py index dd30d3db..c2a03e8e 100644 --- a/src/cellier/gui/qt/render/_per_visual.py +++ b/src/cellier/gui/qt/render/_per_visual.py @@ -34,11 +34,16 @@ ) from cellier.gui._render_controls import ( AMBIENT_OCCLUSION_CHOICES, + DEPTH_RANGE_LABELS, + DEPTH_RANGE_MODES, + DEPTH_RANGE_SOLID_MAXIMUM, + 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, ) @@ -437,8 +442,9 @@ class QtLabelsOutlineControls(_QtPaletteFollowingPanel): visual_ids : The visuals to drive. values : - ``outline.slot``, ``outline.placement``, ``outline_selected_labels`` - and ``default_placement``. + ``outline.slot``, ``outline.placement``, ``outline_mode``, + ``outline_selected_labels``, ``outline_depth_range`` and + ``default_placement``. palette : The current outline palette, for the per-row slot swatches. parent : @@ -488,17 +494,30 @@ def _emit(self, field: str, value: Any) -> None: # lives in the outer layout, not the section being replaced. self._build_mode_section() + def _on_visual_render_changed(self, event: VisualRenderChangedEvent) -> None: + if ( + event.field_name == "outline_depth_range" + and event.source_id != self._id + and "outline_depth_range" not in self._appliers + ): + # The row is not drawn in this mode, but the value is what it + # shows when the mode is next entered. + self._values["outline_depth_range"] = tuple(event.new_value) + return + super()._on_visual_render_changed(event) + def _mode(self) -> str: return self._values.get("outline_mode") or "per_label" def _build_mode_section(self) -> None: """Draw the controls whose meaning depends on the mode. - In whole-volume and all-boundaries mode ``outline.slot`` chooses the - colour, so it gets the same swatch row every other visual has. In - per-label mode it only decides whether the volume participates, and - the colour comes from the rows below -- so it gets a checkbox, and - the rows appear. + In whole-volume, all-boundaries and depth-threshold mode + ``outline.slot`` chooses the colour, so it gets the same swatch row + every other visual has. In per-label mode it only decides whether + the volume participates, and the colour comes from the rows below -- + so it gets a checkbox, and the rows appear. Depth-threshold mode + also gets the row for its depth range. """ while self._mode_layout.count(): item = self._mode_layout.takeAt(0) @@ -506,7 +525,11 @@ def _build_mode_section(self) -> None: if widget is not None: widget.setParent(None) widget.deleteLater() - for field in ("outline.slot", "outline_selected_labels"): + for field in ( + "outline.slot", + "outline_selected_labels", + "outline_depth_range", + ): self._appliers.pop(field, None) # The builders append to ``self._layout``; point them at the host. @@ -521,9 +544,78 @@ def _build_mode_section(self) -> None: from_model=bool, ) self._add_label_rows() + if self._mode() in DEPTH_RANGE_MODES: + self._add_depth_range() finally: self._layout = outer + # ------------------------------------------------------------------ + # The depth range + # ------------------------------------------------------------------ + + def _add_depth_range(self) -> None: + """Two spin boxes for the ``(start, solid)`` pair, sent as one value. + + The pair goes to the model whole, so the model never sees a start + above its solid value on the way from one range to another. + """ + from qtpy.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget + + from cellier.gui.qt.visuals._chrome import labelled_row + + spec = self._spec("outline_depth_range") + tooltip = self._tooltip(spec) + host = QWidget(self._content) + row = QHBoxLayout(host) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(4) + + boxes = [] + for text in DEPTH_RANGE_LABELS: + label = QLabel(text, host) + label.setToolTip(tooltip) + box = QDoubleSpinBox(host) + box.setDecimals(spec.decimals) + box.setSingleStep(DEPTH_RANGE_STEP) + # A value is sent when it is committed, not at every keystroke: + # "0.0" on the way to "0.05" is not a range. + box.setKeyboardTracking(False) + box.setToolTip(tooltip) + row.addWidget(label) + row.addWidget(box, 1) + boxes.append(box) + start_box, solid_box = boxes + start_box.setRange(spec.minimum, 1.0) + solid_box.setRange(spec.minimum, DEPTH_RANGE_SOLID_MAXIMUM) + + def _show(pair) -> None: + start, solid = pair + for box, value in ((start_box, start), (solid_box, solid)): + box.blockSignals(True) + box.setValue(float(value)) + box.blockSignals(False) + + def _changed(_value) -> None: + pair = coerce_depth_range(start_box.value(), solid_box.value()) + _show(pair) + self._emit("outline_depth_range", pair) + + _show(self._depth_range()) + start_box.valueChanged.connect(_changed) + solid_box.valueChanged.connect(_changed) + + self._depth_range_boxes = (start_box, solid_box) + self._appliers["outline_depth_range"] = _show + self._layout.addWidget(labelled_row(spec.label, host, self._content)) + + def _depth_range(self) -> tuple[float, float]: + from cellier.visuals._label_memory import DEFAULT_OUTLINE_DEPTH_RANGE + + start, solid = ( + self._values.get("outline_depth_range") or DEFAULT_OUTLINE_DEPTH_RANGE + ) + return float(start), float(solid) + def _on_palette_changed(self) -> None: """Whichever half is on screen draws from the palette.""" if self._mode() in SLOT_IS_COLOUR_MODES: diff --git a/src/cellier/render/_outline.py b/src/cellier/render/_outline.py index 82a3f421..0b6876b0 100644 --- a/src/cellier/render/_outline.py +++ b/src/cellier/render/_outline.py @@ -21,6 +21,12 @@ Thickness is measured in **internal pixels**. Effect passes run before the output pass's SSAA downsample, so at ``pixel_ratio > 1`` the on-screen band is correspondingly thinner than the configured number. + +A visual whose table entry carries a depth range also gets **depth edges**: +a band where one part of it passes in front of another part with the same +key. That term reads the frame's depth and needs the camera's inverse +projection; it is compiled in, and the depth texture bound, only while some +visual has a range (``OutlinePass.set_depth_edges``). """ from __future__ import annotations @@ -28,10 +34,12 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar +import numpy as np import wgpu from pygfx.renderers.wgpu.engine.binding import Binding from pygfx.renderers.wgpu.engine.effectpasses import ( EffectPass, + FullQuadPass, create_full_quad_pipeline, ) from pygfx.renderers.wgpu.engine.shared import get_shared @@ -44,7 +52,7 @@ from cellier.render._visual_lut import MAX_SLOT, get_shared_visual_lut if TYPE_CHECKING: # pragma: no cover - typing only - from collections.abc import Sequence + from collections.abc import Callable, Sequence import pygfx as gfx @@ -73,6 +81,7 @@ class _OutlineQuadPass: "inner_color": "4xf4", "boundaries_enabled": "i4", "selection_enabled": "i4", + "projection_transform_inv": "4x4xf4", } def __init__(self) -> None: @@ -104,6 +113,7 @@ def __init__(self) -> None: "has_inward": True, "has_outward": False, "has_outline_id": False, + "has_depth_edges": False, } self._pipeline = None self._pipeline_hash: object = None @@ -146,18 +156,30 @@ def render( lut_tex: wgpu.GPUTextureView, target_tex: wgpu.GPUTextureView, outline_id_tex: wgpu.GPUTextureView | None = None, + depth_tex: wgpu.GPUTextureView | None = None, ) -> None: - """Composite the outline over *color_tex* into *target_tex*.""" - # Whether the label-key target is bound changes the binding layout, - # so it is part of the pipeline identity as well as a template var. - self.set_template_vars(has_outline_id=outline_id_tex is not None) + """Composite the outline over *color_tex* into *target_tex*. + + Passing *depth_tex* compiles the depth edges in; the caller must + then have set the ``projection_transform_inv`` uniform. + """ + # Whether the label-key target and the depth are bound changes the + # binding layout, so each is part of the pipeline identity as well + # as a template var. + self.set_template_vars( + has_outline_id=outline_id_tex is not None, + has_depth_edges=depth_tex is not None, + ) pipeline_hash = ( (target_format := target_tex.texture.format), (outline_id_tex is not None), + (depth_tex is not None), ) if self._pipeline is None or self._pipeline_hash != pipeline_hash: self._pipeline = self._create_pipeline( - target_format, with_outline_id=outline_id_tex is not None + target_format, + with_outline_id=outline_id_tex is not None, + with_depth=depth_tex is not None, ) self._pipeline_hash = pipeline_hash @@ -185,6 +207,9 @@ def render( [{"binding": 5, "resource": outline_id_tex}] if outline_id_tex is not None else [] + ) + + ( + [{"binding": 6, "resource": depth_tex}] if depth_tex is not None else [] ), ) @@ -209,7 +234,9 @@ def render( # Internals # ------------------------------------------------------------------ - def _create_pipeline(self, target_format: str, *, with_outline_id: bool): + def _create_pipeline( + self, target_format: str, *, with_outline_id: bool, with_depth: bool = False + ): binding_layout = [ wgpu.BindGroupLayoutEntry( binding=0, @@ -279,6 +306,22 @@ def _create_pipeline(self, target_format: str, *, with_outline_id: bool): @group(0) @binding(5) var outlineIdTex: texture_2d; """ + if with_depth: + binding_layout.append( + wgpu.BindGroupLayoutEntry( + binding=6, + visibility=wgpu.ShaderStage.FRAGMENT, + texture=wgpu.TextureBindingLayout( + sample_type=wgpu.TextureSampleType.depth, + view_dimension="2d", + multisampled=False, + ), + ) + ) + definitions += """ + @group(0) @binding(6) + var depthTex: texture_depth_2d; + """ targets = [ wgpu.ColorTargetState( format=target_format, @@ -321,10 +364,15 @@ class OutlinePass(EffectPass): lut : VisualLut or None The shared ``global_id`` -> entry table. ``None`` (the default) resolves it on first draw, so a canvas that never enables outlines - never allocates the 1 MB texture. + never allocates the 4 MB texture. + get_camera : Callable[[], gfx.Camera] or None + Returns the camera the frame is drawn with. The depth edges need + its inverse projection; without it they are not drawn. """ - USES_DEPTH = False + # flush() hands the depth view over only to a pass that asks. It is + # bound, and read, only while a visual has a depth range. + USES_DEPTH = True # Fallback shader, used only if the base machinery ever compiles a # pipeline for this object. The real work happens in _OutlineQuadPass. @@ -336,11 +384,16 @@ class OutlinePass(EffectPass): """ def __init__( - self, renderer: gfx.WgpuRenderer, lut: VisualLut | None = None + self, + renderer: gfx.WgpuRenderer, + lut: VisualLut | None = None, + get_camera: Callable[[], gfx.Camera] | None = None, ) -> None: super().__init__() self._renderer = renderer self._lut = lut + self._get_camera = get_camera + self._has_depth_edges = False self._quad_pass = _OutlineQuadPass() self.enabled = False @@ -389,6 +442,14 @@ def set_placements(self, *, has_inward: bool, has_outward: bool) -> None: has_inward=bool(has_inward), has_outward=bool(has_outward) ) + def set_depth_edges(self, enabled: bool) -> None: + """Declare whether any outlined visual has a depth range. + + While none has, the depth term is compiled away and the depth + texture is not bound, so the frame is what it was without it. + """ + self._has_depth_edges = bool(enabled) + # ------------------------------------------------------------------ # Render # ------------------------------------------------------------------ @@ -404,8 +465,25 @@ def render( pick_tex = get_pick_view(self._renderer) if pick_tex is None: # No bindable pick target on this renderer: nothing to outline. - super().render(command_encoder, color_tex, depth_tex, target_tex) + # The copy shader reads no depth, so it is not handed any. + FullQuadPass.render( + self, command_encoder, colorTex=color_tex, targetTex=target_tex + ) return + if ( + not self._has_depth_edges + or self._get_camera is None + or self._get_camera() is None + ): + depth_tex = None + if depth_tex is not None: + # pygfx stores 4x4 uniforms transposed, as the SSAO pass notes. + self._quad_pass.set_uniform( + "projection_transform_inv", + np.asarray( + self._get_camera().projection_matrix_inverse, dtype=np.float32 + ).T, + ) self._quad_pass.render( command_encoder, color_tex, @@ -413,4 +491,5 @@ def render( self.lut.view, target_tex, get_extra_target_view(self._renderer, OUTLINE_ID_TARGET), + depth_tex, ) diff --git a/src/cellier/render/_ssao.py b/src/cellier/render/_ssao.py index 98afbb7f..2bed9759 100644 --- a/src/cellier/render/_ssao.py +++ b/src/cellier/render/_ssao.py @@ -126,7 +126,7 @@ class _AOComputePass: Deliberately **not** a ``FullQuadPass`` subclass, for the same reason ``_OutlineQuadPass`` is not: that class builds its binding layout from a naming convention knowing only ``float`` and ``depth`` sample types, - so the ``rgba16uint`` pick texture and the ``r8uint`` visual LUT cannot + so the ``rgba16uint`` pick texture and the ``rg16uint`` visual LUT cannot be declared through it. The layout is built here and handed to the module-level ``create_full_quad_pipeline``, which is the documented low-level entry point ``FullQuadPass`` itself is built on -- so the diff --git a/src/cellier/render/_visual_lut.py b/src/cellier/render/_visual_lut.py index ad9ee63e..90740d82 100644 --- a/src/cellier/render/_visual_lut.py +++ b/src/cellier/render/_visual_lut.py @@ -1,18 +1,19 @@ """The shared per-visual lookup table. Maps a pygfx ``global_id`` (what the pick buffer stores, and what -``wobject.id`` returns on the Python side) to a one-byte entry carrying -every per-visual flag the effect passes need. - -Two features read it: the screen-space outline pass (bits 0-6) and the -ambient occlusion pass (bit 7). **One table, one authoritative map.** -``apply`` is a whole-state sync -- ids present in the table but absent from -the supplied mapping are cleared -- so if the two features kept separate -maps, enabling outlines would silently wipe every occlusion exclusion. +``wobject.id`` returns on the Python side) to an entry carrying every +per-visual value the effect passes need. + +Two features read it: the screen-space outline pass (bits 0-6 of the entry +byte, and the depth range) and the ambient occlusion pass (bit 7). **One +table, one authoritative map.** ``apply`` is a whole-state sync -- ids +present in the table but absent from the supplied mapping are cleared -- so +if the two features kept separate maps, enabling outlines would silently +wipe every occlusion exclusion. ``RenderManager`` therefore holds a single ``{visual_id: VisualFlags}`` map and derives the whole table from it. -The table is a 1024x1024 ``r8uint`` texture -- 1 MB -- indexed as +The table is a 1024x1024 ``rg16uint`` texture -- 4 MB -- indexed as ``(id & 1023, id >> 10)``. It is deliberately *not* smaller: pygfx does not allocate ids with a counter, it draws them randomly over the full 2^20 range:: @@ -30,21 +31,34 @@ Ids are not stable across sessions, so the table is always built at runtime from cellier's visual-to-world-object mapping and never persisted. -Entry layout, one byte: - -===== ========================================================= -bits field -===== ========================================================= -0-3 selection slot; 0 = not selected, else palette index v - 1 -4-5 kind; 0 = not outlined, 1 = whole object, 2 = label, - 3 = label with a uniform colour -6 placement; 0 = inward, 1 = outward -7 excluded from ambient occlusion -===== ========================================================= - -The byte is now full: bit 7 was the last free bit and kind 3 the last free -kind. If another per-object flag or mode is ever needed the format goes -``r8uint`` -> ``r16uint`` (2 MB) and the index arithmetic is unchanged. +Entry layout. On the Python side an entry is one integer, +``byte | ramp << 8 | start << 16``, so that ``apply`` syncs every part of +it at once; the texel holds its low half in ``r`` and its high half in +``g``: + +======= ===== ===================================================== +channel bits field +======= ===== ===================================================== +r 0-3 selection slot; 0 = not selected, else palette index + v - 1 +r 4-5 kind; 0 = not outlined, 1 = whole object, 2 = label, + 3 = label with a uniform colour +r 6 placement; 0 = inward, 1 = outward +r 7 excluded from ambient occlusion +r 8-15 depth ramp code; 0 = a hard edge, else + ``solid = start * 2 ** (code / 32)`` +g 0-15 depth start code; 0 = no depth test, else + ``start = code / 65535`` +======= ===== ===================================================== + +The low byte of ``r`` is full: bit 7 was the last free bit and kind 3 the +last free kind. Readers of ``r`` must mask the bits they want, because +its upper byte is not theirs. + +The depth range is the pair ``(start, solid)`` of a depth outline: a depth +jump of ``start``, as a fraction of the distance from the camera, is where +a line begins, and from ``solid`` up it is drawn in full. See +:func:`encode_depth_range`. Entry 0 is permanently inert: the pick target clears to zero and pygfx's ``IdProvider`` seeds ``_ids_in_use = {0}``, so no object is ever given id @@ -53,6 +67,7 @@ from __future__ import annotations +import math from typing import TYPE_CHECKING import numpy as np @@ -64,6 +79,9 @@ LUT_WIDTH: int = 1024 LUT_HEIGHT: int = 1024 +#: ``rg16uint``: two 16-bit channels. +_BYTES_PER_TEXEL: int = 4 + #: Highest usable selection slot. Slot 0 means "not selected". MAX_SLOT: int = 15 @@ -86,6 +104,13 @@ #: Bit 7: the visual does not receive ambient occlusion. AO_EXCLUDED_BIT: int = 0x80 +#: Largest depth start code; the code of a start of 1. +MAX_DEPTH_START_CODE: int = 0xFFFF +#: Largest depth ramp code. +MAX_DEPTH_RAMP_CODE: int = 0xFF +#: Ramp codes per doubling of ``solid / start``. +DEPTH_RAMP_CODES_PER_OCTAVE: int = 32 + def lut_index(object_id: int) -> tuple[int, int]: """Return the ``(x, y)`` texel holding *object_id*'s entry. @@ -109,8 +134,10 @@ def encode_entry( kind: int = KIND_WHOLE_OBJECT, placement: int = PLACEMENT_INWARD, ao_excluded: bool = False, + depth_start_code: int = 0, + depth_ramp_code: int = 0, ) -> int: - """Pack a per-visual entry into its single byte. + """Pack a per-visual entry into one integer. Parameters ---------- @@ -128,11 +155,18 @@ def encode_entry( Independent of every other field: a visual can be excluded from occlusion and not outlined at all, which is what a MIP volume in a scene with no selection looks like. + depth_start_code : int + Start of the depth range as a code in ``[0, 65535]``, from + :func:`encode_depth_range`. 0 means the visual has no depth test. + depth_ramp_code : int + Ramp of the depth range as a code in ``[0, 255]``. 0 is a hard + edge. Returns ------- int - The packed byte. + The packed entry: the byte in bits 0-7, the ramp code in bits 8-15 + and the start code in bits 16-31. Raises ------ @@ -145,25 +179,105 @@ def encode_entry( raise ValueError(f"unknown outline kind: {kind}") if placement not in (PLACEMENT_INWARD, PLACEMENT_OUTWARD): raise ValueError(f"unknown outline placement: {placement}") + if not 0 <= depth_start_code <= MAX_DEPTH_START_CODE: + raise ValueError( + f"depth_start_code must be in [0, {MAX_DEPTH_START_CODE}], " + f"got {depth_start_code}" + ) + if not 0 <= depth_ramp_code <= MAX_DEPTH_RAMP_CODE: + raise ValueError( + f"depth_ramp_code must be in [0, {MAX_DEPTH_RAMP_CODE}], " + f"got {depth_ramp_code}" + ) return ( (slot & 0xF) | ((kind & 0x3) << 4) | ((placement & 0x1) << 6) | (AO_EXCLUDED_BIT if ao_excluded else 0) + | (depth_ramp_code << 8) + | (depth_start_code << 16) ) -def decode_entry(value: int) -> tuple[int, int, int, bool]: - """Unpack an entry byte into ``(slot, kind, placement, ao_excluded)``.""" +def decode_entry(value: int) -> tuple[int, int, int, bool, int, int]: + """Unpack an entry. + + Returns + ------- + tuple[int, int, int, bool, int, int] + ``(slot, kind, placement, ao_excluded, depth_start_code, + depth_ramp_code)``. + """ value = int(value) return ( value & 0xF, (value >> 4) & 0x3, (value >> 6) & 0x1, bool(value & AO_EXCLUDED_BIT), + (value >> 16) & MAX_DEPTH_START_CODE, + (value >> 8) & MAX_DEPTH_RAMP_CODE, ) +def encode_depth_range(depth_range: tuple[float, float]) -> tuple[int, int]: + """Return the codes of a depth range. + + Parameters + ---------- + depth_range : tuple[float, float] + ``(start, solid)``, both fractions of the distance from the + camera, with ``0 < start <= 1`` and ``start <= solid``. + + Returns + ------- + tuple[int, int] + ``(depth_start_code, depth_ramp_code)`` for :func:`encode_entry`. + The start code is never 0, so a range that is given never encodes + as "no depth test". The ramp code is 0 exactly when ``solid == + start``, a hard edge. + + Raises + ------ + ValueError + If the pair is out of range or out of order. + + Notes + ----- + The ramp is the ratio of ``solid`` to the start *as coded*, on a + logarithmic scale, so the solid value the GPU uses is within 1.1% of + the one given whatever the size of the start. Two cases fall outside + that: a ratio under ``2 ** (1 / 64)`` is drawn as the smallest ramp + rather than as a hard edge, and a ratio over about 250 is clamped. + """ + start, solid = (float(value) for value in depth_range) + if not 0.0 < start <= 1.0: + raise ValueError(f"the start of a depth range must be in (0, 1], got {start}") + if not solid >= start: + raise ValueError( + f"the solid value of a depth range must not be below its start, " + f"got {(start, solid)}" + ) + start_code = max(1, min(MAX_DEPTH_START_CODE, round(start * MAX_DEPTH_START_CODE))) + if solid == start: + return start_code, 0 + coded_start = start_code / MAX_DEPTH_START_CODE + ramp = DEPTH_RAMP_CODES_PER_OCTAVE * math.log2(solid / coded_start) + return start_code, max(1, min(MAX_DEPTH_RAMP_CODE, round(ramp))) + + +def decode_depth_range( + depth_start_code: int, depth_ramp_code: int +) -> tuple[float, float] | None: + """Return the ``(start, solid)`` the GPU uses for a pair of codes. + + ``None`` when the start code is 0: no depth test. + """ + if depth_start_code == 0: + return None + start = depth_start_code / MAX_DEPTH_START_CODE + return start, start * 2.0 ** (depth_ramp_code / DEPTH_RAMP_CODES_PER_OCTAVE) + + class VisualLut: """GPU-resident ``global_id`` -> per-visual flags table. @@ -183,13 +297,16 @@ def __init__(self, device: wgpu.GPUDevice | None = None) -> None: device = get_shared().device self._device = device - self._data = np.zeros((LUT_HEIGHT, LUT_WIDTH), dtype=np.uint8) + self._data = np.zeros((LUT_HEIGHT, LUT_WIDTH), dtype=np.uint32) self._written: dict[int, int] = {} self._texture = device.create_texture( size=(LUT_WIDTH, LUT_HEIGHT, 1), - format=wgpu.TextureFormat.r8uint, - usage=wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + format=wgpu.TextureFormat.rg16uint, + # COPY_SRC lets the table be read back, which the tests do. + usage=wgpu.TextureUsage.TEXTURE_BINDING + | wgpu.TextureUsage.COPY_DST + | wgpu.TextureUsage.COPY_SRC, dimension="2d", ) self._view = self._texture.create_view() @@ -197,8 +314,12 @@ def __init__(self, device: wgpu.GPUDevice | None = None) -> None: # uninitialised. device.queue.write_texture( {"texture": self._texture, "mip_level": 0, "origin": (0, 0, 0)}, - self._data, - {"offset": 0, "bytes_per_row": LUT_WIDTH, "rows_per_image": LUT_HEIGHT}, + np.zeros((LUT_HEIGHT, LUT_WIDTH, 2), dtype=np.uint16), + { + "offset": 0, + "bytes_per_row": LUT_WIDTH * _BYTES_PER_TEXEL, + "rows_per_image": LUT_HEIGHT, + }, (LUT_WIDTH, LUT_HEIGHT, 1), ) @@ -214,11 +335,11 @@ def texture(self) -> wgpu.GPUTexture: @property def entries(self) -> dict[int, int]: - """Copy of the currently written ``{object_id: entry byte}`` map.""" + """Copy of the currently written ``{object_id: entry}`` map.""" return dict(self._written) def get_entry(self, object_id: int) -> int: - """Return the entry byte currently stored for *object_id*.""" + """Return the entry currently stored for *object_id*.""" x, y = lut_index(object_id) return int(self._data[y, x]) @@ -230,7 +351,7 @@ def set_entry(self, object_id: int, value: int) -> bool: object_id : int The pygfx ``global_id`` to write. value : int - Packed entry byte, from :func:`encode_entry`. + Packed entry, from :func:`encode_entry`. Returns ------- @@ -241,7 +362,7 @@ def set_entry(self, object_id: int, value: int) -> bool: if object_id == 0: # Entry 0 is background and must stay inert. return False - value = int(value) & 0xFF + value = int(value) & 0xFFFFFFFF x, y = lut_index(object_id) if int(self._data[y, x]) == value and self._written.get(object_id) == value: return False @@ -267,7 +388,7 @@ def apply(self, entries: Mapping[int, int]) -> bool: Parameters ---------- entries : Mapping[int, int] - ``{object_id: entry byte}`` for every flag that should be + ``{object_id: entry}`` for every flag that should be set right now, across *all* features that share the table. Anything absent is cleared, so callers must supply the whole state rather than their own feature's slice of it. @@ -295,8 +416,8 @@ def _upload_texel(self, x: int, y: int, value: int) -> None: "mip_level": 0, "origin": (x, y, 0), }, - np.array([[value]], dtype=np.uint8), - {"offset": 0, "bytes_per_row": 1, "rows_per_image": 1}, + np.array([[[value & 0xFFFF, value >> 16]]], dtype=np.uint16), + {"offset": 0, "bytes_per_row": _BYTES_PER_TEXEL, "rows_per_image": 1}, (1, 1, 1), ) @@ -309,7 +430,7 @@ def get_shared_visual_lut() -> VisualLut: pygfx allocates ``global_id`` from a single process-wide ``IdProvider`` and every canvas shares one wgpu device, so the - id-to-entry mapping is global and one 1 MB table serves every canvas. + id-to-entry mapping is global and one 4 MB table serves every canvas. Returns ------- diff --git a/src/cellier/render/canvas_view.py b/src/cellier/render/canvas_view.py index 7e97533d..241df6ee 100644 --- a/src/cellier/render/canvas_view.py +++ b/src/cellier/render/canvas_view.py @@ -286,7 +286,9 @@ def __init__( # than a flicker. DDAA stays last so it antialiases the outline. # The pass starts disabled and pygfx's flush() skips disabled passes # entirely, so this costs nothing until outlines are switched on. - self._outline_pass = OutlinePass(self._renderer) + self._outline_pass = OutlinePass( + self._renderer, get_camera=lambda: self._camera + ) self._renderer.effect_passes = ( self._ssao_pass, self._outline_pass, diff --git a/src/cellier/render/render_manager.py b/src/cellier/render/render_manager.py index 61afaa42..38b7dff9 100644 --- a/src/cellier/render/render_manager.py +++ b/src/cellier/render/render_manager.py @@ -22,6 +22,7 @@ PLACEMENT_INWARD, PLACEMENT_NAMES, PLACEMENT_OUTWARD, + encode_depth_range, encode_entry, get_shared_visual_lut, peek_shared_visual_lut, @@ -131,9 +132,10 @@ class VisualFlags(NamedTuple): Parameters ---------- - outline : tuple[int, int, int] or None - ``(slot, placement, kind)`` for the screen-space outline pass, or - ``None`` when the visual is not outlined. + outline : tuple[int, int, int, int, int] or None + ``(slot, placement, kind, depth_start_code, depth_ramp_code)`` for + the screen-space outline pass, or ``None`` when the visual is not + outlined. Both codes are 0 when the visual has no depth range. ao : bool or None Whether the visual receives ambient occlusion. ``None`` is auto: excluded when it renders in the MIP family, which writes the depth @@ -141,7 +143,7 @@ class VisualFlags(NamedTuple): ``False`` are explicit and survive a render-mode change. """ - outline: tuple[int, int, int] | None = None + outline: tuple[int, int, int, int, int] | None = None ao: bool | None = None @@ -585,6 +587,7 @@ def set_visual_outline( slot: int = 1, placement: str | int = PLACEMENT_INWARD, kind: int = KIND_WHOLE_OBJECT, + depth_range: tuple[float, float] | None = None, ) -> None: """Record the outline assignment for one visual. @@ -611,11 +614,15 @@ def set_visual_outline( keys on the label too but colours every one of them from *slot*. Both label kinds require the ``outline_id`` target; without it a label visual falls back to a whole-object silhouette. + depth_range : tuple[float, float] or None + ``(start, solid)`` of the visual's depth outline, as fractions + of the distance from the camera; see ``encode_depth_range``. + ``None`` gives the visual no depth test. Raises ------ ValueError - If *slot* or *placement* is out of range. + If *slot*, *placement* or *depth_range* is out of range. """ slot = int(slot) if not 0 <= slot <= MAX_SLOT: @@ -631,12 +638,29 @@ def set_visual_outline( if placement not in (PLACEMENT_INWARD, PLACEMENT_OUTWARD): raise ValueError(f"unknown outline placement: {placement}") - outline = None if slot == 0 else (slot, int(placement), int(kind)) + start_code, ramp_code = ( + (0, 0) if depth_range is None else encode_depth_range(depth_range) + ) + outline = ( + None + if slot == 0 + else (slot, int(placement), int(kind), start_code, ramp_code) + ) self._update_visual_flags(visual_id, outline=outline) self._sync_visual_lut() - def get_visual_outline(self, visual_id: UUID) -> tuple[int, int, int] | None: - """Return ``(slot, placement, kind)`` for *visual_id*, or ``None``.""" + def get_visual_outline( + self, visual_id: UUID + ) -> tuple[int, int, int, int, int] | None: + """Return the outline assignment of *visual_id*, or ``None``. + + Returns + ------- + tuple[int, int, int, int, int] or None + ``(slot, placement, kind, depth_start_code, depth_ramp_code)``. + The codes are those of the table (``decode_depth_range`` turns + them back into a range) and are 0 with no depth range. + """ flags = self._visual_flags.get(visual_id) return None if flags is None else flags.outline @@ -785,19 +809,20 @@ def _sync_visual_lut(self) -> None: mapping are cleared. If outlines and ambient occlusion kept separate maps and synced independently, enabling outlines would wipe every occlusion exclusion. That is why this walks one - authoritative map and writes one byte per object. + authoritative map and writes one entry per object. """ ssao_on = self._config.ambient_occlusion.enabled if not self._visual_flags and not ssao_on: if peek_shared_visual_lut() is None: # Neither feature has ever needed the table on this # process, so there is nothing to sync and no reason to - # allocate 1 MB. + # allocate 4 MB. return entries: dict[int, int] = {} has_inward = False has_outward = False + has_depth_edges = False n_excluded = 0 for visual_id, scene_id in self._visual_to_scene.items(): @@ -813,14 +838,21 @@ def _sync_visual_lut(self) -> None: continue if flags is not None and flags.outline is not None: - slot, placement, kind = flags.outline - value = encode_entry(slot, kind, placement) + slot, placement, kind, start_code, ramp_code = flags.outline + value = encode_entry( + slot, + kind, + placement, + depth_start_code=start_code, + depth_ramp_code=ramp_code, + ) for object_id in self._world_object_ids(gfx_visual): entries[object_id] = entries.get(object_id, 0) | value if placement == PLACEMENT_OUTWARD: has_outward = True else: has_inward = True + has_depth_edges = has_depth_edges or start_code > 0 if not ssao_on: continue @@ -854,6 +886,7 @@ def _sync_visual_lut(self) -> None: canvas._outline_pass.set_placements( has_inward=has_inward, has_outward=has_outward ) + canvas._outline_pass.set_depth_edges(has_depth_edges) canvas._ssao_pass.set_has_exclusions(n_excluded > 0) @staticmethod diff --git a/src/cellier/render/shaders/_label_multiscale.py b/src/cellier/render/shaders/_label_multiscale.py index 316bdaf3..e45f598b 100644 --- a/src/cellier/render/shaders/_label_multiscale.py +++ b/src/cellier/render/shaders/_label_multiscale.py @@ -353,7 +353,7 @@ class LabelBlockMaterial(gfx.ImageBasicMaterial): label_colors_texture : gfx.Texture | None Direct-mode RGBA per entry. paint_cache_texture : gfx.Texture | None - 2D float32 paint cache (stripes-of-tiles layout). + 2D float32 paint cache (row-major grid of slot tiles). paint_lut_texture : gfx.Texture | None 2D float32 paint LUT (slot_index, presence) per tile. colormap_mode : "random" | "direct" diff --git a/src/cellier/render/shaders/wgsl/label_block.wgsl b/src/cellier/render/shaders/wgsl/label_block.wgsl index dcfb569b..5b9bdbe8 100644 --- a/src/cellier/render/shaders/wgsl/label_block.wgsl +++ b/src/cellier/render/shaders/wgsl/label_block.wgsl @@ -11,7 +11,8 @@ // t_label_keys -- texture_2d (direct mode: sorted label IDs) // t_label_colors -- texture_2d (direct mode: RGBA per entry) // t_paint_lut -- texture_2d, .r=slot_index .g=presence (0 = no slot) -// t_paint_cache -- texture_2d, .r=label_id_as_float32 .g=presence +// t_paint_cache -- texture_2d, .r=label_id_as_float32 .g=presence; +// a row-major grid of block_size tiles, one per slot // NOTE: Do NOT define struct LabelParams, LutParams, or BlockScales here. // pygfx auto-generates them from numpy dtypes via structname=. @@ -146,7 +147,9 @@ fn sample_paint_label_lut(texcoord: vec2) -> vec2 { let slot = i32(lutv.x); let bs = i32(u_lut_params.block_size_x); let within = vec2(pos - tile_f * block_size); - let coord = vec2(within.x, slot * bs + within.y); + // Slots fill a grid in row-major order; the texture is cols * bs wide. + let cols = max(i32(textureDimensions(t_paint_cache).x) / bs, 1); + let coord = vec2(slot % cols, slot / cols) * bs + within; let v = textureLoad(t_paint_cache, coord, 0); return vec2(v.r, v.g); // (label_id_as_float32, presence) } diff --git a/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl b/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl index 23358a2a..a0614047 100644 --- a/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl +++ b/src/cellier/render/shaders/wgsl/label_volume_brick.wgsl @@ -269,7 +269,9 @@ fn setup_brick( } // ── Object-space label surface normal (iso_categorical + gradient_debug) ─ -$$ if render_mode == "iso_categorical" or render_mode == "gradient_debug" +// Also compiled whenever the normal target is written: every mode but +// smooth_iso then calls it (see needs_normal in the fragment shader). +$$ if render_mode == "iso_categorical" or render_mode == "gradient_debug" or write_normal fn label_object_normal( voxel_pos: vec3, lut_entry: vec4, diff --git a/src/cellier/render/shaders/wgsl/outline.wgsl b/src/cellier/render/shaders/wgsl/outline.wgsl index 5d4f4b73..0f996b8b 100644 --- a/src/cellier/render/shaders/wgsl/outline.wgsl +++ b/src/cellier/render/shaders/wgsl/outline.wgsl @@ -17,17 +17,23 @@ // LABEL_ALL is the fourth cell: every label in one volume banded in a // single colour, boundaries between touching labels included. // +// A visual whose entry carries a depth range also gets **depth edges** +// (has_depth_edges): a band where two pixels of the same key lie on +// surfaces a depth jump apart, one part of the visual passing in front of +// another. See "Depth edges" below. +// // Bindings are declared by the Python side (_outline.py); this file // assumes: // 0 u_outline uniform // 1 texSampler sampler (unused; the pass loads, never samples) // 2 colorTex texture_2d // 3 pickTex texture_2d -// 4 lutTex texture_2d r8uint, 1024x1024 +// 4 lutTex texture_2d rg16uint, 1024x1024 // 5 outlineIdTex texture_2d r32uint, label keys (has_outline_id) +// 6 depthTex texture_depth_2d the frame's depth (has_depth_edges) // // Template vars: b_t_in, b_t_out, s_t_in, s_t_out, inner_t, -// has_inward, has_outward, has_outline_id. +// has_inward, has_outward, has_outline_id, has_depth_edges. const KIND_NONE: u32 = 0u; const KIND_LABEL: u32 = 2u; @@ -45,14 +51,17 @@ const LABEL_KEY_TAG: u32 = 0x80000000u; // The range the label key's partition reserves for selected labels. const MAX_LABEL_SLOT: u32 = 15u; +// strength scales the alpha of whatever is drawn for the hit. It is 1 for +// an identity edge and between 0 and 1 for a depth edge. struct OutlineEntry { slot: u32, kind: u32, placement: u32, + strength: f32, }; fn none_entry() -> OutlineEntry { - return OutlineEntry(0u, KIND_NONE, PLACEMENT_INWARD); + return OutlineEntry(0u, KIND_NONE, PLACEMENT_INWARD, 1.0); } // Both label kinds key on the per-pixel label rather than on the object @@ -76,11 +85,15 @@ fn pick_id(texel: vec2) -> u32 { return pick.r | ((pick.g & 0xFu) << 16u); } +// The entry byte is the low byte of r. The rest of the texel (the upper +// byte of r, and g) is the depth range and is not read here. fn lut_entry(object_id: u32) -> OutlineEntry { let x = i32(object_id & 1023u); let y = i32((object_id >> 10u) & 1023u); let value = textureLoad(lutTex, vec2(x, y), 0).r; - return OutlineEntry(value & 0xFu, (value >> 4u) & 0x3u, (value >> 6u) & 0x1u); + return OutlineEntry( + value & 0xFu, (value >> 4u) & 0x3u, (value >> 6u) & 0x1u, 1.0 + ); } $$ if has_outline_id @@ -129,6 +142,116 @@ $$ endif return entry; } +$$ if has_depth_edges +// --------------------------------------------------------------------- +// Depth edges +// +// For two pixels of the same key, `near` and `far`, a thickness apart: +// +// gap z_far - z_near > start * z_near +// carried the far surface carried on in a straight line to where +// the near pixel is: from `beyond`, one thickness past far, +// or, where that is off the surface, from the pixel one +// step past far. Off the surface: outside the frame, or +// another key. With neither there is no edge. +// in_front z(carried) - z_near > start * z_near +// +// The pair is an edge when gap and in_front both hold. Raw depth is +// linear across a plane on screen, so the far surface is carried on in raw +// depth. The second condition is what keeps the rim of a wide surface, +// where the depth also changes fast, from being drawn as an edge. +// +// z is the distance from the camera along the view direction, and `start` +// and `solid` are the visual's depth range, fractions of that distance. +// The edge's strength rises from 0 at a jump of `start` to 1 at `solid`, +// on the smaller of the two jumps tested. +// --------------------------------------------------------------------- + +fn in_frame(texel: vec2) -> bool { + let dims = vec2(textureDimensions(pickTex)); + return texel.x >= 0 && texel.y >= 0 && texel.x < dims.x && texel.y < dims.y; +} + +fn raw_depth(texel: vec2) -> f32 { + return textureLoad(depthTex, clamp_texel(texel), 0); +} + +// Distance from the camera plane of a raw depth. Nothing drawn (the far +// plane) and anything behind the camera read as very far away. +fn view_z(raw: f32) -> f32 { + let inv = u_outline.projection_transform_inv; + let z = inv[2][2] * raw + inv[3][2]; + let w = inv[2][3] * raw + inv[3][3]; + let distance = -z / w; + if (raw >= 1.0 || distance <= 0.0) { + return 1.0e30; + } + return distance; +} + +// (start, solid) of the visual drawn at a texel. A start of 0 means the +// visual has no depth test. g holds the start in 16 bits; the upper byte +// of r holds the ramp, 32 codes to a doubling of solid over start, 0 being +// a hard edge. +fn depth_range(texel: vec2) -> vec2 { + let id = pick_id(texel); + let value = textureLoad( + lutTex, vec2(i32(id & 1023u), i32((id >> 10u) & 1023u)), 0 + ); + let start = f32(value.g) / 65535.0; + let ramp = (value.r >> 8u) & 0xFFu; + return vec2(start, start * exp2(f32(ramp) / 32.0)); +} + +// The strength of a depth edge whose smaller jump is `jump`. +fn edge_strength(jump: f32, range: vec2) -> f32 { + if (range.y > range.x) { + return smoothstep(range.x, range.y, jump); + } + return 1.0; +} + +// The strength of the depth edge between a near and a far pixel of one +// key, or -1 when the pair is not an edge. +// +// far_texel the far pixel +// away from the far pixel away from the near one, one thickness +// t the thickness, in pixels +// z_near the near pixel's distance +// range the range of the visual the edge is drawn for +fn depth_edge( + far_texel: vec2, + away: vec2, + t: i32, + key: u32, + z_near: f32, + range: vec2, +) -> f32 { + let limit = range.x * z_near; + let raw_far = raw_depth(far_texel); + let gap = view_z(raw_far) - z_near; + if (gap <= limit) { + return -1.0; + } + var raw_carried: f32; + let beyond = far_texel + away; + if (in_frame(beyond) && outline_key(beyond) == key) { + raw_carried = 2.0 * raw_far - raw_depth(beyond); + } else { + let one_past = far_texel + away / t; + if (!in_frame(one_past) || outline_key(one_past) != key) { + return -1.0; + } + raw_carried = raw_far + f32(t) * (raw_far - raw_depth(one_past)); + } + let front = view_z(raw_carried) - z_near; + if (front <= limit) { + return -1.0; + } + return edge_strength(min(gap, front) / z_near, range); +} +$$ endif + // Resolve which outline style, if any, applies to this pixel. // // Inward placement takes its style from the *centre* pixel: the pixel is @@ -141,6 +264,14 @@ $$ endif // The tests run on the raw key, never on the resolved slot, so two // adjacent regions that both resolve to "not selected" stay // distinguishable. +// +// A depth edge is directed like an identity edge, "the tap is much farther +// away" standing for "the tap has left the region". Inward, the centre is +// the near pixel and the band lies on the nearer surface, with the +// centre's style and range. Outward, the tap is the near pixel and the +// band lies on the farther surface, with the tap's. An identity edge is +// returned at once; of the depth edges of the four taps the strongest is +// kept, because the first to fire need not be. fn outline_hit(texel: vec2, t_in: i32, t_out: i32) -> OutlineEntry { let c_key = outline_key(texel); @@ -155,6 +286,32 @@ $$ if has_inward || outline_key(texel + vec2(0, -t_in)) != c_key) { return c_entry; } +$$ if has_depth_edges + let c_range = depth_range(texel); + if (c_range.x > 0.0) { + let z_c = view_z(raw_depth(texel)); + var c_best = -1.0; + var c_offsets = array, 4>( + vec2(t_in, 0), + vec2(-t_in, 0), + vec2(0, t_in), + vec2(0, -t_in) + ); + for (var i: i32 = 0; i < 4; i = i + 1) { + c_best = max( + c_best, + depth_edge( + texel + c_offsets[i], c_offsets[i], t_in, c_key, z_c, c_range + ) + ); + } + if (c_best >= 0.0) { + var c_hit = c_entry; + c_hit.strength = c_best; + return c_hit; + } + } +$$ endif } $$ endif @@ -166,6 +323,10 @@ $$ if has_outward vec2(0, t_out), vec2(0, -t_out) ); +$$ if has_depth_edges + var n_best = -1.0; + var n_hit = none_entry(); +$$ endif for (var i: i32 = 0; i < 4; i = i + 1) { let n_texel = texel + offsets[i]; if (outline_key(n_texel) != c_key) { @@ -175,7 +336,37 @@ $$ if has_outward return n_entry; } } +$$ if has_depth_edges + else if (c_key != 0u) { + // The same key: the tap may be a nearer part of the region + // the centre is in. + let n_entry = resolved_entry(n_texel); + let n_range = depth_range(n_texel); + if (n_entry.kind != KIND_NONE + && n_entry.placement == PLACEMENT_OUTWARD + && n_range.x > 0.0) { + let strength = depth_edge( + texel, + -offsets[i], + t_out, + c_key, + view_z(raw_depth(n_texel)), + n_range + ); + if (strength > n_best) { + n_best = strength; + n_hit = n_entry; + } + } + } +$$ endif + } +$$ if has_depth_edges + if (n_best >= 0.0) { + n_hit.strength = n_best; + return n_hit; } +$$ endif } $$ endif @@ -190,6 +381,15 @@ fn alpha_over(src: vec4, dst: vec4) -> vec4 { return vec4(mix(dst.rgb, src.rgb, src.a), a); } +// A layer's colour at the strength of the hit it is drawn for. +fn at_strength(colour: vec4, hit: OutlineEntry) -> vec4 { +$$ if has_depth_edges + return vec4(colour.rgb, colour.a * hit.strength); +$$ else + return colour; +$$ endif +} + @fragment fn fs_main(varyings: Varyings) -> @location(0) vec4 { let texel = vec2(varyings.position.xy); @@ -199,7 +399,9 @@ fn fs_main(varyings: Varyings) -> @location(0) vec4 { if (u_outline.boundaries_enabled != 0) { let hit = outline_hit(texel, {{ b_t_in }}, {{ b_t_out }}); if (hit.kind != KIND_NONE) { - out_color = alpha_over(u_outline.boundary_color, out_color); + out_color = alpha_over( + at_strength(u_outline.boundary_color, hit), out_color + ); } } @@ -207,7 +409,9 @@ fn fs_main(varyings: Varyings) -> @location(0) vec4 { if (u_outline.selection_enabled != 0) { let hit = outline_hit(texel, {{ s_t_in }}, {{ s_t_out }}); if (hit.slot != 0u) { - out_color = alpha_over(u_outline.palette[hit.slot - 1u], out_color); + out_color = alpha_over( + at_strength(u_outline.palette[hit.slot - 1u], hit), out_color + ); } $$ if inner_t > 0 else { @@ -217,7 +421,9 @@ $$ if inner_t > 0 texel, {{ s_t_in }} + {{ inner_t }}, {{ s_t_out }} + {{ inner_t }} ); if (inner.slot != 0u) { - out_color = alpha_over(u_outline.inner_color, out_color); + out_color = alpha_over( + at_strength(u_outline.inner_color, inner), out_color + ); } } $$ endif diff --git a/src/cellier/render/shaders/wgsl/ssao.wgsl b/src/cellier/render/shaders/wgsl/ssao.wgsl index 2c6d7bba..b9362fc0 100644 --- a/src/cellier/render/shaders/wgsl/ssao.wgsl +++ b/src/cellier/render/shaders/wgsl/ssao.wgsl @@ -135,7 +135,7 @@ fn pick_id(texel: vec2) -> u32 { return pick.r | ((pick.g & 0xFu) << 16u); } -// Bit 7 of the shared visual LUT entry is the "excluded from AO" flag. +// Bit 7 of r of the shared visual LUT entry is the "excluded from AO" flag. fn ao_excluded(object_id: u32) -> bool { let x = i32(object_id & 1023u); let y = i32((object_id >> 10u) & 1023u); diff --git a/src/cellier/render/visuals/_label_multiscale.py b/src/cellier/render/visuals/_label_multiscale.py index cdd9e3bd..642bf54d 100644 --- a/src/cellier/render/visuals/_label_multiscale.py +++ b/src/cellier/render/visuals/_label_multiscale.py @@ -7,6 +7,7 @@ import numpy as np import pygfx as gfx +from pygfx.renderers.wgpu.engine.shared import get_shared from cellier.logging import _PERF_LOGGER from cellier.render._clipping import ClippingPlanesMixin, drawn_materials @@ -101,7 +102,10 @@ _make_aabb_line, _rect_wireframe_positions, ) -from cellier.render.visuals._paint_tile_slot_manager import PaintTileSlotManager +from cellier.render.visuals._paint_tile_slot_manager import ( + PaintTileSlotManager, + paint_cache_grid, +) from cellier.render.visuals._pick import ( multiscale_image_data_coordinate, multiscale_volume_data_coordinate, @@ -288,6 +292,7 @@ def __init__( self._paint_slot_manager: PaintTileSlotManager | None = None self._t_paint_cache: gfx.Texture | None = None self._t_paint_lut: gfx.Texture | None = None + self._paint_cache_cols: int = 1 # Build label colormap GPU resources if color_dict is None: @@ -1764,7 +1769,20 @@ def _allocate_paint_resources_2d(self) -> None: gh, gw = self._image_geometry_2d.base_layout.grid_dims n_slots = self._paint_max_tiles - paint_cache_data = np.zeros((n_slots * bs, bs, 2), dtype=np.float32) + # Slots sit on a (rows, cols) grid so that neither texture dimension + # depends on the slot count alone. + rows, cols = paint_cache_grid(n_slots) + max_dim = int(get_shared().device.limits["max-texture-dimension-2d"]) + if max(rows, cols) * bs > max_dim: + raise ValueError( + f"The 2D paint cache for paint_max_tiles={n_slots} and " + f"block_size={bs} is {cols * bs} x {rows * bs} pixels, over " + f"the device limit of {max_dim}. Lower " + "MultiscaleLabelRenderConfig.paint_max_tiles to at most " + f"{(max_dim // bs) ** 2}." + ) + self._paint_cache_cols = cols + paint_cache_data = np.zeros((rows * bs, cols * bs, 2), dtype=np.float32) self._t_paint_cache = gfx.Texture(paint_cache_data, dim=2, format="2xf4") paint_lut_data = np.zeros((gh, gw, 2), dtype=np.float32) @@ -1822,6 +1840,7 @@ def patch_paint_texture( cache_data = self._t_paint_cache.data lut_data = self._t_paint_lut.data + cache_cols = self._paint_cache_cols cache_y_min = cache_data.shape[0] cache_y_max = 0 @@ -1849,17 +1868,18 @@ def patch_paint_texture( tx_sel = tx[sel] v_sel = vals[sel] - slot_y0 = slot * bs - cache_data[slot_y0 + ty_sel, tx_sel, 0] = v_sel - cache_data[slot_y0 + ty_sel, tx_sel, 1] = 1.0 + slot_y0 = (slot // cache_cols) * bs + slot_x0 = (slot % cache_cols) * bs + cache_data[slot_y0 + ty_sel, slot_x0 + tx_sel, 0] = v_sel + cache_data[slot_y0 + ty_sel, slot_x0 + tx_sel, 1] = 1.0 lut_data[gy_t, gx_t, 0] = float(slot) lut_data[gy_t, gx_t, 1] = 1.0 cache_y_min = min(cache_y_min, slot_y0) cache_y_max = max(cache_y_max, slot_y0 + bs) - cache_x_min = 0 - cache_x_max = bs + cache_x_min = min(cache_x_min, slot_x0) + cache_x_max = max(cache_x_max, slot_x0 + bs) lut_y_min = min(lut_y_min, gy_t) lut_y_max = max(lut_y_max, gy_t + 1) lut_x_min = min(lut_x_min, gx_t) diff --git a/src/cellier/render/visuals/_paint_tile_slot_manager.py b/src/cellier/render/visuals/_paint_tile_slot_manager.py index e9818b50..9e7339a7 100644 --- a/src/cellier/render/visuals/_paint_tile_slot_manager.py +++ b/src/cellier/render/visuals/_paint_tile_slot_manager.py @@ -3,10 +3,37 @@ from __future__ import annotations import logging +import math _logger = logging.getLogger(__name__) +def paint_cache_grid(n_slots: int) -> tuple[int, int]: + """Return the ``(rows, cols)`` slot grid of the paint cache texture. + + Slots fill the grid in row-major order: slot ``s`` sits at row + ``s // cols``, column ``s % cols``. The grid is as square as it can + be, so neither texture dimension grows with ``n_slots`` alone. The + paint shader derives ``cols`` from the texture width, so the layout + must stay ``cols * block_size`` pixels wide. + + Parameters + ---------- + n_slots : int + Number of paint slots (``paint_max_tiles``). + + Returns + ------- + rows, cols : tuple[int, int] + Slots per texture column and per texture row. + """ + if n_slots <= 0: + raise ValueError(f"n_slots must be positive, got {n_slots!r}") + cols = math.isqrt(n_slots - 1) + 1 + rows = -(-n_slots // cols) + return rows, cols + + class PaintTileSlotManager: """Allocates fixed-pool paint-cache slots indexed by tile grid coords. diff --git a/src/cellier/visuals/_label_memory.py b/src/cellier/visuals/_label_memory.py index 5ad79939..f39a4807 100644 --- a/src/cellier/visuals/_label_memory.py +++ b/src/cellier/visuals/_label_memory.py @@ -2,14 +2,54 @@ from typing import Literal -from pydantic import Field +from pydantic import Field, field_validator from cellier.visuals._base_visual import BaseAppearance, BaseVisual from cellier.visuals._render_plane import PLANE_RENDER_MODE, RenderPlane -OutlineMode = Literal["per_label", "whole_object", "all_boundaries"] +OutlineMode = Literal["per_label", "whole_object", "all_boundaries", "depth_threshold"] """How a labels visual is outlined; see ``BaseLabelsVisual``.""" +DEFAULT_OUTLINE_DEPTH_RANGE: tuple[float, float] = (0.01, 0.06) +"""The default ``outline_depth_range`` of a labels visual.""" + + +def validate_outline_depth_range(value) -> tuple[float, float]: + """Return *value* as an ``outline_depth_range``, or raise. + + Parameters + ---------- + value : + ``(start, solid)``: two numbers with ``0 < start <= 1`` and + ``start <= solid``. + + Returns + ------- + tuple[float, float] + The pair as floats. + + Raises + ------ + ValueError + If *value* is not such a pair. + """ + try: + start, solid = (float(part) for part in value) + except (TypeError, ValueError): + raise ValueError( + f"outline_depth_range must be a pair (start, solid); got {value!r}." + ) from None + if not 0.0 < start <= 1.0: + raise ValueError( + f"the start of outline_depth_range must be in (0, 1]; got {start}." + ) + if not solid >= start: + raise ValueError( + "the solid value of outline_depth_range must not be below its " + f"start; got {(start, solid)}." + ) + return start, solid + class BaseLabelsAppearance(BaseAppearance): """Base appearance parameters shared by all label visuals. @@ -78,9 +118,39 @@ class BaseLabelsVisual(BaseVisual): between them. The model field is left untouched, so switching back to ``"per_label"`` restores the selection. + ``"depth_threshold"`` is ``"all_boundaries"`` with one thing more: + a band where one part of a label passes in front of another part of + the *same* label, as where one branch of a tree crosses another. + How large a jump in depth is drawn, and how strongly, is + :attr:`outline_depth_range`. With ``outline.placement = "inward"`` + that band lies on the nearer surface, along its edge; with + ``"outward"`` on the farther one, just outside the edge of the + nearer. It draws depth bands in a 3D view of a surface render mode + only: in 2D and in ``"plane"`` mode there is no jump in depth, and + it is ``"all_boundaries"``. :attr:`outline_selected_labels` has no + effect in it either. + With ``outline.placement = "outward"`` a boundary between two touching labels comes out double-width, since each side casts a halo onto the other. ``"inward"`` is the default for labels visuals. + outline_depth_range : tuple[float, float] + ``(start, solid)`` of the depth bands of ``"depth_threshold"`` + mode, both as fractions of the distance from the camera to the + nearer of the two surfaces. A jump in depth of ``start`` is where + a line begins, at no alpha; from ``solid`` up the line is drawn in + full; between the two its alpha rises smoothly. ``start == + solid`` is a hard edge. Needs ``0 < start <= 1`` and ``start <= + solid``. Kept, and without effect, in the other modes. + + The surface of a labels volume is made of voxel faces, and the step + from one voxel to the next is a real jump in depth. Zoomed in far + enough that a voxel covers several pixels, or while a coarse level + of a multiscale volume is on screen, those steps are outlined too. + A larger ``start`` removes them. + + The pair is checked when the visual is built and when it is set + through the controller (``update_visual_render_field``); assign it + there rather than on the model. outline_selected_labels : dict[int, int] Maps a label value to the palette slot the selection layer draws it in. Empty (the default) means no label is selected, so an outlined @@ -101,8 +171,12 @@ class BaseLabelsVisual(BaseVisual): ``whole_object`` pick id ``outline.slot`` ``per_label`` label field ``outline_selected_labels`` ``all_boundaries`` label field ``outline.slot`` + ``depth_threshold`` label field ``outline.slot`` ================== ============= ========================== + ``depth_threshold`` has the kind of ``all_boundaries``; what sets it + apart is that its entry also carries :attr:`outline_depth_range`. + ``render_planes`` are the planes the visual draws its labels on in a 3D view while ``appearance.render_mode`` is ``"plane"``; ignored otherwise. World space, at most four; see :class:`~cellier.visuals.RenderPlane`. @@ -116,8 +190,14 @@ class BaseLabelsVisual(BaseVisual): outline_mode: OutlineMode = "per_label" outline_selected_labels: dict[int, int] = Field(default_factory=dict) + outline_depth_range: tuple[float, float] = DEFAULT_OUTLINE_DEPTH_RANGE render_planes: tuple[RenderPlane, ...] = () + @field_validator("outline_depth_range") + @classmethod + def _check_outline_depth_range(cls, value): + return validate_outline_depth_range(value) + def plane_mode(self) -> bool: """Whether a 3D view draws this visual on its ``render_planes``.""" return self.appearance.render_mode == PLANE_RENDER_MODE diff --git a/src/cellier/visuals/_labels.py b/src/cellier/visuals/_labels.py index 4dc14b27..82aa9bdd 100644 --- a/src/cellier/visuals/_labels.py +++ b/src/cellier/visuals/_labels.py @@ -89,6 +89,9 @@ class MultiscaleLabelRenderConfig(BaseModel): Maximum GPU memory for the 2-D tile cache. Default 64 MiB. paint_max_tiles : int Tiles the 2-D paint overlay can hold. Default 512. + The tiles share one near-square texture, so + ``ceil(sqrt(paint_max_tiles)) * block_size`` must not exceed the + device's maximum 2-D texture dimension. loading : ProgressiveLoadingConfig The coarse backstop loaded ahead of the target level. Changing only this reslices; the other fields reallocate GPU resources. diff --git a/tests/convenience/test_add_render_kwargs.py b/tests/convenience/test_add_render_kwargs.py index df0ab9b5..52cfd5e7 100644 --- a/tests/convenience/test_add_render_kwargs.py +++ b/tests/convenience/test_add_render_kwargs.py @@ -171,7 +171,7 @@ def test_outline_mode_defaults_to_per_label(method, request): assert _add(method, request).outline_mode == "per_label" -@pytest.mark.parametrize("mode", ["whole_object", "all_boundaries"]) +@pytest.mark.parametrize("mode", ["whole_object", "all_boundaries", "depth_threshold"]) @pytest.mark.parametrize("method", _LABELS_METHODS) def test_outline_mode_reaches_the_model(method, mode, request): assert _add(method, request, outline_mode=mode).outline_mode == mode @@ -182,6 +182,38 @@ def test_an_unknown_outline_mode_is_refused(request): _add("add_labels", request, outline_mode="silhouette") +def test_only_labels_take_outline_depth_range(): + assert { + name + for name in _ADDERS + if "outline_depth_range" + in inspect.signature(getattr(Viewer, name.split("[")[0])).parameters + } == set(_LABELS_METHODS) + + +@pytest.mark.parametrize("method", _LABELS_METHODS) +def test_outline_depth_range_defaults_to_the_models(method, request): + assert _add(method, request).outline_depth_range == (0.01, 0.06) + + +@pytest.mark.parametrize("method", _LABELS_METHODS) +def test_outline_depth_range_reaches_the_model(method, request): + visual = _add( + method, + request, + outline_mode="depth_threshold", + outline_depth_range=(0.02, 0.12), + ) + assert visual.outline_mode == "depth_threshold" + assert visual.outline_depth_range == (0.02, 0.12) + + +@pytest.mark.parametrize("method", _LABELS_METHODS) +def test_a_bad_outline_depth_range_is_refused(method, request): + with pytest.raises(ValueError, match="outline_depth_range"): + _add(method, request, outline_depth_range=(0.06, 0.01)) + + def _gfx_pick_writes(viewer, visual) -> list[bool]: """``pick_write`` on every material the renderer built for *visual*.""" import pygfx as gfx diff --git a/tests/convenience/test_appearance_specs_parity.py b/tests/convenience/test_appearance_specs_parity.py index 07b3137a..b5584642 100644 --- a/tests/convenience/test_appearance_specs_parity.py +++ b/tests/convenience/test_appearance_specs_parity.py @@ -8,6 +8,11 @@ The golden is never regenerated: the code that wrote it is gone. A difference is a bug in a spec builder. + +A value a control gained after the golden was written is named in +``ADDED_SINCE_THE_GOLDEN``, with what it holds for the golden's visuals. It +is added to the golden's specs before the comparison, so the file stands as +it was written and the comparison still covers every value. """ from __future__ import annotations @@ -28,6 +33,25 @@ GOLDEN = json.loads(GOLDEN_PATH.read_text()) +#: ``{spec kind: {value name: value}}``: values added to a control's spec +#: since the golden was written, as they are for the golden's visuals. +ADDED_SINCE_THE_GOLDEN = { + # The depth range of the ``depth_threshold`` outline mode; no golden + # visual sets one, so each holds the model's default. + "labels_outline": {"outline_depth_range": [0.01, 0.06]}, +} + + +def _expected(case_name: str) -> list[dict]: + """The golden's specs for a case, with the values added since.""" + expected = [] + for spec in GOLDEN[case_name]: + added = ADDED_SINCE_THE_GOLDEN.get(spec["kind"], {}) + # A name the golden already holds is not an addition. + assert not set(added) & set(spec["values"]), (spec["kind"], added) + expected.append({**spec, "values": {**spec["values"], **added}}) + return expected + def test_every_golden_case_has_a_list(): assert sorted(PARITY_CONTROLS) == sorted(GOLDEN) @@ -59,4 +83,4 @@ def test_the_list_builds_the_specs_of_the_golden(case_name, tmp_path): spec["kind"] for spec in GOLDEN[case_name] ] # Through JSON, as the golden went: tuples become lists either way. - assert json.loads(json.dumps(built)) == GOLDEN[case_name] + assert json.loads(json.dumps(built)) == _expected(case_name) diff --git a/tests/convenience/test_labels_outline_mode_seed.py b/tests/convenience/test_labels_outline_mode_seed.py index 57019c46..f34e0f8f 100644 --- a/tests/convenience/test_labels_outline_mode_seed.py +++ b/tests/convenience/test_labels_outline_mode_seed.py @@ -46,7 +46,9 @@ def _outline_widget(viewer, visual, backend): return widget -@pytest.mark.parametrize("mode", ["per_label", "whole_object", "all_boundaries"]) +@pytest.mark.parametrize( + "mode", ["per_label", "whole_object", "all_boundaries", "depth_threshold"] +) def test_the_spec_carries_the_mode(labels_store, mode): _viewer, visual = _labels(labels_store, mode) @@ -76,3 +78,75 @@ def test_the_anywidget_control_draws_the_visuals_mode(labels_store): assert "outline_selected_labels" not in { entry["field"] for entry in widget.controls } + + +def _outline_spec(visual): + (spec,) = [ + s for s in appearance_specs(visual, _CONFIG).specs if s.kind == "labels_outline" + ] + return spec + + +@pytest.mark.parametrize("mode", ["per_label", "depth_threshold"]) +def test_the_spec_carries_the_depth_range(labels_store, mode): + """In every mode: the mode can be entered from the panel.""" + viewer, visual = _labels(labels_store, mode) + assert _outline_spec(visual).values["outline_depth_range"] == (0.01, 0.06) + + viewer.controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.02, 0.12) + ) + assert _outline_spec(visual).values["outline_depth_range"] == (0.02, 0.12) + + +def test_the_qt_control_draws_the_visuals_depth_range(qtbot, labels_store): + viewer, visual = _labels(labels_store, "depth_threshold") + viewer.controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.02, 0.12) + ) + + widget = _outline_widget(viewer, visual, QT_BACKEND) + + assert widget._mode() == "depth_threshold" + start_box, solid_box = widget._depth_range_boxes + assert (start_box.value(), solid_box.value()) == (0.02, 0.12) + + +def test_the_anywidget_control_draws_the_visuals_depth_range(labels_store): + viewer, visual = _labels(labels_store, "depth_threshold") + viewer.controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.02, 0.12) + ) + + widget = _outline_widget(viewer, visual, ANYWIDGET_BACKEND) + + assert widget.values["outline_depth_range"] == [0.02, 0.12] + assert "outline_depth_range" in {entry["field"] for entry in widget.controls} + + +def test_the_qt_range_row_drives_the_model(qtbot, labels_store): + """A box edit reaches the visual, and a change from code reaches the box.""" + viewer, visual = _labels(labels_store, "depth_threshold") + widget = _outline_widget(viewer, visual, QT_BACKEND) + start_box, solid_box = widget._depth_range_boxes + + solid_box.setValue(0.2) + assert visual.outline_depth_range == (0.01, 0.2) + + viewer.controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.03, 0.09) + ) + assert (start_box.value(), solid_box.value()) == (0.03, 0.09) + + +def test_the_anywidget_range_row_drives_the_model(labels_store): + viewer, visual = _labels(labels_store, "depth_threshold") + widget = _outline_widget(viewer, visual, ANYWIDGET_BACKEND) + + widget.values = {**widget.values, "outline_depth_range": [0.02, 0.12]} + assert visual.outline_depth_range == (0.02, 0.12) + + viewer.controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.03, 0.09) + ) + assert widget.values["outline_depth_range"] == [0.03, 0.09] diff --git a/tests/gui/test_per_visual_widgets.py b/tests/gui/test_per_visual_widgets.py index fb9f587d..273606c2 100644 --- a/tests/gui/test_per_visual_widgets.py +++ b/tests/gui/test_per_visual_widgets.py @@ -580,3 +580,236 @@ def test_renaming_onto_an_existing_label_is_refused(qtbot): widget._on_label_renamed(3, 1) assert emitted == [] + + +# --------------------------------------------------------------------------- +# Depth threshold: a fourth mode, and the row for its depth range +# --------------------------------------------------------------------------- + + +def _range_boxes(widget): + return widget._depth_range_boxes + + +def _labels_qt(qtbot, **values): + from cellier.gui.qt.render import QtLabelsOutlineControls + + widget = QtLabelsOutlineControls( + [uuid4()], {**_LABELS_VALUES, **values}, palette=_PALETTE + ) + qtbot.addWidget(widget.widget) + emitted: list[VisualRenderUpdateEvent] = [] + widget.changed.connect(emitted.append) + return widget, emitted + + +def _labels_any(**values): + from cellier.gui.anywidget.render import AnywidgetLabelsOutlineControls + + widget = AnywidgetLabelsOutlineControls( + [uuid4()], {**_LABELS_VALUES, **values}, palette=_PALETTE + ) + emitted: list[VisualRenderUpdateEvent] = [] + widget.changed.connect(emitted.append) + return widget, emitted + + +def _foreign(field, value): + return VisualRenderChangedEvent( + source_id=uuid4(), visual_id=uuid4(), field_name=field, new_value=value + ) + + +def test_depth_threshold_is_the_fourth_mode_of_the_menu(): + """Appended, not inserted: the tests above drive the menu by index.""" + from cellier.gui._render_controls import OUTLINE_MODE_CHOICES + + assert [value for _label, value in OUTLINE_MODE_CHOICES] == [ + "per_label", + "whole_object", + "all_boundaries", + "depth_threshold", + ] + assert OUTLINE_MODE_CHOICES[3][0] == "Depth threshold" + + +def test_choosing_depth_threshold_reveals_the_swatches_and_the_range(qtbot): + """The slot is the colour in this mode, and the range row appears.""" + widget, emitted = _labels_qt(qtbot) + assert "outline_depth_range" not in widget._appliers + + _combo(widget).setCurrentIndex(3) # Depth threshold + + assert emitted[-1].field == "outline_mode" + assert emitted[-1].value == "depth_threshold" + assert widget._swatch_group.buttons() + assert "outline_selected_labels" not in widget._appliers + assert "outline_depth_range" in widget._appliers + # No range was given, so the row shows the model's default. + start_box, solid_box = _range_boxes(widget) + assert (start_box.value(), solid_box.value()) == (0.01, 0.06) + assert start_box.decimals() == 3 + assert start_box.minimum() == 0.001 + + +@pytest.mark.parametrize("index", [0, 1, 2]) +def test_leaving_depth_threshold_removes_the_range_row(qtbot, index): + widget, _emitted = _labels_qt(qtbot, outline_mode="depth_threshold") + assert "outline_depth_range" in widget._appliers + + _combo(widget).setCurrentIndex(index) + + assert "outline_depth_range" not in widget._appliers + + +def test_the_range_row_shows_the_visuals_range(qtbot): + widget, _emitted = _labels_qt( + qtbot, outline_mode="depth_threshold", outline_depth_range=(0.02, 0.12) + ) + start_box, solid_box = _range_boxes(widget) + assert (start_box.value(), solid_box.value()) == (0.02, 0.12) + + +def test_editing_a_box_sends_the_whole_pair(qtbot): + """One field, one value: the model never sees half a change.""" + widget, emitted = _labels_qt(qtbot, outline_mode="depth_threshold") + start_box, solid_box = _range_boxes(widget) + + solid_box.setValue(0.2) + assert (emitted[-1].field, emitted[-1].value) == ( + "outline_depth_range", + (0.01, 0.2), + ) + + start_box.setValue(0.05) + assert emitted[-1].value == (0.05, 0.2) + assert len(emitted) == 2 + + +def test_raising_the_start_past_the_solid_value_carries_it_up(qtbot): + widget, emitted = _labels_qt(qtbot, outline_mode="depth_threshold") + start_box, solid_box = _range_boxes(widget) + + start_box.setValue(0.3) + + assert emitted[-1].value == (0.3, 0.3) + assert solid_box.value() == 0.3 + + +def test_the_solid_value_cannot_be_set_below_the_start(qtbot): + widget, emitted = _labels_qt(qtbot, outline_mode="depth_threshold") + start_box, solid_box = _range_boxes(widget) + + solid_box.setValue(0.004) + + assert emitted[-1].value == (0.01, 0.01) + assert solid_box.value() == 0.01 + assert start_box.value() == 0.01 + + +def test_an_inbound_range_updates_the_boxes_without_an_echo(qtbot): + widget, emitted = _labels_qt(qtbot, outline_mode="depth_threshold") + + widget._on_visual_render_changed(_foreign("outline_depth_range", (0.03, 0.09))) + + start_box, solid_box = _range_boxes(widget) + assert (start_box.value(), solid_box.value()) == (0.03, 0.09) + assert emitted == [] + + +def test_a_range_set_while_the_row_is_hidden_is_shown_on_entering_the_mode(qtbot): + """The range is kept in the other modes, so the panel has to keep it too.""" + widget, _emitted = _labels_qt(qtbot) + + widget._on_visual_render_changed(_foreign("outline_depth_range", (0.03, 0.09))) + widget._on_visual_render_changed(_foreign("outline_mode", "depth_threshold")) + + start_box, solid_box = _range_boxes(widget) + assert (start_box.value(), solid_box.value()) == (0.03, 0.09) + + +def test_both_front_ends_show_the_same_controls_in_depth_threshold(qtbot): + pytest.importorskip("anywidget") + qt_widget, _e = _labels_qt(qtbot, outline_mode="depth_threshold") + any_widget, _e = _labels_any(outline_mode="depth_threshold") + + fields = {c["field"] for c in any_widget.controls} + assert set(qt_widget._appliers) == fields + assert "outline_depth_range" in fields + + +def test_the_anywidget_range_row_follows_the_mode(): + pytest.importorskip("anywidget") + widget, emitted = _labels_any() + assert "outline_depth_range" not in {c["field"] for c in widget.controls} + # The default is there for the script to show when the row appears. + assert widget.values["outline_depth_range"] == [0.01, 0.06] + + widget.values = {**widget.values, "outline_mode": "depth_threshold"} + + by_field = {c["field"]: c for c in widget.controls} + assert by_field["outline.slot"]["kind"] == "slot" + assert "outline_selected_labels" not in by_field + entry = by_field["outline_depth_range"] + assert entry["kind"] == "range" + assert entry["labels"] == ["Start", "Solid"] + assert (entry["minimum"], entry["first_maximum"], entry["decimals"]) == ( + 0.001, + 1.0, + 3, + ) + assert [(e.field, e.value) for e in emitted] == [ + ("outline_mode", "depth_threshold") + ] + + widget.values = {**widget.values, "outline_mode": "all_boundaries"} + assert "outline_depth_range" not in {c["field"] for c in widget.controls} + + +def test_the_anywidget_range_is_sent_as_a_pair(): + pytest.importorskip("anywidget") + widget, emitted = _labels_any(outline_mode="depth_threshold") + + widget.values = {**widget.values, "outline_depth_range": [0.02, 0.12]} + + assert [(e.field, e.value) for e in emitted] == [ + ("outline_depth_range", (0.02, 0.12)) + ] + assert isinstance(emitted[-1].value, tuple) + + +def test_the_anywidget_never_sends_a_pair_out_of_order(): + """The script keeps the pair in order; Python does not rely on it.""" + pytest.importorskip("anywidget") + widget, emitted = _labels_any(outline_mode="depth_threshold") + + widget.values = {**widget.values, "outline_depth_range": [0.3, 0.06]} + + assert emitted[-1].value == (0.3, 0.3) + assert widget.values["outline_depth_range"] == [0.3, 0.3] + assert len(emitted) == 1 + + widget.values = {**widget.values, "outline_depth_range": ["x", 0.06]} + assert len(emitted) == 1 + + +def test_an_inbound_range_reaches_the_anywidget_in_any_mode(): + pytest.importorskip("anywidget") + for mode in ("per_label", "depth_threshold"): + widget, emitted = _labels_any(outline_mode=mode) + + widget._on_visual_render_changed(_foreign("outline_depth_range", (0.03, 0.09))) + + assert widget.values["outline_depth_range"] == [0.03, 0.09] + assert emitted == [] + + +def test_the_range_coercion_is_what_the_model_accepts(): + """Whatever the boxes say, the pair the row holds is a valid range.""" + from cellier.gui._render_controls import coerce_depth_range + from cellier.visuals._label_memory import validate_outline_depth_range + + for start, solid in [(0.0, 0.0), (-1.0, 5.0), (0.5, 0.1), (3.0, 0.2), (0.01, 99)]: + pair = coerce_depth_range(start, solid) + assert validate_outline_depth_range(pair) == pair + assert coerce_depth_range(0.02, 0.12) == (0.02, 0.12) diff --git a/tests/render/test_multiscale_labels_3d_normal_target.py b/tests/render/test_multiscale_labels_3d_normal_target.py new file mode 100644 index 00000000..b10577f5 --- /dev/null +++ b/tests/render/test_multiscale_labels_3d_normal_target.py @@ -0,0 +1,93 @@ +"""3D multiscale labels draw in every render mode with the normal target. + +Ambient occlusion installs the ``normal`` render target, which sets +``write_normal`` on every shader. The multiscale labels shader then needs a +surface normal in every mode, but ``label_object_normal`` was only compiled +for the modes that shade with it, so ``flat_categorical`` called a function +that was not there and the shader did not compile. +""" + +from __future__ import annotations + +from typing import get_args + +import numpy as np +import pytest +import tensorstore as ts + +from cellier.data.image._zarr_multiscale_store import MultiscaleZarrDataStore +from cellier.render._cellier_blender import NORMAL_TARGET +from cellier.visuals._labels import ( + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) +from tests._gpu_budget import SMALL_BUDGETS +from tests.render._planes import shoot + +SHAPES = [(16, 16, 16), (8, 8, 8)] +CUBE_LO, CUBE_HI = 5, 10 # inclusive, level-0 voxels, every axis +LABEL = 3 + +RENDER_MODES = get_args( + MultiscaleLabelsAppearance.model_fields["render_mode"].annotation +) + + +def _store(root) -> MultiscaleZarrDataStore: + level0 = np.zeros(SHAPES[0], dtype=np.int32) + level0[CUBE_LO : CUBE_HI + 1, CUBE_LO : CUBE_HI + 1, CUBE_LO : CUBE_HI + 1] = LABEL + for index, data in enumerate((level0, level0[::2, ::2, ::2])): + spec = { + "driver": "zarr3", + "kvstore": {"driver": "file", "path": str(root / f"s{index}")}, + "metadata": { + "shape": list(data.shape), + "data_type": "int32", + "chunk_grid": { + "name": "regular", + "configuration": {"chunk_shape": list(data.shape)}, + }, + }, + "create": True, + "delete_existing": True, + } + ts.open(spec).result()[...].write(np.ascontiguousarray(data)).result() + return MultiscaleZarrDataStore.from_scale_and_translation( + zarr_path=str(root), + scale_names=["s0", "s1"], + level_scales=[(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)], + level_translations=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], + name="labels-3d-normal-target", + ) + + +def test_every_render_mode_is_covered(): + assert set(RENDER_MODES) >= { + "iso_categorical", + "flat_categorical", + "gradient_debug", + "smooth_iso", + } + + +@pytest.mark.parametrize("render_mode", RENDER_MODES) +async def test_labels_draw_with_the_normal_target( + controller, reslice, tmp_path, render_mode +): + scene = controller.add_scene( + displayed_axes=("z", "y", "x"), name=f"normal-{render_mode}" + ) + controller.add_labels_multiscale( + data=_store(tmp_path), + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance(force_level=0, render_mode=render_mode), + render_config=MultiscaleLabelRenderConfig(**SMALL_BUDGETS, block_size=8), + ) + controller.add_canvas(scene_id=scene.id) + await reslice(controller, scene.id) + + # A shader that does not compile raises out of the draw. + shot = shoot(controller, scene, size=(128, 128), blender_targets=[NORMAL_TARGET]) + + if render_mode != "plane": # no render plane was added, so nothing is cut + assert (shot.frame[..., 3] > 0).any(), "the label cube drew no pixel" diff --git a/tests/render/test_multiscale_paint_textures.py b/tests/render/test_multiscale_paint_textures.py index 06b5f41a..5fe8c1b2 100644 --- a/tests/render/test_multiscale_paint_textures.py +++ b/tests/render/test_multiscale_paint_textures.py @@ -86,8 +86,8 @@ def visual_setup(qtbot, tmp_path): @pytest.mark.asyncio async def test_paint_textures_are_allocated(visual_setup): _ctrl, gfx_visual = visual_setup - # block_size=16, paint_max_tiles=4 => paint cache shape (64, 16, 2). - assert gfx_visual._t_paint_cache.data.shape == (64, 16, 2) + # block_size=16, paint_max_tiles=4 => a 2 x 2 grid of 16-pixel slots. + assert gfx_visual._t_paint_cache.data.shape == (32, 32, 2) # LUT shape: 64x64 labels / 16-block = 4x4 grid. assert gfx_visual._t_paint_lut.data.shape == (4, 4, 2) np.testing.assert_array_equal(gfx_visual._t_paint_cache.data, 0.0) @@ -107,7 +107,7 @@ async def test_patch_writes_label_and_presence(visual_setup): assert gfx_visual._paint_slot_manager.get((1, 2)) == 0 # LUT entry: (slot=0, presence=1). np.testing.assert_array_equal(gfx_visual._t_paint_lut.data[1, 2], [0.0, 1.0]) - # Stripes layout: slot 0 occupies rows 0..16. + # Grid layout: slot 0 occupies rows 0..16, columns 0..16. # Voxel (16, 32) -> ty=0, tx=0 in tile (1, 2) -> cache[0, 0] # Voxel (17, 33) -> ty=1, tx=1 in tile (1, 2) -> cache[1, 1] np.testing.assert_allclose(gfx_visual._t_paint_cache.data[0, 0], [7.0, 1.0]) @@ -125,6 +125,25 @@ async def test_multiple_tiles_get_distinct_slots(visual_setup): assert {sm.get((0, 0)), sm.get((1, 0)), sm.get((0, 1))} == {0, 1, 2} +@pytest.mark.asyncio +async def test_slots_fill_the_grid_in_row_major_order(visual_setup): + _ctrl, gfx_visual = visual_setup + # Four tiles in np.unique order take slots 0..3 of the 2 x 2 grid. + voxels = np.array([[1, 2], [3, 20], [20, 5], [21, 22]], dtype=np.int64) + values = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32) + assert gfx_visual.patch_paint_texture(voxels, values, displayed_axes=(0, 1)) == 4 + + sm = gfx_visual._paint_slot_manager + assert [sm.get(t) for t in [(0, 0), (0, 1), (1, 0), (1, 1)]] == [0, 1, 2, 3] + cache = gfx_visual._t_paint_cache.data + # Slot s sits at row s // 2, column s % 2; the in-tile offset is added. + np.testing.assert_allclose(cache[0 + 1, 0 + 2], [1.0, 1.0]) + np.testing.assert_allclose(cache[0 + 3, 16 + 4], [2.0, 1.0]) + np.testing.assert_allclose(cache[16 + 4, 0 + 5], [3.0, 1.0]) + np.testing.assert_allclose(cache[16 + 5, 16 + 6], [4.0, 1.0]) + assert np.count_nonzero(cache[..., 1]) == 4 + + @pytest.mark.asyncio async def test_pool_exhaustion_drops_excess_tiles(visual_setup): _ctrl, gfx_visual = visual_setup diff --git a/tests/render/test_outline.py b/tests/render/test_outline.py index 57c412ff..442a1991 100644 --- a/tests/render/test_outline.py +++ b/tests/render/test_outline.py @@ -27,13 +27,16 @@ ) from cellier.render._pick_buffer import enable_pick_texture_binding, get_pick_view from cellier.render._visual_lut import ( + KIND_LABEL_ALL, KIND_WHOLE_OBJECT, LUT_HEIGHT, LUT_WIDTH, PLACEMENT_INWARD, PLACEMENT_OUTWARD, VisualLut, + decode_depth_range, decode_entry, + encode_depth_range, encode_entry, lut_index, ) @@ -108,6 +111,8 @@ def test_encode_decode_entry_round_trips(): kind, placement, ao_excluded, + 0, + 0, ) @@ -185,6 +190,170 @@ def test_lut_apply_clears_stale_entries(offscreen_renderer): assert lut.get_entry(987654) == 0 +# --------------------------------------------------------------------------- +# The depth range in the table +# --------------------------------------------------------------------------- + +#: Depth ranges across the sizes a user can set, the default among them. +_DEPTH_RANGES = [ + (0.01, 0.06), + (0.02, 0.12), + (0.001, 0.002), + (0.001, 0.2), + (0.0037, 0.0041), + (0.05, 0.05), + (0.3, 1.0), + (1.0, 1.0), + (1.0, 3.0), +] + + +def _read_lut_texel(lut: VisualLut, object_id: int) -> tuple[int, int]: + """Read ``(r, g)`` of *object_id*'s texel back from the GPU.""" + x, y = lut_index(object_id) + raw = lut._device.queue.read_texture( + {"texture": lut.texture, "mip_level": 0, "origin": (x, y, 0)}, + {"offset": 0, "bytes_per_row": 4, "rows_per_image": 1}, + (1, 1, 1), + ) + r, g = np.frombuffer(raw, np.uint16) + return int(r), int(g) + + +def test_the_default_depth_range_has_the_documented_codes(): + """The codes the design names for ``(0.01, 0.06)``.""" + assert encode_depth_range((0.01, 0.06)) == (655, 83) + + +@pytest.mark.parametrize("depth_range", _DEPTH_RANGES) +def test_a_depth_range_round_trips_through_its_codes(depth_range): + """The start comes back within one code and the solid value within 1.1%.""" + start, solid = depth_range + start_code, ramp_code = encode_depth_range(depth_range) + assert 1 <= start_code <= 65535 + assert 0 <= ramp_code <= 255 + + coded_start, coded_solid = decode_depth_range(start_code, ramp_code) + assert abs(coded_start - start) <= 1 / 65535 + assert abs(coded_solid / solid - 1) <= 0.011 + + # A hard edge is ramp 0, and nothing else is. + assert (ramp_code == 0) == (solid == start) + if solid == start: + assert coded_solid == coded_start + + +def test_a_depth_range_that_is_on_never_encodes_as_off(): + """A start too small for one code still gets a code; 0 is the off flag.""" + start_code, ramp_code = encode_depth_range((1e-9, 1e-9)) + assert start_code == 1 + assert ramp_code == 0 + assert decode_depth_range(0, 0) is None + assert decode_depth_range(0, 83) is None + + +def test_a_ramp_is_never_rounded_to_a_hard_edge_and_is_clamped(): + """``solid > start`` keeps a ramp however close the two are.""" + assert encode_depth_range((0.02, 0.02 * (1 + 1e-9)))[1] == 1 + # A ratio past what 8 bits hold takes the widest ramp. + assert encode_depth_range((0.001, 1000.0))[1] == 255 + + +@pytest.mark.parametrize( + "depth_range", + [(0.0, 0.1), (-0.01, 0.1), (1.5, 2.0), (0.02, 0.01), (float("nan"), 0.1)], +) +def test_encode_depth_range_rejects_a_bad_pair(depth_range): + """A start outside (0, 1] or a solid value below it raises.""" + with pytest.raises(ValueError): + encode_depth_range(depth_range) + + +def test_the_depth_codes_leave_the_entry_byte_alone(): + """Every existing entry keeps its low byte whatever the codes are.""" + for slot in range(MAX_OUTLINE_SLOT + 1): + for kind in (0, 1, 2, 3): + for placement in (PLACEMENT_INWARD, PLACEMENT_OUTWARD): + for ao_excluded in (False, True): + plain = encode_entry(slot, kind, placement, ao_excluded=ao_excluded) + for start_code, ramp_code in ((1, 0), (655, 83), (65535, 255)): + value = encode_entry( + slot, + kind, + placement, + ao_excluded=ao_excluded, + depth_start_code=start_code, + depth_ramp_code=ramp_code, + ) + assert value & 0xFF == plain + assert decode_entry(value) == ( + slot, + kind, + placement, + ao_excluded, + start_code, + ramp_code, + ) + + +def test_encode_entry_rejects_out_of_range_depth_codes(): + """The codes are checked like every other field.""" + with pytest.raises(ValueError): + encode_entry(1, depth_start_code=65536) + with pytest.raises(ValueError): + encode_entry(1, depth_start_code=-1) + with pytest.raises(ValueError): + encode_entry(1, depth_ramp_code=256) + + +def test_the_texel_holds_the_entry_in_two_channels(offscreen_renderer): + """``r`` is the byte under the ramp code and ``g`` the start code.""" + lut = VisualLut() + assert lut.texture.format == wgpu.TextureFormat.rg16uint + + object_id = 734_003 + plain = encode_entry(3, KIND_LABEL_ALL, PLACEMENT_OUTWARD, ao_excluded=True) + lut.set_entry(object_id, plain) + assert _read_lut_texel(lut, object_id) == (plain, 0) + + ranged = encode_entry( + 3, + KIND_LABEL_ALL, + PLACEMENT_OUTWARD, + ao_excluded=True, + depth_start_code=655, + depth_ramp_code=83, + ) + assert lut.set_entry(object_id, ranged) is True + assert lut.get_entry(object_id) == ranged + assert _read_lut_texel(lut, object_id) == (plain | (83 << 8), 655) + + # The largest codes fill both channels and nothing spills. + full = encode_entry(15, 3, 1, True, depth_start_code=65535, depth_ramp_code=255) + lut.set_entry(object_id, full) + assert _read_lut_texel(lut, object_id) == (0xFFFF, 0xFFFF) + + # A change of the range alone is a change of the entry. + assert lut.set_entry(object_id, ranged) is True + assert lut.set_entry(object_id, ranged) is False + + +def test_lut_apply_clears_an_entry_with_a_depth_range(offscreen_renderer): + """The range goes with its id, on the GPU as on the Python side.""" + lut = VisualLut() + value = encode_entry( + 1, KIND_LABEL_ALL, PLACEMENT_INWARD, depth_start_code=655, depth_ramp_code=83 + ) + lut.apply({12345: value, 987654: value}) + assert lut.entries == {12345: value, 987654: value} + + lut.apply({987654: value}) + assert lut.entries == {987654: value} + assert lut.get_entry(12345) == 0 + assert _read_lut_texel(lut, 12345) == (0, 0) + assert _read_lut_texel(lut, 987654) == (value & 0xFFFF, 655) + + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @@ -1138,3 +1307,109 @@ def test_canvas_with_outlines_off_never_allocates_the_lut(qtbot, offscreen_rende assert lut_module.peek_shared_visual_lut() is not None finally: lut_module._SHARED_LUT = saved + + +# --------------------------------------------------------------------------- +# The depth range: carried in the table, read by nothing yet +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_depth_codes_in_an_entry_do_not_change_the_frame(offscreen_renderer, placement): + """The pass masks the bits it reads, so the codes beside them are inert.""" + object_id = 600_123 + ids = np.zeros((_SIZE, _SIZE), dtype=np.uint32) + ids[20:44, 20:44] = object_id + + frames = [] + for codes in ((0, 0), (65535, 255)): + harness = _SyntheticOutline(ids) + harness.configure( + OutlineConfig( + enabled=True, + boundaries=OutlineLayerConfig(enabled=True, inward_thickness=1), + selection=OutlineLayerConfig( + enabled=True, inward_thickness=2, outward_thickness=2 + ), + inner_thickness=2, + palette=[_SELECT], + ), + has_inward=placement == PLACEMENT_INWARD, + has_outward=placement == PLACEMENT_OUTWARD, + ) + harness.set_entry( + object_id, + encode_entry( + 1, + KIND_WHOLE_OBJECT, + placement, + depth_start_code=codes[0], + depth_ramp_code=codes[1], + ), + ) + frames.append(harness.render()) + + assert _count_exact(frames[0], _SELECT) > 0 + np.testing.assert_array_equal(frames[0], frames[1]) + + +def test_set_visual_outline_carries_the_depth_range(outline_controller): + """The range reaches every world object's entry and comes back as codes.""" + from cellier.render._visual_lut import get_shared_visual_lut + + controller, _scene, visual = outline_controller + manager = controller._render_manager + lut = get_shared_visual_lut() + lut.clear() + + manager.set_visual_outline(visual.id, slot=2) + assert manager.get_visual_outline(visual.id) == ( + 2, + PLACEMENT_INWARD, + KIND_WHOLE_OBJECT, + 0, + 0, + ) + plain = encode_entry(2, KIND_WHOLE_OBJECT, PLACEMENT_INWARD) + assert set(lut.entries.values()) == {plain} + + manager.set_visual_outline(visual.id, slot=2, depth_range=(0.01, 0.06)) + assert manager.get_visual_outline(visual.id)[3:] == (655, 83) + assert lut.entries + for object_id, value in lut.entries.items(): + assert value == plain | (83 << 8) | (655 << 16) + assert _read_lut_texel(lut, object_id) == (plain | (83 << 8), 655) + + # No range again: both codes go back to 0. + manager.set_visual_outline(visual.id, slot=2) + assert set(lut.entries.values()) == {plain} + + with pytest.raises(ValueError): + manager.set_visual_outline(visual.id, slot=2, depth_range=(0.06, 0.01)) + + +def test_a_depth_range_follows_a_rebuilt_world_object(outline_controller): + """A rebuild hands out a new id; the range must move to it with the rest.""" + from cellier.render._visual_lut import get_shared_visual_lut + + controller, scene, visual = outline_controller + manager = controller._render_manager + lut = get_shared_visual_lut() + manager.set_visual_outline(visual.id, slot=1, depth_range=(0.02, 0.12)) + original = dict(lut.entries) + assert original + (value,) = set(original.values()) + assert decode_entry(value)[4:] == encode_depth_range((0.02, 0.12)) + + gfx_visual = manager._scenes[scene.id].get_visual(visual.id) + replacement = gfx.Mesh(gfx.box_geometry(), gfx.MeshBasicMaterial(pick_write=True)) + gfx_visual.node_3d = replacement + gfx_visual.node_2d = replacement + gfx_visual.node = replacement + + manager._sync_visual_lut() + + assert lut.entries == {replacement.id: value} + assert _read_lut_texel(lut, replacement.id) == (value & 0xFFFF, value >> 16) + for stale_id in original: + assert _read_lut_texel(lut, stale_id) == (0, 0) diff --git a/tests/render/test_outline_depth_edges.py b/tests/render/test_outline_depth_edges.py new file mode 100644 index 00000000..38976025 --- /dev/null +++ b/tests/render/test_outline_depth_edges.py @@ -0,0 +1,824 @@ +"""Tests for the depth edges of the screen-space outline pass. + +A depth edge is a band where two pixels of the *same* key lie on surfaces a +depth jump apart: one part of a visual passing in front of another. + +Most of these drive ``_OutlineQuadPass`` over *fabricated* pick, label-key +and depth buffers, as ``test_outline.py`` does for the identity edges, so +the band can be named pixel by pixel. Every fabricated scene is constant +along y: the columns of one row say everything. The last group draws a +real labels volume through a canvas. +""" + +from __future__ import annotations + +import asyncio + +import numpy as np +import pygfx as gfx +import pytest +import wgpu + +from cellier.render._config import OutlineConfig, OutlineLayerConfig +from cellier.render._visual_lut import ( + KIND_LABEL_ALL, + KIND_WHOLE_OBJECT, + PLACEMENT_INWARD, + PLACEMENT_OUTWARD, + VisualLut, + decode_depth_range, + encode_depth_range, + encode_entry, +) +from tests.render.conftest import drain_loading + +_SIZE = 64 +_ROW = _SIZE // 2 +_FILL = (0.2, 0.2, 0.2, 1.0) +_SELECT = (1.0, 0.0, 1.0, 1.0) +_BOUNDARY = (0.0, 1.0, 0.0, 1.0) +_INNER = (0.0, 0.0, 1.0, 1.0) + +#: The camera the fabricated depth is written for. +_NEAR, _FAR = 5.0, 40.0 +_CAMERA = gfx.PerspectiveCamera(50, 1, depth_range=(_NEAR, _FAR)) +_PROJECTION = np.asarray(_CAMERA.projection_matrix, dtype=np.float64) +_PROJECTION_INV = np.asarray(_CAMERA.projection_matrix_inverse, dtype=np.float64) + +_A = 300_001 +_B = 700_002 + + +def _raw_of(z) -> np.ndarray: + """The raw depth of a distance *z* from the camera plane.""" + z = np.asarray(z, dtype=np.float64) + clip_z = _PROJECTION[2, 2] * -z + _PROJECTION[2, 3] + clip_w = _PROJECTION[3, 2] * -z + _PROJECTION[3, 3] + return clip_z / clip_w + + +def _z_of(raw) -> np.ndarray: + """The distance of a raw depth, as the shader recovers it.""" + raw = np.asarray(raw, dtype=np.float64) + z = _PROJECTION_INV[2, 2] * raw + _PROJECTION_INV[2, 3] + w = _PROJECTION_INV[3, 2] * raw + _PROJECTION_INV[3, 3] + return -z / w + + +def _columns(values) -> np.ndarray: + """A ``(_SIZE, _SIZE)`` image holding *values* in every row.""" + return np.tile(np.asarray(values)[None, :], (_SIZE, 1)) + + +class _SyntheticDepthOutline: + """Run ``_OutlineQuadPass`` over hand-built pick, key and depth buffers. + + Parameters + ---------- + ids : np.ndarray + ``(h, w)`` pygfx object ids; 0 is the background. + z : np.ndarray + ``(h, w)`` distance of each pixel's surface from the camera plane. + label_keys : np.ndarray or None + ``(h, w)`` label keys, for visuals of a label kind. ``None`` binds + no label-key target. + """ + + def __init__(self, ids, z, label_keys=None) -> None: + from pygfx.renderers.wgpu.engine.shared import get_shared + + from cellier.render._outline import _OutlineQuadPass + + self._device = get_shared().device + height, width = ids.shape + self._size = (width, height) + + pick = np.zeros((height, width, 4), dtype=np.uint16) + pick[..., 0] = ids & 0xFFFF + pick[..., 1] = (ids >> 16) & 0xF + self._pick_view = self._upload(pick, wgpu.TextureFormat.rgba16uint, 8) + + color = np.zeros((height, width, 4), dtype=np.uint8) + color[:] = np.round(np.array(_FILL) * 255).astype(np.uint8) + self._color_view = self._upload(color, wgpu.TextureFormat.rgba8unorm, 4) + + # A depth32float texture cannot be written from the CPU; 16 bits is + # ample here. ``raw`` is the depth as stored. + codes = np.round(_raw_of(z) * 65535.0).astype(np.uint16) + self.raw = codes / 65535.0 + self._depth_view = self._upload(codes, wgpu.TextureFormat.depth16unorm, 2) + + self._key_view = None + if label_keys is not None: + self._key_view = self._upload( + np.ascontiguousarray(label_keys, dtype=np.uint32), + wgpu.TextureFormat.r32uint, + 4, + ) + + self._target = self._device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.RENDER_ATTACHMENT | wgpu.TextureUsage.COPY_SRC, + dimension="2d", + ) + self._lut = VisualLut() + self._quad = _OutlineQuadPass() + self._quad.set_uniform( + "projection_transform_inv", _PROJECTION_INV.astype(np.float32).T + ) + + def _upload(self, data, fmt, bytes_per_texel): + width, height = self._size + texture = self._device.create_texture( + size=(width, height, 1), + format=fmt, + usage=wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + dimension="2d", + ) + self._device.queue.write_texture( + {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)}, + np.ascontiguousarray(data), + { + "offset": 0, + "bytes_per_row": bytes_per_texel * width, + "rows_per_image": height, + }, + (width, height, 1), + ) + return texture.create_view() + + def configure(self, config: OutlineConfig, *, placement: int) -> None: + """Apply an ``OutlineConfig`` for visuals of one *placement*.""" + quad = self._quad + quad.set_template_vars( + b_t_in=config.boundaries.inward_thickness, + b_t_out=config.boundaries.outward_thickness, + s_t_in=config.selection.inward_thickness, + s_t_out=config.selection.outward_thickness, + inner_t=config.inner_thickness, + has_inward=placement == PLACEMENT_INWARD, + has_outward=placement == PLACEMENT_OUTWARD, + ) + quad.set_uniform("boundaries_enabled", int(config.boundaries.enabled)) + quad.set_uniform("selection_enabled", int(config.selection.enabled)) + quad.set_uniform("boundary_color", tuple(config.boundaries.color)) + quad.set_uniform("inner_color", tuple(config.inner_color)) + quad.set_palette([tuple(c) for c in config.palette]) + + def set_visual( + self, object_id, *, placement, depth_range=None, kind=KIND_WHOLE_OBJECT, slot=1 + ) -> None: + """Write one visual's entry, with or without a depth range.""" + start_code, ramp_code = ( + (0, 0) if depth_range is None else encode_depth_range(depth_range) + ) + self._lut.set_entry( + object_id, + encode_entry( + slot, + kind, + placement, + depth_start_code=start_code, + depth_ramp_code=ramp_code, + ), + ) + + def render(self, *, depth_edges: bool = True) -> np.ndarray: + """Draw and read the target back as an ``(h, w, 4)`` uint8 array.""" + width, height = self._size + encoder = self._device.create_command_encoder() + self._quad.render( + encoder, + self._color_view, + self._pick_view, + self._lut.view, + self._target.create_view(), + self._key_view, + self._depth_view if depth_edges else None, + ) + self._device.queue.submit([encoder.finish()]) + raw = self._device.queue.read_texture( + {"texture": self._target, "mip_level": 0, "origin": (0, 0, 0)}, + {"offset": 0, "bytes_per_row": 4 * width, "rows_per_image": height}, + (width, height, 1), + ) + return np.frombuffer(raw, np.uint8).reshape(height, width, 4) + + +def _config(t: int, *, boundaries: int = 0, inner_t: int = 0) -> OutlineConfig: + """The selection layer at thickness *t* on both sides; other layers opt in.""" + return OutlineConfig( + enabled=True, + boundaries=OutlineLayerConfig( + enabled=boundaries > 0, + inward_thickness=max(boundaries, 1), + outward_thickness=max(boundaries, 1), + color=_BOUNDARY, + ), + selection=OutlineLayerConfig( + enabled=True, inward_thickness=t, outward_thickness=t + ), + inner_thickness=inner_t, + inner_color=_INNER, + palette=[_SELECT], + ) + + +def _rgb8(rgba) -> np.ndarray: + return np.round(np.array(rgba[:3]) * 255).astype(np.uint8) + + +def _changed_columns(frame: np.ndarray, row: int = _ROW) -> list[int]: + """Columns of *row* that are not the fill.""" + return np.flatnonzero(np.any(frame[row, :, :3] != _rgb8(_FILL), axis=-1)).tolist() + + +def _is_select(frame: np.ndarray, columns, row: int = _ROW) -> bool: + return bool(np.all(frame[row, columns, :3] == _rgb8(_SELECT))) + + +def _step_scene(z_near: float = 10.0, z_far: float = 20.0, edge: int = _SIZE // 2): + """One object filling the frame: near left of *edge*, far from it on.""" + ids = np.full((_SIZE, _SIZE), _A, dtype=np.uint32) + z = _columns(np.where(np.arange(_SIZE) < edge, z_near, z_far)) + return ids, z + + +def _band_columns(placement: int, edge: int, t: int = 2) -> list[int]: + """The band of a jump at *edge*: on the near side inward, the far outward.""" + if placement == PLACEMENT_INWARD: + return list(range(edge - t, edge)) + return list(range(edge, edge + t)) + + +def _over(colour, alpha: float, below) -> np.ndarray: + """*colour* at *alpha* over *below*, as floats in [0, 1].""" + return np.asarray(below[:3]) * (1.0 - alpha) + np.asarray(colour[:3]) * alpha + + +def _smoothstep(low: float, high: float, value: float) -> float: + x = min(max((value - low) / (high - low), 0.0), 1.0) + return x * x * (3.0 - 2.0 * x) + + +# --------------------------------------------------------------------------- +# Where the band lies +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("thickness", [1, 2, 4]) +@pytest.mark.parametrize( + "placement, side", [(PLACEMENT_INWARD, "near"), (PLACEMENT_OUTWARD, "far")] +) +def test_a_depth_jump_inside_one_object_is_outlined( + offscreen_renderer, thickness, placement, side +): + """Inward the band is on the nearer surface, outward on the farther. + + The object fills the frame, so it has no identity edge at all: with no + depth range nothing is drawn. + """ + edge = _SIZE // 2 + harness = _SyntheticDepthOutline(*_step_scene(edge=edge)) + harness.configure(_config(thickness), placement=placement) + + harness.set_visual(_A, placement=placement) + assert _changed_columns(harness.render()) == [] + + harness.set_visual(_A, placement=placement, depth_range=(0.02, 0.02)) + frame = harness.render() + expected = ( + list(range(edge - thickness, edge)) + if side == "near" + else list(range(edge, edge + thickness)) + ) + assert _changed_columns(frame) == expected + # A hard range draws the line at its full alpha. + assert _is_select(frame, expected) + # Constant along y: every row is the same. + assert np.all(frame == frame[_ROW]) + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_a_jump_under_the_start_is_not_an_edge(offscreen_renderer, placement): + """The start is relative to the distance of the nearer surface.""" + harness = _SyntheticDepthOutline(*_step_scene(z_near=10.0, z_far=10.4)) + harness.configure(_config(2), placement=placement) + + harness.set_visual(_A, placement=placement, depth_range=(0.05, 0.05)) + assert _changed_columns(harness.render()) == [] + + harness.set_visual(_A, placement=placement, depth_range=(0.03, 0.03)) + assert len(_changed_columns(harness.render())) == 2 + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_without_the_depth_texture_the_range_draws_nothing( + offscreen_renderer, placement +): + """The term is compiled away when no depth is handed to the pass.""" + harness = _SyntheticDepthOutline(*_step_scene()) + harness.configure(_config(2), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=(0.02, 0.02)) + assert _changed_columns(harness.render(depth_edges=False)) == [] + assert harness._quad._template_vars["has_depth_edges"] is False + + +# --------------------------------------------------------------------------- +# The second condition: the near pixel in front of the far surface carried on +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("thickness", [1, 2, 4]) +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_a_slanted_surface_is_not_an_edge(offscreen_renderer, thickness, placement): + """One flat surface seen at a slant has depth jumps and no edge. + + The plain gap between two pixels a thickness apart is over the start + all the way across; what stops a band is that the near pixel lies *on* + the far surface carried on to it. The surface runs off the frame on + both sides, so this also holds the rule at the frame's edge: there the + surface cannot be carried on from a texel outside the frame, and + reading the clamped texel instead would draw a band along the edge. + """ + start = 0.004 + # Raw depth is linear across a plane on screen. + raw = np.linspace(float(_raw_of(10.0)), float(_raw_of(30.0)), _SIZE) + z = _z_of(raw) + # The test has teeth: the plain gap is over the start everywhere. + assert np.all((z[thickness:] - z[:-thickness]) > start * z[:-thickness]) + + ids = np.full((_SIZE, _SIZE), _A, dtype=np.uint32) + harness = _SyntheticDepthOutline(ids, _columns(z)) + harness.configure(_config(thickness), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=(start, start)) + assert _changed_columns(harness.render()) == [] + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_a_near_surface_over_a_slanted_one_is_an_edge(offscreen_renderer, placement): + """A real overlap is still drawn when the far surface is slanted.""" + edge = _SIZE // 2 + raw = np.linspace(float(_raw_of(18.0)), float(_raw_of(30.0)), _SIZE) + z = np.where(np.arange(_SIZE) < edge, 10.0, _z_of(raw)) + ids = np.full((_SIZE, _SIZE), _A, dtype=np.uint32) + harness = _SyntheticDepthOutline(ids, _columns(z)) + harness.configure(_config(2), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=(0.004, 0.004)) + expected = _band_columns(placement, edge) + assert _changed_columns(harness.render()) == expected + + +# --------------------------------------------------------------------------- +# A far surface too narrow to be carried on from a thickness away (T2Fs) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "far_width, expected_width", [(1, 0), (2, 1), (3, 2), (4, 2), (8, 2)] +) +def test_outward_a_narrow_far_surface_is_carried_on_from_one_pixel( + offscreen_renderer, far_width, expected_width +): + """The band is lost only where the far surface is one pixel wide. + + Past a far pixel the surface is carried on from the pixel a thickness + beyond it, and where that is off the surface, from the pixel one step + beyond. With only the first of the two a far surface 3 pixels wide + would get a band 1 pixel wide at thickness 2, and one 2 wide none. + """ + edge = 30 + columns = np.arange(_SIZE) + ids_row = np.where(columns < edge + far_width, _A, 0).astype(np.uint32) + z = np.where(columns < edge, 10.0, 20.0) + harness = _SyntheticDepthOutline(_columns(ids_row), _columns(z)) + harness.configure(_config(2), placement=PLACEMENT_OUTWARD) + + # Outward, the object's own silhouette is drawn on the background. + harness.set_visual(_A, placement=PLACEMENT_OUTWARD) + silhouette = _changed_columns(harness.render()) + assert silhouette == [edge + far_width, edge + far_width + 1] + + harness.set_visual(_A, placement=PLACEMENT_OUTWARD, depth_range=(0.02, 0.02)) + band = sorted(set(_changed_columns(harness.render())) - set(silhouette)) + assert band == list(range(edge, edge + expected_width)) + + +@pytest.mark.parametrize( + "far_width, expected", [(1, []), (2, [28]), (3, [28, 29]), (4, [28, 29])] +) +def test_inward_a_narrow_far_surface_is_carried_on_from_one_pixel( + offscreen_renderer, far_width, expected +): + """The same rule with the centre as the near pixel.""" + edge = 30 + columns = np.arange(_SIZE) + ids_row = np.where(columns < edge + far_width, _A, 0).astype(np.uint32) + z = np.where(columns < edge, 10.0, 20.0) + harness = _SyntheticDepthOutline(_columns(ids_row), _columns(z)) + harness.configure(_config(2), placement=PLACEMENT_INWARD) + + harness.set_visual(_A, placement=PLACEMENT_INWARD) + silhouette = _changed_columns(harness.render()) + + harness.set_visual(_A, placement=PLACEMENT_INWARD, depth_range=(0.02, 0.02)) + band = sorted(set(_changed_columns(harness.render())) - set(silhouette)) + assert band == expected + + +# --------------------------------------------------------------------------- +# Identity edges are untouched +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_two_objects_at_one_depth_keep_their_identity_edge( + offscreen_renderer, placement +): + """A range adds nothing where the keys differ and the depth does not.""" + ids = _columns(np.where(np.arange(_SIZE) < _SIZE // 2, _A, _B).astype(np.uint32)) + z = np.full((_SIZE, _SIZE), 12.0) + frames = [] + for depth_range in (None, (0.01, 0.06)): + harness = _SyntheticDepthOutline(ids, z) + harness.configure(_config(2), placement=placement) + for object_id in (_A, _B): + harness.set_visual(object_id, placement=placement, depth_range=depth_range) + frames.append(harness.render()) + assert len(_changed_columns(frames[0])) == 4 + np.testing.assert_array_equal(frames[0], frames[1]) + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_an_identity_edge_is_not_faded(offscreen_renderer, placement): + """A silhouette over a far background of another key has full alpha. + + The jump across it is under the solid value, so a depth edge there + would be faint; an identity edge has a strength of 1. + """ + edge = _SIZE // 2 + ids = _columns(np.where(np.arange(_SIZE) < edge, _A, _B).astype(np.uint32)) + z = _columns(np.where(np.arange(_SIZE) < edge, 10.0, 10.3)) + harness = _SyntheticDepthOutline(ids, z) + harness.configure(_config(2), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=(0.02, 0.12)) + frame = harness.render() + expected = _band_columns(placement, edge) + assert _changed_columns(frame) == expected + assert _is_select(frame, expected) + + +# --------------------------------------------------------------------------- +# The strength of an edge +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +@pytest.mark.parametrize("jump", [0.015, 0.03, 0.05, 0.09, 0.12, 0.3]) +def test_the_alpha_of_a_depth_line_grows_with_the_jump( + offscreen_renderer, placement, jump +): + """No band under the start, the line's colour from the solid value up. + + Between the two the line is blended over the fill at + ``smoothstep(start, solid, jump)``. + """ + depth_range = (0.02, 0.1) + start, solid = decode_depth_range(*encode_depth_range(depth_range)) + edge = _SIZE // 2 + harness = _SyntheticDepthOutline(*_step_scene(z_near=10.0, z_far=10.0 * (1 + jump))) + harness.configure(_config(2), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=depth_range) + frame = harness.render() + + # The jump as the pass sees it, from the depth as stored. + z_near, z_far = _z_of(harness.raw[_ROW, edge - 1]), _z_of(harness.raw[_ROW, edge]) + seen = (z_far - z_near) / z_near + columns = _band_columns(placement, edge) + + if seen <= start: + assert _changed_columns(frame) == [] + return + assert _changed_columns(frame) == columns + alpha = _smoothstep(start, solid, seen) + expected = _over(_SELECT, alpha, _FILL) * 255 + for column in columns: + np.testing.assert_allclose(frame[_ROW, column, :3], expected, atol=1.5) + if seen >= solid: + assert _is_select(frame, columns) + else: + assert not _is_select(frame, columns) + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_the_strongest_of_the_taps_is_drawn(offscreen_renderer, placement): + """A pixel between a shallow and a deep jump takes the deep one's alpha. + + A strip 2 pixels wide with a shallow jump on its left and a deep one on + its right: at thickness 2 every pixel of it has both in reach, and the + left tap is examined first. + """ + depth_range = (0.02, 0.2) + left, right = 30, 32 + columns = np.arange(_SIZE) + if placement == PLACEMENT_INWARD: + # The strip is near; what lies to its right is much farther. + z = np.select([columns < left, columns < right], [10.5, 10.0], 14.0) + else: + # The strip is far; what lies to its right is much nearer. + z = np.select([columns < left, columns < right], [13.5, 14.0], 10.0) + ids = np.full((_SIZE, _SIZE), _A, dtype=np.uint32) + harness = _SyntheticDepthOutline(ids, _columns(z)) + harness.configure(_config(2), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=depth_range) + frame = harness.render() + # The deep jump is over the solid value: full alpha on the whole strip. + assert _is_select(frame, [left, left + 1]) + + +def test_every_layer_is_faded_with_the_line(offscreen_renderer): + """The boundaries layer and the contrast band take the edge's strength.""" + depth_range = (0.02, 0.1) + start, solid = decode_depth_range(*encode_depth_range(depth_range)) + edge = _SIZE // 2 + harness = _SyntheticDepthOutline(*_step_scene(z_near=10.0, z_far=10.5)) + harness.configure(_config(2, boundaries=1, inner_t=2), placement=PLACEMENT_OUTWARD) + harness.set_visual(_A, placement=PLACEMENT_OUTWARD, depth_range=depth_range) + frame = harness.render() + + z_near, z_far = _z_of(harness.raw[_ROW, edge - 1]), _z_of(harness.raw[_ROW, edge]) + alpha = _smoothstep(start, solid, (z_far - z_near) / z_near) + assert 0.2 < alpha < 0.8 + + assert _changed_columns(frame) == [edge, edge + 1, edge + 2, edge + 3] + line = _over(_SELECT, alpha, _FILL) + # The boundaries layer, 1 pixel, lies under the line's first pixel. + line_over_boundary = _over(_SELECT, alpha, _over(_BOUNDARY, alpha, _FILL)) + contrast = _over(_INNER, alpha, _FILL) + expected = [line_over_boundary, line, contrast, contrast] + for offset, colour in enumerate(expected): + np.testing.assert_allclose( + frame[_ROW, edge + offset, :3], np.asarray(colour) * 255, atol=1.5 + ) + + +# --------------------------------------------------------------------------- +# The range is per visual +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("placement", [PLACEMENT_INWARD, PLACEMENT_OUTWARD]) +def test_each_visual_is_tested_against_its_own_range(offscreen_renderer, placement): + """The same jump is an edge for one visual and not for the other.""" + edge = _SIZE // 2 + ids = np.full((_SIZE, _SIZE), _A, dtype=np.uint32) + ids[_SIZE // 2 :] = _B + z = _columns(np.where(np.arange(_SIZE) < edge, 10.0, 10.3)) + harness = _SyntheticDepthOutline(ids, z) + harness.configure(_config(2), placement=placement) + harness.set_visual(_A, placement=placement, depth_range=(0.02, 0.02)) + harness.set_visual(_B, placement=placement, depth_range=(0.05, 0.05)) + frame = harness.render() + assert len(_changed_columns(frame, row=8)) == 2 + assert _changed_columns(frame, row=_SIZE - 8) == [] + + +def _same_key_scene(): + """Visual A near on the left, visual B far on the right, one label key.""" + edge = _SIZE // 2 + columns = np.arange(_SIZE) + ids = _columns(np.where(columns < edge, _A, _B).astype(np.uint32)) + z = _columns(np.where(columns < edge, 10.0, 20.0)) + keys = np.full((_SIZE, _SIZE), 19_209_143, dtype=np.uint32) + return ids, z, keys + + +@pytest.mark.parametrize( + "placement, ranged, expected", + [ + # Outward: the range and the style are those of the tap, the nearer. + (PLACEMENT_OUTWARD, _A, [32, 33]), + (PLACEMENT_OUTWARD, _B, []), + # Inward: those of the centre, which is the nearer pixel. + (PLACEMENT_INWARD, _A, [30, 31]), + (PLACEMENT_INWARD, _B, []), + (PLACEMENT_INWARD, None, []), + (PLACEMENT_OUTWARD, None, []), + ], +) +def test_two_label_visuals_with_one_key_get_a_depth_edge_between_them( + offscreen_renderer, placement, ranged, expected +): + """Two labels visuals holding the same label have no identity edge. + + The depth test is consulted between them, with the range of the nearer + visual. With no range on it nothing is drawn, as before. + """ + harness = _SyntheticDepthOutline(*_same_key_scene()) + harness.configure(_config(2), placement=placement) + for object_id in (_A, _B): + harness.set_visual( + object_id, + placement=placement, + kind=KIND_LABEL_ALL, + depth_range=(0.02, 0.02) if object_id == ranged else None, + ) + frame = harness.render() + assert _changed_columns(frame) == expected + assert _is_select(frame, expected) + + +# --------------------------------------------------------------------------- +# Through a canvas +# --------------------------------------------------------------------------- + + +def _crossing_tubes() -> np.ndarray: + """One label: a tube along x, and a tube along y in front of it.""" + shape = (48, 64, 64) + z, y, x = np.mgrid[: shape[0], : shape[1], : shape[2]] + data = np.zeros(shape, dtype=np.int32) + data[(z - 12) ** 2 + (y - 32) ** 2 < 25] = 1 # far, along x + data[(z - 36) ** 2 + (x - 32) ** 2 < 25] = 1 # near, along y + return data + + +@pytest.fixture +def crossing_scene(qtbot, offscreen_renderer): + """A 3D labels visual of two crossing tubes, outlined; one canvas.""" + from cellier.controller import CellierController + from cellier.data import LabelMemoryStore + from cellier.render import RenderManagerConfig + from cellier.visuals import InMemoryLabelsAppearance, VisualOutline + + controller = CellierController( + render_config=RenderManagerConfig(outline=_config(2)) + ) + controller.camera_reslice_enabled = False + scene = controller.add_scene(displayed_axes=("z", "y", "x"), name="scene") + visual = controller.add_labels( + data=LabelMemoryStore(data=_crossing_tubes(), name="labels"), + scene_id=scene.id, + appearance=InMemoryLabelsAppearance( + colormap_mode="random", render_mode="iso_categorical" + ), + name="labels", + outline=VisualOutline(slot=1, placement="outward"), + outline_mode="all_boundaries", + ) + controller.add_canvas(scene_id=scene.id, depth_range_3d=(1.0, 20000.0)) + # A pixel is then either the label or the line: no background gradient. + controller.update_background_field(scene.id, "visible", False) + + async def _load() -> None: + controller.fit_camera(scene.id) + controller.reslice_all() + await drain_loading(controller) + + asyncio.run(_load()) + yield controller, scene, visual + controller.close() + + +def _draw_scene(controller, scene, placement, depth_range, size=(192, 192)): + """Draw the live scene through an outline pass of its own renderer.""" + from pygfx.renderers.wgpu.engine.effectpasses import PPAAPass + from rendercanvas.offscreen import RenderCanvas + + from cellier.render._cellier_blender import ( + OUTLINE_ID_TARGET, + install_cellier_blender, + ) + from cellier.render._outline import OutlinePass + from cellier.render._pick_buffer import enable_pick_texture_binding + from cellier.render._visual_lut import get_shared_visual_lut + + canvas_view = controller.get_canvas_view(controller.get_canvas_ids(scene.id)[0]) + camera = canvas_view.camera + gfx_scene = controller._render_manager.get_scene(scene.id) + # Along the data's z axis: the tubes cross at the middle of the frame. + camera.show_object(gfx_scene, view_dir=(0.0, 0.0, -1.0), up=(0.0, 1.0, 0.0)) + canvas = RenderCanvas(size=size, pixel_ratio=1) + renderer = gfx.WgpuRenderer(canvas) + renderer.pixel_scale = 1 + renderer.ppaa = "none" + install_cellier_blender(renderer, [OUTLINE_ID_TARGET]) + enable_pick_texture_binding(renderer) + + outline = OutlinePass(renderer, get_shared_visual_lut(), lambda: camera) + outline.apply_config(controller.render_config.outline) + outline.set_placements( + has_inward=placement == "inward", has_outward=placement == "outward" + ) + outline.set_depth_edges(depth_range is not None) + renderer.effect_passes = ( + outline, + *(p for p in renderer.effect_passes if not isinstance(p, PPAAPass)), + ) + canvas.request_draw(lambda: renderer.render(gfx_scene, camera)) + return np.asarray(canvas.draw()) + + +def _draw_mode(controller, scene, visual, placement, mode, depth_range=None): + """Draw the labels in an outline mode, set through the controller.""" + controller.update_visual_render_field(visual.id, "outline.placement", placement) + controller.update_visual_render_field(visual.id, "outline_mode", mode) + if depth_range is not None: + controller.update_visual_render_field( + visual.id, "outline_depth_range", depth_range + ) + sent = controller._render_manager.get_visual_outline(visual.id)[3] > 0 + return _draw_scene(controller, scene, placement, depth_range if sent else None) + + +@pytest.mark.parametrize("placement", ["inward", "outward"]) +def test_depth_threshold_outlines_a_crossing_of_one_label(crossing_scene, placement): + """The mode, end to end: model, controller, table, pass. + + Two tubes of one label cross. ``all_boundaries`` draws the silhouette + only; ``depth_threshold`` draws that and more, and the more is at the + crossing. + """ + controller, scene, visual = crossing_scene + + def select_pixels(mode, depth_range=None) -> np.ndarray: + frame = _draw_mode(controller, scene, visual, placement, mode, depth_range) + return np.all(frame[..., :3] == _rgb8(_SELECT), axis=-1) + + plain = select_pixels("all_boundaries") + assert plain.sum() > 0 + # The start is well over a voxel's depth, which at this size is a few + # percent of the camera's distance: the steps of the voxel surface are + # real depth jumps too, and are not what is tested here. + ranged = select_pixels("depth_threshold", (0.1, 0.1)) + added = ranged & ~plain + assert added.sum() > 0 + assert not np.any(plain & ~ranged) + # The tubes cross at the middle of the frame, and only there does one + # part of the label pass in front of another. + rows, cols = np.nonzero(added) + height, width = added.shape + assert np.all(np.abs(rows - height / 2) < height / 4) + assert np.all(np.abs(cols - width / 2) < width / 4) + + # Back in all_boundaries the range is no longer sent. + np.testing.assert_array_equal(select_pixels("all_boundaries"), plain) + + +def test_depth_threshold_on_a_render_plane_is_all_boundaries(crossing_scene): + """Labels drawn on a plane have no jump in depth inside them. + + The plane is seen at a slant, so its depth changes from pixel to pixel + by more than the start; it is one flat surface and gets no band. + """ + from tests.render import _planes as planes + + controller, scene, visual = crossing_scene + plane = planes.plane_zyx( + planes.world_of(controller, scene), (24.0, 32.0, 32.0), (1.0, 0.6, 0.0) + ) + + async def _to_plane_mode() -> None: + # Inside the loop: the change schedules a reslice. + controller.set_render_planes(visual.id, [plane]) + controller.update_appearance_field(visual.id, "render_mode", "plane") + await drain_loading(controller) + + asyncio.run(_to_plane_mode()) + + expected = _draw_mode(controller, scene, visual, "inward", "all_boundaries") + assert np.any(np.all(expected[..., :3] == _rgb8(_SELECT), axis=-1)) + frame = _draw_mode( + controller, scene, visual, "inward", "depth_threshold", (0.001, 0.001) + ) + np.testing.assert_array_equal(frame, expected) + + +def test_the_canvas_pass_follows_the_ranges_of_the_visuals(crossing_scene): + """A canvas's pass has the canvas's camera and is told when to read depth. + + While no visual has a range the depth term is off, which is what keeps + the frame of every existing scene as it was. + """ + controller, scene, visual = crossing_scene + manager = controller._render_manager + canvas_view = controller.get_canvas_view(controller.get_canvas_ids(scene.id)[0]) + outline_pass = canvas_view._outline_pass + assert outline_pass.USES_DEPTH is True + assert outline_pass._get_camera() is canvas_view.camera + assert outline_pass._has_depth_edges is False + + manager.set_visual_outline( + visual.id, slot=1, kind=KIND_LABEL_ALL, depth_range=(0.01, 0.06) + ) + assert outline_pass._has_depth_edges is True + + manager.set_visual_outline(visual.id, slot=1, kind=KIND_LABEL_ALL) + assert outline_pass._has_depth_edges is False + + manager.set_visual_outline( + visual.id, slot=1, kind=KIND_LABEL_ALL, depth_range=(0.01, 0.06) + ) + manager.set_visual_outline(visual.id, slot=0) + assert outline_pass._has_depth_edges is False diff --git a/tests/render/test_outline_labels.py b/tests/render/test_outline_labels.py index 5e8c986d..bd88fd13 100644 --- a/tests/render/test_outline_labels.py +++ b/tests/render/test_outline_labels.py @@ -115,8 +115,19 @@ async def _load() -> None: return controller, scene, visual -def _render(controller, scene, *, with_target: bool = True, size=(160, 160)): - """Render the live scene through an outline pass; return (frame, renderer).""" +def _render( + controller, + scene, + *, + with_target: bool = True, + size=(160, 160), + depth_edges: bool = False, +): + """Render the live scene through an outline pass; return (frame, renderer). + + With *depth_edges* the pass is given the camera and reads the depth, as + a canvas's own pass does while a visual has a depth range. + """ from rendercanvas.offscreen import RenderCanvas canvas = RenderCanvas(size=size, pixel_ratio=1) @@ -129,17 +140,18 @@ def _render(controller, scene, *, with_target: bool = True, size=(160, 160)): install_cellier_blender(renderer, [OUTLINE_ID_TARGET]) enable_pick_texture_binding(renderer) - outline = OutlinePass(renderer, get_shared_visual_lut()) + camera = controller.get_canvas_view(controller.get_canvas_ids(scene.id)[0]).camera + outline = OutlinePass(renderer, get_shared_visual_lut(), lambda: camera) outline.apply_config(controller.render_config.outline) controller._render_manager._sync_visual_lut() outline.set_placements(has_inward=True, has_outward=False) + outline.set_depth_edges(depth_edges) renderer.effect_passes = ( outline, *(p for p in renderer.effect_passes if not isinstance(p, PPAAPass)), ) gfx_scene = controller._render_manager.get_scene(scene.id) - camera = controller.get_canvas_view(controller.get_canvas_ids(scene.id)[0]).camera errors: list[BaseException] = [] def _draw() -> None: @@ -212,6 +224,7 @@ def test_every_outline_mode_maps_to_its_own_kind(label_scene): ("per_label", KIND_LABEL), ("whole_object", KIND_WHOLE_OBJECT), ("all_boundaries", KIND_LABEL_ALL), + ("depth_threshold", KIND_LABEL_ALL), ): visual.outline_mode = mode entry = controller._render_manager.get_visual_outline(visual.id) @@ -530,3 +543,54 @@ def test_canvas_view_keeps_both_couplings_when_outlines_are_enabled( canvas = next(iter(controller._render_manager._canvases.values())) assert canvas._outline_id_available is True assert canvas._outline_available is True + + +# --------------------------------------------------------------------------- +# depth_threshold: all_boundaries, and depth bands where there is depth +# --------------------------------------------------------------------------- + + +def test_depth_threshold_in_2d_is_all_boundaries(label_scene): + """A 2D view has no jump in depth, so the mode adds nothing to it. + + Drawn with the depth term compiled in and the range in the table, at a + hard edge with a small start: the frame is that of ``all_boundaries``. + """ + controller, scene, visual = label_scene + controller.set_visual_outline(visual.id, slot=1) + visual.outline_mode = "all_boundaries" + expected, _r = _render(controller, scene) + assert _count(expected, _SLOT1) > 0 + + controller.update_visual_render_field(visual.id, "outline_mode", "depth_threshold") + controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.001, 0.001) + ) + assert controller._render_manager.get_visual_outline(visual.id)[3] > 0 + frame, _r = _render(controller, scene, depth_edges=True) + + np.testing.assert_array_equal(frame, expected) + + +def test_depth_threshold_ignores_a_stale_selection(label_scene): + """The selection is suppressed as in ``all_boundaries``, keys included.""" + controller, scene, visual = label_scene + controller.set_visual_outline(visual.id, slot=1) + visual.outline_mode = "depth_threshold" + clean = _count(_render(controller, scene)[0], _SLOT1) + assert clean > 0 + + visual.outline_mode = "per_label" + controller.set_label_selection(visual.id, {1: 2, 2: 2}) + visual.outline_mode = "depth_threshold" + + frame, renderer = _render(controller, scene) + keys = np.unique(_read_outline_id(renderer)) + non_background = keys[keys != 0] + assert len(non_background) == 3 + assert np.all(non_background >= 16), "a selection key survived the mode" + assert _count(frame, _SLOT2) == 0, "the selection colour survived the mode" + assert _count(frame, _SLOT1) == clean, "a boundary was lost to the selection" + + visual.outline_mode = "per_label" + assert _count(_render(controller, scene)[0], _SLOT2) > 0 diff --git a/tests/render/test_paint_cache_grid_layout.py b/tests/render/test_paint_cache_grid_layout.py new file mode 100644 index 00000000..2b2e9007 --- /dev/null +++ b/tests/render/test_paint_cache_grid_layout.py @@ -0,0 +1,102 @@ +"""The 2D paint cache is a grid of slots, not one column of them. + +One column made the texture ``paint_max_tiles * block_size`` pixels tall, +which the default 512 tiles pushed over the device limit for any block size +above 32. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from pygfx.renderers.wgpu.engine.shared import get_shared + +from cellier.visuals._labels import ( + MultiscaleLabelRenderConfig, + MultiscaleLabelsAppearance, +) + + +def _add_labels(controller, store, render_config): + scene = controller.add_scene(displayed_axes=("y", "x"), name="scene") + visual = controller.add_labels_multiscale( + data=store, + scene_id=scene.id, + appearance=MultiscaleLabelsAppearance(), + render_config=render_config, + ) + controller.add_canvas(scene_id=scene.id) + gfx_visual = controller._render_manager._scenes[scene.id].get_visual(visual.id) + return scene, gfx_visual + + +async def test_block_size_64_with_default_paint_max_tiles_draws( + controller, render_scene, reslice, multiscale_labels_store +): + """The default ``paint_max_tiles`` fits the device with 64-pixel tiles.""" + render_config = MultiscaleLabelRenderConfig(block_size=64) + assert render_config.paint_max_tiles == 512 + scene, gfx_visual = _add_labels(controller, multiscale_labels_store, render_config) + await reslice(controller, scene.id) + + max_dim = get_shared().device.limits["max-texture-dimension-2d"] + height, width = gfx_visual._t_paint_cache.data.shape[:2] + assert (height, width) == (23 * 64, 23 * 64) + assert max(height, width) <= max_dim + + frame = render_scene(controller, scene.id) + assert np.count_nonzero(frame[..., 3]) > 0 + + +async def test_shader_reads_a_slot_off_the_first_row_and_column( + controller, render_scene, reslice, multiscale_labels_store +): + """A tile painted into slot 3 of a 2 x 2 grid shows as one solid block.""" + scene, gfx_visual = _add_labels( + controller, + multiscale_labels_store, + MultiscaleLabelRenderConfig(block_size=8, paint_max_tiles=4), + ) + await reslice(controller, scene.id) + + def paint(rows: np.ndarray, cols: np.ndarray) -> None: + voxels = np.stack([np.zeros_like(rows), rows, cols], axis=1) + values = np.full(rows.shape[0], 5.0, dtype=np.float32) + assert gfx_visual.patch_paint_texture(voxels, values, (1, 2)) == 1 + + # One voxel in each of three tiles takes slots 0, 1 and 2. + for row, col in [(0, 0), (0, 8), (8, 0)]: + paint(np.array([row]), np.array([col])) + before = render_scene(controller, scene.id) + + # The whole of tile (1, 1) goes to slot 3: grid row 1, column 1. + rows, cols = np.meshgrid(np.arange(8, 16), np.arange(8, 16), indexing="ij") + paint(rows.ravel(), cols.ravel()) + assert gfx_visual._paint_slot_manager.get((1, 1)) == 3 + after = render_scene(controller, scene.id) + + # Opaque pixels only: the labels are drawn with a soft halo round them. + changed = np.any(before != after, axis=-1) & (after[..., 3] == 255) + # A wrong slot address would show the single voxels of slots 0..2 or + # nothing; the right one fills a solid square, half the data on a side. + # Measured by the longest run per axis: a halo pixel on a neighbouring + # label may change too, which a bounding box would count. + width = int(changed.sum(axis=1).max()) + height = int(changed.sum(axis=0).max()) + assert abs(height - width) <= 2 + assert min(height, width) > before.shape[0] // 4 + assert 0.95 * height * width <= changed.sum() <= 1.05 * height * width + + +async def test_paint_cache_over_the_device_limit_raises( + controller, multiscale_labels_store +): + """A grid too large for the device is refused with the fitting maximum.""" + max_dim = get_shared().device.limits["max-texture-dimension-2d"] + block_size = 64 + most = (max_dim // block_size) ** 2 + render_config = MultiscaleLabelRenderConfig( + block_size=block_size, paint_max_tiles=most + 1 + ) + with pytest.raises(ValueError, match=f"paint_max_tiles to at most {most}"): + _add_labels(controller, multiscale_labels_store, render_config) diff --git a/tests/render/test_paint_tile_slot_manager.py b/tests/render/test_paint_tile_slot_manager.py index 728b1406..6d47bb7b 100644 --- a/tests/render/test_paint_tile_slot_manager.py +++ b/tests/render/test_paint_tile_slot_manager.py @@ -6,6 +6,7 @@ from cellier.render.visuals._paint_tile_slot_manager import ( PaintTileSlotManager, + paint_cache_grid, ) @@ -62,3 +63,20 @@ def test_get_does_not_allocate() -> None: def test_invalid_max_slots(bad: int) -> None: with pytest.raises(ValueError): PaintTileSlotManager(max_slots=bad) + + +@pytest.mark.parametrize( + ("n_slots", "expected"), + [(1, (1, 1)), (2, (1, 2)), (4, (2, 2)), (5, (2, 3)), (7, (3, 3)), (512, (23, 23))], +) +def test_paint_cache_grid(n_slots: int, expected: tuple[int, int]) -> None: + rows, cols = paint_cache_grid(n_slots) + assert (rows, cols) == expected + # Every slot has a cell, and no whole row is spare. + assert (rows - 1) * cols < n_slots <= rows * cols + + +@pytest.mark.parametrize("bad", [0, -1]) +def test_paint_cache_grid_rejects_no_slots(bad: int) -> None: + with pytest.raises(ValueError, match="n_slots must be positive"): + paint_cache_grid(bad) diff --git a/tests/render/test_ssao.py b/tests/render/test_ssao.py index b665ed36..b766822e 100644 --- a/tests/render/test_ssao.py +++ b/tests/render/test_ssao.py @@ -736,7 +736,7 @@ def test_applying_an_outline_state_does_not_clobber_the_ao_bit(): # What a merged sync writes: both features in one byte. lut.apply({100: outline | AO_EXCLUDED_BIT, 200: AO_EXCLUDED_BIT}) - assert decode_entry(lut.get_entry(100)) == (2, KIND_WHOLE_OBJECT, 0, True) + assert decode_entry(lut.get_entry(100))[:4] == (2, KIND_WHOLE_OBJECT, 0, True) assert decode_entry(lut.get_entry(200))[3] is True # What a naive outline-only sync would write, and why it is wrong. @@ -1006,7 +1006,7 @@ def test_outlines_and_exclusions_do_not_clobber_each_other(ssao_controller): entries = lut.entries assert set(entries) >= excluded_ids for object_id in excluded_ids: - slot, kind, _placement, ao_excluded = decode_entry(entries[object_id]) + slot, kind, _placement, ao_excluded = decode_entry(entries[object_id])[:4] assert ao_excluded is True, "enabling outlines cleared the exclusion" assert slot == 3 assert kind == KIND_WHOLE_OBJECT diff --git a/tests/v2/test_per_visual_render_settings.py b/tests/v2/test_per_visual_render_settings.py index cc21fcce..f2843a61 100644 --- a/tests/v2/test_per_visual_render_settings.py +++ b/tests/v2/test_per_visual_render_settings.py @@ -32,6 +32,7 @@ InMemoryImageAppearance, InMemoryImageSingleAppearance, InMemoryLabelsAppearance, + LabelMemoryVisual, LinesMemoryAppearance, MeshFlatAppearance, PointsMarkerAppearance, @@ -613,8 +614,13 @@ def test_the_event_is_filterable_by_visual(controller, scene): def test_the_field_table_matches_what_the_seam_accepts(controller, scene): """Every field in the table is settable, and the table lists them all.""" visual = _add_mesh(controller, scene) - # The two labels-only fields are exercised in their own tests below. - covered = {*_FIELD_VALUES, "outline_selected_labels", "outline_mode"} + # The labels-only fields are exercised in their own tests. + covered = { + *_FIELD_VALUES, + "outline_selected_labels", + "outline_mode", + "outline_depth_range", + } assert covered == set(VISUAL_RENDER_FIELDS) for field, value in _FIELD_VALUES.items(): @@ -829,3 +835,190 @@ def test_the_add_warnings_fire_once(controller, scene): off_warnings = [w for w in record if "outline pass is off" in str(w.message)] assert len(off_warnings) == 1 + + +# --------------------------------------------------------------------------- +# The depth_threshold outline mode and its range +# --------------------------------------------------------------------------- + + +def _outlined_labels(controller, scene): + labels = _add_labels(controller, scene) + labels.outline.slot = 1 + return labels + + +def _depth_range_in_the_renderer(controller, visual): + """The range the render layer holds for *visual*; ``None`` when off.""" + from cellier.render._visual_lut import decode_depth_range + + return decode_depth_range( + *controller._render_manager.get_visual_outline(visual.id)[3:] + ) + + +def test_the_depth_range_defaults_and_is_only_sent_in_depth_threshold_mode( + controller, scene +): + """Every labels visual has the range; one mode sends it to the renderer. + + ``depth_threshold`` has the kind of ``all_boundaries``. What sets it + apart is the range in its table entry. + """ + from cellier.render._visual_lut import KIND_LABEL_ALL + + labels = _outlined_labels(controller, scene) + manager = controller._render_manager + assert labels.outline_depth_range == (0.01, 0.06) + + for mode in ("per_label", "whole_object", "all_boundaries"): + controller.update_visual_render_field(labels.id, "outline_mode", mode) + assert manager.get_visual_outline(labels.id)[3:] == (0, 0), mode + + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + assert labels.outline_mode == "depth_threshold" + assert manager.get_visual_outline(labels.id)[2] == KIND_LABEL_ALL + # The codes of the default range. + assert manager.get_visual_outline(labels.id)[3:] == (655, 83) + + +def test_the_depth_range_changes_at_runtime(controller, scene, seen): + """Set through the seam, it reaches the model, the renderer and the bus.""" + labels = _outlined_labels(controller, scene) + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + seen.clear() + source = uuid4() + + # A list, as a GUI or a file would hand it over. + controller.update_visual_render_field( + labels.id, "outline_depth_range", [0.02, 0.12], source_id=source + ) + + assert labels.outline_depth_range == (0.02, 0.12) + start, solid = _depth_range_in_the_renderer(controller, labels) + assert start == pytest.approx(0.02, abs=1 / 65535) + assert solid == pytest.approx(0.12, rel=0.011) + assert [(e.field_name, e.new_value, e.source_id) for e in seen] == [ + ("outline_depth_range", (0.02, 0.12), source) + ] + + +def test_the_depth_range_is_kept_across_a_switch_of_mode(controller, scene): + """Leaving the mode stops the range being sent; the model keeps it.""" + labels = _outlined_labels(controller, scene) + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + controller.update_visual_render_field( + labels.id, "outline_depth_range", (0.03, 0.03) + ) + + controller.update_visual_render_field(labels.id, "outline_mode", "all_boundaries") + assert labels.outline_depth_range == (0.03, 0.03) + assert _depth_range_in_the_renderer(controller, labels) is None + + # Changed while in another mode: stored, and still not sent. + controller.update_visual_render_field(labels.id, "outline_depth_range", (0.05, 0.2)) + assert _depth_range_in_the_renderer(controller, labels) is None + + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + start, solid = _depth_range_in_the_renderer(controller, labels) + assert start == pytest.approx(0.05, abs=1 / 65535) + assert solid == pytest.approx(0.2, rel=0.011) + + +@pytest.mark.parametrize( + "bad", + [(0.0, 0.1), (-0.01, 0.1), (1.5, 2.0), (0.06, 0.01), (0.01,), "wide", None], +) +def test_a_bad_depth_range_is_refused(controller, scene, bad): + """A start outside (0, 1], a solid value below it, or not a pair.""" + labels = _outlined_labels(controller, scene) + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + + with pytest.raises(ValueError, match="outline_depth_range"): + controller.update_visual_render_field(labels.id, "outline_depth_range", bad) + + assert labels.outline_depth_range == (0.01, 0.06) + assert controller._render_manager.get_visual_outline(labels.id)[3:] == (655, 83) + + +def test_a_bad_depth_range_is_refused_when_the_visual_is_built(controller, scene): + data = np.zeros((8, 8, 8), dtype=np.int32) + with pytest.raises(ValueError, match="outline_depth_range"): + controller.add_labels( + data=LabelMemoryStore(data=data, name="bad"), + scene_id=scene.id, + name="bad", + outline_depth_range=(0.06, 0.01), + ) + with pytest.raises(ValueError, match="outline_depth_range"): + LabelMemoryVisual( + name="bad", + data_store_id="store", + appearance=InMemoryLabelsAppearance(), + outline_depth_range=(0.06, 0.01), + ) + + +def test_an_unknown_outline_mode_is_refused_at_the_seam(controller, scene): + """It would otherwise be stored, and fail when pushed to the renderer.""" + labels = _outlined_labels(controller, scene) + with pytest.raises(ValueError, match="outline_mode"): + controller.update_visual_render_field(labels.id, "outline_mode", "silhouette") + assert labels.outline_mode == "per_label" + + +def test_the_depth_range_is_only_available_on_labels(controller, scene): + visual = _add_mesh(controller, scene) + with pytest.raises(ValueError, match="only available on labels visuals"): + controller.update_visual_render_field( + visual.id, "outline_depth_range", (0.01, 0.06) + ) + + +def test_depth_threshold_suppresses_the_label_selection(controller, scene): + """As ``all_boundaries`` does: every band takes the visual's own slot.""" + labels = _outlined_labels(controller, scene) + controller.set_label_selection(labels.id, {1: 2}) + manager = controller._render_manager + pushed = [] + original = manager.set_label_selection + manager.set_label_selection = lambda visual_id, selection: ( + pushed.append(dict(selection)), + original(visual_id, selection), + ) + + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + assert pushed[-1] == {} + assert labels.outline_selected_labels == {1: 2} + + # A selection made while in the mode is stored and still not pushed. + controller.set_label_selection(labels.id, {1: 3}) + assert pushed[-1] == {} + + controller.update_visual_render_field(labels.id, "outline_mode", "per_label") + assert pushed[-1] == {1: 3} + + +def test_the_depth_mode_and_range_round_trip_through_a_file( + controller, scene, tmp_path +): + labels = _outlined_labels(controller, scene) + controller.update_visual_render_field(labels.id, "outline_mode", "depth_threshold") + controller.update_visual_render_field( + labels.id, "outline_depth_range", (0.02, 0.12) + ) + + path = tmp_path / "viewer.json" + controller.to_file(path) + restored = CellierController.from_file(path) + try: + by_name = { + v.name: v for v in next(iter(restored._model.scenes.values())).visuals + } + assert by_name["labels"].outline_mode == "depth_threshold" + assert by_name["labels"].outline_depth_range == (0.02, 0.12) + start, solid = _depth_range_in_the_renderer(restored, by_name["labels"]) + assert start == pytest.approx(0.02, abs=1 / 65535) + assert solid == pytest.approx(0.12, rel=0.011) + finally: + restored.close() diff --git a/zensical.toml b/zensical.toml index 35fbaf0b..0c1bd4ec 100644 --- a/zensical.toml +++ b/zensical.toml @@ -61,6 +61,7 @@ nav = [ { "Clip a visual with planes" = "How_To/clipping_planes.md" }, { "Debug events" = "How_To/debug_events.md" }, { "Draw a volume on planes" = "How_To/render_planes.md" }, + { "Outline a visual" = "How_To/outlines.md" }, ] }, { "API Reference" = [ { "CellierController" = "api/controller.md" },