From 23035296dba49a944cbd3e2bca59a8b77472a8ed Mon Sep 17 00:00:00 2001 From: uermel Date: Mon, 5 Oct 2026 23:37:42 -0700 Subject: [PATCH 1/4] feat: filaments, instance segmentations and compact instance browsing Shared, viewer-independent support for the copick filament and instance/panoptic segmentation features, used by napari-copick and chimerax-copick: - core/types.py: copick object / segmentation type detection (binary, multilabel, instance, panoptic) and filament specs, tolerant of older copick versions. - util/filaments.py and util/filament_session.py: FilamentEditSession, the editing state for traced filaments (insert/move/remove control points, new, reverse, delete, convert, cut and join), all regenerated through copick core's curve API, with an undo/redo history that hosts can mirror; pick sampling along filaments and direction markers. - util/instances.py: instance colours shared by all viewers, ID sets, label counts, merge/delete, and the rows and compact titles/captions of the instance browser. - widgets/instances/instance_browser.py: InstanceBrowserWidget (step, focus, visibility, filter, sort menu, new/delete/merge/reverse, shared selection). - widgets/compact_delegate.py: two-line / inline compact rows (swatch, name, type chip, caption) with outline selection, a header sort menu and wrap-around arrow keys. - widgets/flow_layout.py: wrapping rows that keep native button heights. - Edit object types dialog keeps object metadata and edits filament specs; info widget, data worker, URI widget and CLI forms know filaments and segmentation types. Requires copick >= 2.0.0a2 (the v2.0 twins copick/copick#481, #482, #493, #495). The v2.0 counterpart of the main-branch change. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- src/copick_shared_ui/core/click_schema.py | 2 +- src/copick_shared_ui/core/types.py | 132 ++++ .../ui/edit_object_types_dialog.py | 118 +++- src/copick_shared_ui/util/filament_session.py | 567 ++++++++++++++++++ src/copick_shared_ui/util/filaments.py | 341 +++++++++++ src/copick_shared_ui/util/instances.py | 332 ++++++++++ .../widgets/cli/actions_bar.py | 17 +- .../widgets/cli/command_form.py | 15 +- .../widgets/cli/uri_widget.py | 30 +- .../widgets/compact_delegate.py | 466 ++++++++++++++ src/copick_shared_ui/widgets/flow_layout.py | 105 ++++ .../widgets/info/info_widget.py | 48 +- .../widgets/instances/__init__.py | 5 + .../widgets/instances/instance_browser.py | 490 +++++++++++++++ src/copick_shared_ui/workers/data_worker.py | 12 + tests/conftest.py | 16 + tests/test_compact_rows.py | 203 +++++++ tests/test_filament_session.py | 302 ++++++++++ tests/test_filaments.py | 131 ++++ tests/test_instances.py | 99 +++ tests/test_package_metadata.py | 2 +- tests/test_types.py | 67 +++ tests/test_uri_and_dialog.py | 110 ++++ 24 files changed, 3572 insertions(+), 40 deletions(-) create mode 100644 src/copick_shared_ui/core/types.py create mode 100644 src/copick_shared_ui/util/filament_session.py create mode 100644 src/copick_shared_ui/util/filaments.py create mode 100644 src/copick_shared_ui/util/instances.py create mode 100644 src/copick_shared_ui/widgets/compact_delegate.py create mode 100644 src/copick_shared_ui/widgets/flow_layout.py create mode 100644 src/copick_shared_ui/widgets/instances/__init__.py create mode 100644 src/copick_shared_ui/widgets/instances/instance_browser.py create mode 100644 tests/test_compact_rows.py create mode 100644 tests/test_filament_session.py create mode 100644 tests/test_filaments.py create mode 100644 tests/test_instances.py create mode 100644 tests/test_types.py create mode 100644 tests/test_uri_and_dialog.py diff --git a/pyproject.toml b/pyproject.toml index cc109a1..508eb1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dynamic = ["version"] dependencies = [ "qtpy", "pydantic>=2", - "copick>=2.0.0a1,<3", + "copick>=2.0.0a2,<3", "zarr>=3.1.6,<4", "numpy>=2.0.2", "superqt" diff --git a/src/copick_shared_ui/core/click_schema.py b/src/copick_shared_ui/core/click_schema.py index ae61230..f91aa2a 100644 --- a/src/copick_shared_ui/core/click_schema.py +++ b/src/copick_shared_ui/core/click_schema.py @@ -40,7 +40,7 @@ class URIParamMeta: """Metadata for a URI parameter, extracted from CopickURI type.""" - object_type: str # "picks", "mesh", "segmentation", "tomogram", "feature", "any" + object_type: str # "picks", "filaments", "mesh", "segmentation", "tomogram", "feature", "any" role: str # "input", "output", "reference" diff --git a/src/copick_shared_ui/core/types.py b/src/copick_shared_ui/core/types.py new file mode 100644 index 0000000..29397f8 --- /dev/null +++ b/src/copick_shared_ui/core/types.py @@ -0,0 +1,132 @@ +"""Copick entity types as the UI sees them, tolerant of copick versions that predate filaments and the newer +segmentation types. Qt-free.""" + +from typing import Any, Optional + +SEGMENTATION_TYPES = ("binary", "multilabel", "instance", "panoptic") + +SEGMENTATION_TYPE_LABELS = { + "binary": "Binary", + "multilabel": "Multilabel", + "instance": "Instance", + "panoptic": "Panoptic", +} + +# Short tags for compact tables and badges (the chips copick-web shows). +SEGMENTATION_TYPE_TAGS = { + "binary": "binary", + "multilabel": "multi", + "instance": "inst", + "panoptic": "pan", +} + +FILAMENT_METADATA_NAMESPACE = "copick" +FILAMENT_METADATA_KEY = "filament" + + +def _class_names(obj: Any): + return [cls.__name__ for cls in type(obj).__mro__] + + +def copick_object_type(obj: Any) -> Optional[str]: + """``"filaments"``, ``"picks"``, ``"mesh"``, ``"segmentation"``, ``"tomogram"``, ``"feature"``, ``"voxel_spacing"``, + ``"run"`` or None, by class (works for every backend's subclasses).""" + names = _class_names(obj) + # Order matters: check the more specific names first. + for cls_name, kind in ( + ("CopickFilaments", "filaments"), + ("CopickPicks", "picks"), + ("CopickMesh", "mesh"), + ("CopickSegmentation", "segmentation"), + ("CopickTomogram", "tomogram"), + ("CopickFeatures", "feature"), + ("CopickVoxelSpacing", "voxel_spacing"), + ("CopickRun", "run"), + ): + if cls_name in names: + return kind + return None + + +URI_OBJECT_TYPES = ("picks", "filaments", "mesh", "segmentation", "tomogram", "feature") + + +def uri_object_type(obj: Any) -> Optional[str]: + """The copick URI object type of an entity (``copick.util.uri``), or None if it has no URI form.""" + if obj is None: + return None + kind = copick_object_type(obj) + return kind if kind in URI_OBJECT_TYPES else None + + +def segmentation_type_of(seg: Any) -> str: + """``"binary"``, ``"multilabel"``, ``"instance"`` or ``"panoptic"`` (falls back to ``is_multilabel`` for copick + versions without instance and panoptic segmentations).""" + kind = getattr(seg, "segmentation_type", None) + if isinstance(kind, str) and kind in SEGMENTATION_TYPES: + return kind + if getattr(seg, "is_panoptic", False) is True: + return "panoptic" + if getattr(seg, "is_instance", False) is True: + return "instance" + return "multilabel" if getattr(seg, "is_multilabel", False) else "binary" + + +def segmentation_type_flags(kind: str, explicit: Optional[bool] = None) -> dict: + """Keyword flags for ``new_segmentation`` / ``get_segmentations`` / ``delete_segmentations`` selecting exactly one + type. + + With a copick that has instance and panoptic segmentations (``explicit``, detected by default), all three flags are + given, because an omitted flag means "any": ``delete_segmentations(..., is_multilabel=False)`` alone would also + delete an instance segmentation with the same name, user, session and voxel size. Older copick versions only get + ``is_multilabel``. + """ + if kind not in SEGMENTATION_TYPES: + raise ValueError(f"Unknown segmentation type {kind!r}") + explicit = supports_segmentation_types() if explicit is None else explicit + if not explicit: + if kind in ("instance", "panoptic"): + raise ValueError(f"This copick version has no {kind} segmentations.") + return {"is_multilabel": kind == "multilabel"} + return {"is_multilabel": kind == "multilabel", "is_instance": kind == "instance", "is_panoptic": kind == "panoptic"} + + +def filament_spec_of(obj: Any) -> Optional[dict]: + """The filament declaration of a pickable object (``PickableObject`` or ``CopickObject``) as a dict, or None.""" + spec = getattr(obj, "filament", None) + if spec is not None and not callable(spec): + return spec.model_dump(exclude_none=True) if hasattr(spec, "model_dump") else dict(spec) + meta = getattr(obj, "metadata", None) + if meta is None and hasattr(obj, "_meta"): + meta = getattr(obj._meta, "metadata", None) + if isinstance(meta, dict): + ns = meta.get(FILAMENT_METADATA_NAMESPACE) + if isinstance(ns, dict) and isinstance(ns.get(FILAMENT_METADATA_KEY), dict): + return dict(ns[FILAMENT_METADATA_KEY]) + return None + + +def is_filament_object(obj: Any) -> bool: + """Whether a pickable object is declared a filament.""" + value = getattr(obj, "is_filament", None) + if isinstance(value, bool): + return value + return filament_spec_of(obj) is not None + + +def supports_filaments() -> bool: + """Whether the installed copick has the Filaments entity.""" + try: + from copick.models import CopickRun + except ImportError: # pragma: no cover + return False + return hasattr(CopickRun, "get_filaments") + + +def supports_segmentation_types() -> bool: + """Whether the installed copick has instance and panoptic segmentations.""" + try: + from copick.models import CopickSegmentation + except ImportError: # pragma: no cover + return False + return hasattr(CopickSegmentation, "segmentation_type") diff --git a/src/copick_shared_ui/ui/edit_object_types_dialog.py b/src/copick_shared_ui/ui/edit_object_types_dialog.py index 3b58b49..c594011 100644 --- a/src/copick_shared_ui/ui/edit_object_types_dialog.py +++ b/src/copick_shared_ui/ui/edit_object_types_dialog.py @@ -2,7 +2,8 @@ Dialog for editing and managing PickableObject types in the copick configuration """ -from typing import List, Tuple +import copy +from typing import List, Optional, Tuple from copick.models import PickableObject from qtpy.QtCore import Qt @@ -10,6 +11,7 @@ from qtpy.QtWidgets import ( QCheckBox, QColorDialog, + QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, @@ -29,8 +31,11 @@ QWidget, ) +from copick_shared_ui.core.types import FILAMENT_METADATA_KEY, FILAMENT_METADATA_NAMESPACE, filament_spec_of from copick_shared_ui.util.validation import validate_copick_name +POLARITY_CHOICES = [("Not stated", None), ("Polar", True), ("Apolar", False)] + class ColorButton(QPushButton): """Button that displays and allows selection of a color""" @@ -261,6 +266,15 @@ def _create_object_form(self): ) left_layout.addRow("Type:", self._is_particle_cb) + # Is filament checkbox (a filament is a particle annotated with ordered points along its axis) + self._is_filament_cb = QCheckBox("Is Filament") + self._is_filament_cb.setChecked(False) + self._is_filament_cb.setToolTip( + "Check for continuous assemblies (e.g. microtubules, actin) annotated with ordered points along their " + "axis.\nPicks then carry the filament ID as instance ID. Requires 'Is Particle'.", + ) + left_layout.addRow("", self._is_filament_cb) + # Label (numeric ID) self._label_spin = QSpinBox() self._label_spin.setRange(1, 9999) @@ -346,9 +360,39 @@ def _create_object_form(self): right_group.setLayout(right_layout) + # Filament properties (stored as metadata["copick"]["filament"]) + self._filament_group = QGroupBox("Filament Properties") + filament_layout = QFormLayout() + + self._polarity_combo = QComboBox() + for text, _value in POLARITY_CHOICES: + self._polarity_combo.addItem(text) + self._polarity_combo.setToolTip("Whether the structure has a polarity (microtubules and actin are polar)") + filament_layout.addRow("Polarity:", self._polarity_combo) + + self._rise_spin = QDoubleSpinBox() + self._rise_spin.setRange(0.0, 10000.0) + self._rise_spin.setDecimals(2) + self._rise_spin.setValue(0.0) + self._rise_spin.setSpecialValueText("Not set") + self._rise_spin.setToolTip("Axial rise per subunit (descriptive only; never used as a sampling distance)") + filament_layout.addRow("Helical rise (Å):", self._rise_spin) + + self._twist_spin = QDoubleSpinBox() + self._twist_spin.setRange(-361.0, 360.0) + self._twist_spin.setDecimals(2) + self._twist_spin.setValue(-361.0) + self._twist_spin.setSpecialValueText("Not set") + self._twist_spin.setToolTip("Twist per subunit (descriptive only)") + filament_layout.addRow("Helical twist (°):", self._twist_spin) + + self._filament_group.setLayout(filament_layout) + self._filament_group.setEnabled(False) + # Add groups to grid grid_layout.addWidget(left_group, 0, 0) grid_layout.addWidget(right_group, 0, 1) + grid_layout.addWidget(self._filament_group, 1, 0, 1, 2) # Form action buttons form_buttons_layout = QHBoxLayout() @@ -393,6 +437,18 @@ def _connect_signals(self): self._name_edit.textChanged.connect(self._validate_form) self._label_spin.valueChanged.connect(self._validate_form) + # A filament must be a particle + self._is_particle_cb.toggled.connect(self._on_particle_toggled) + self._is_filament_cb.toggled.connect(self._on_filament_toggled) + + def _on_particle_toggled(self, checked: bool): + self._is_filament_cb.setEnabled(checked) + if not checked: + self._is_filament_cb.setChecked(False) + + def _on_filament_toggled(self, checked: bool): + self._filament_group.setEnabled(checked) + def _populate_objects_table(self): """Populate the objects table""" self._objects_table.setRowCount(len(self._existing_objects)) @@ -404,7 +460,10 @@ def _populate_objects_table(self): self._objects_table.setItem(row, 0, name_item) # Type - type_text = "Particle" if obj.is_particle else "Segmentation" + if filament_spec_of(obj) is not None: + type_text = "Filament" + else: + type_text = "Particle" if obj.is_particle else "Segmentation" type_item = QTableWidgetItem(type_text) type_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) self._objects_table.setItem(row, 1, type_item) @@ -626,6 +685,11 @@ def _reset_form(self, keep_editing_mode=False): self._identifier_edit.clear() self._threshold_spin.setValue(self._threshold_spin.minimum()) self._radius_spin.setValue(self._radius_spin.minimum()) + self._is_filament_cb.setEnabled(True) + self._is_filament_cb.setChecked(False) + self._polarity_combo.setCurrentIndex(0) + self._rise_spin.setValue(self._rise_spin.minimum()) + self._twist_spin.setValue(self._twist_spin.minimum()) # Hide validation messages self._name_validation.hide() @@ -658,6 +722,31 @@ def _populate_form_from_object(self, obj: PickableObject): else: self._radius_spin.setValue(self._radius_spin.minimum()) + spec = filament_spec_of(obj) + self._is_filament_cb.setChecked(spec is not None) + spec = spec or {} + polarity = spec.get("polar") + self._polarity_combo.setCurrentIndex(next(i for i, (_t, v) in enumerate(POLARITY_CHOICES) if v == polarity)) + rise = spec.get("helical_rise_a") + self._rise_spin.setValue(rise if rise is not None else self._rise_spin.minimum()) + twist = spec.get("helical_twist_deg") + self._twist_spin.setValue(twist if twist is not None else self._twist_spin.minimum()) + + def _filament_spec_from_form(self, previous: Optional[dict]) -> Optional[dict]: + """The filament spec in the form, keeping any extra keys of the previous spec; None if not a filament.""" + if not (self._is_particle_cb.isChecked() and self._is_filament_cb.isChecked()): + return None + spec = dict(previous or {}) + polar = POLARITY_CHOICES[self._polarity_combo.currentIndex()][1] + rise = self._rise_spin.value() if self._rise_spin.value() != self._rise_spin.minimum() else None + twist = self._twist_spin.value() if self._twist_spin.value() != self._twist_spin.minimum() else None + for key, value in (("polar", polar), ("helical_rise_a", rise), ("helical_twist_deg", twist)): + if value is None: + spec.pop(key, None) + else: + spec[key] = value + return spec + def _populate_initial_data(self): """Populate initial data for new object""" existing_labels = {obj.label for obj in self._existing_objects if obj.label is not None} @@ -686,7 +775,10 @@ def _create_object_from_form(self) -> PickableObject: if self._radius_spin.value() != self._radius_spin.minimum(): radius = self._radius_spin.value() - return PickableObject( + # Start from the edited object so fields the form does not show (metadata, and anything newer copick + # versions add) survive the edit. + data = copy.deepcopy(self._selected_object.model_dump()) if self._selected_object is not None else {} + data.update( name=name, is_particle=is_particle, label=label, @@ -698,6 +790,25 @@ def _create_object_from_form(self) -> PickableObject: radius=radius, ) + # Filament declaration: metadata["copick"]["filament"] + previous = filament_spec_of(self._selected_object) if self._selected_object is not None else None + spec = self._filament_spec_from_form(previous) + metadata = dict(data.get("metadata") or {}) + namespace = metadata.get(FILAMENT_METADATA_NAMESPACE) + namespace = dict(namespace) if isinstance(namespace, dict) else {} + if spec is None: + namespace.pop(FILAMENT_METADATA_KEY, None) + else: + namespace[FILAMENT_METADATA_KEY] = spec + if namespace: + metadata[FILAMENT_METADATA_NAMESPACE] = namespace + else: + metadata.pop(FILAMENT_METADATA_NAMESPACE, None) + if metadata or "metadata" in data: + data["metadata"] = metadata + + return PickableObject(**data) + def _validate_form(self): """Validate the current form state""" name_text = self._name_edit.text().strip() @@ -794,6 +905,7 @@ def has_changes(self) -> bool: or current.identifier != original.identifier or current.map_threshold != original.map_threshold or current.radius != original.radius + or getattr(current, "metadata", None) != getattr(original, "metadata", None) ): return True diff --git a/src/copick_shared_ui/util/filament_session.py b/src/copick_shared_ui/util/filament_session.py new file mode 100644 index 0000000..f33b6d2 --- /dev/null +++ b/src/copick_shared_ui/util/filament_session.py @@ -0,0 +1,567 @@ +"""Editing state for one set of traced filaments (one object / user / session in one run), shared by the napari and +ChimeraX tracers. Qt-free. + +The session holds ``CopickFilament`` models; every edit goes through copick core (``from_control_points``, +``with_control_points``, ``reversed``), so ``points`` are always regenerated from the curve by the normative algorithm +and saving never stores a stale curve. Viewers only translate clicks into control-point edits and redraw from +``polylines()`` / ``controls()``. + +Every edit is one step of an undo/redo history (``undo()`` / ``redo()``). Edits that belong together, such as the +moves of one drag, are grouped with ``begin_step()`` / ``end_step()`` or ``with session.history_step(...)``. Viewers that have +their own undo stack (ChimeraX) set ``on_history`` to mirror each new step there. +""" + +from contextlib import contextmanager +from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple + +import numpy as np + +from copick_shared_ui.util.filaments import ( + editable_handles, + filaments_to_picks, + insert_control_point, + nearest_on_polyline, + split_control_points, + split_polyline, +) + +INSERT_MODES = ("append", "prepend", "nearest") +#: How the insert modes read in the UI ("New points: at the end"). +INSERT_MODE_LABELS = {"append": "at the end", "prepend": "at the start", "nearest": "between nearest"} + +#: Undo steps kept per session. +MAX_HISTORY = 200 + +#: Restorable editing state: (filaments, pending control points, active ID). +_State = Tuple[Dict[int, Any], Dict[int, np.ndarray], int] + + +class FilamentEditSession: + """Traced filaments being edited. + + Args: + run: The copick run. + object_name: Pickable object (should be a filament object). + user_id, session_id: Identity of the filament set. + step: Arc-length spacing of the regenerated points, in Angstrom (usually the tomogram voxel spacing). + source: The ``CopickFilaments`` the session was opened from, if any. + """ + + def __init__( + self, + run: Any, + object_name: str, + user_id: str, + session_id: str, + step: float, + source: Any = None, + ): + if step <= 0: + raise ValueError("step must be positive") + self.run = run + self.object_name = object_name + self.user_id = user_id + self.session_id = session_id + self.step = float(step) + self.source = source + self.filaments: Dict[int, Any] = {} # instance_id -> CopickFilament (>= 2 control points) + self.pending: Dict[int, np.ndarray] = {} # instance_id -> control points of a filament with < 2 points + self._handles: Dict[int, Tuple[np.ndarray, str, bool]] = {} + self.active_id: int = 1 + self.insert_mode: str = "append" + self.dirty = False + # Undo history: (label, state before the step) / (label, state after undoing it). + self._undo: List[Tuple[str, _State]] = [] + self._redo: List[Tuple[str, _State]] = [] + self._step_depth = 0 + self._step_label = "" + self._step_state: Optional[_State] = None + self._step_changed = False + #: Called with the step's label whenever a new undo step is recorded. + self.on_history: Optional[Callable[[str], None]] = None + if source is not None: + for f in source.filaments: + self.filaments[int(f.instance_id)] = f + self.active_id = self.next_id() if not self.filaments else min(self.filaments) + + # ------------------------------------------------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------------------------------------------------ + + @classmethod + def from_filaments(cls, copick_filaments: Any, step: Optional[float] = None) -> "FilamentEditSession": + """Open an existing ``CopickFilaments`` for editing (step defaults to the file's voxel spacing).""" + step = step or copick_filaments.voxel_spacing + if not step: + raise ValueError("The filament set records no voxel spacing; pass step.") + return cls( + run=copick_filaments.run, + object_name=copick_filaments.pickable_object_name, + user_id=copick_filaments.user_id, + session_id=copick_filaments.session_id, + step=step, + source=copick_filaments, + ) + + @property + def read_only(self) -> bool: + """Tool output (session 0) and read-only backends are not edited in place.""" + if str(self.session_id) == "0": + return True + return bool(getattr(self.source, "read_only", False)) + + @property + def object(self) -> Any: + return self.run.root.get_object(self.object_name) + + @property + def radius(self) -> Optional[float]: + obj = self.object + return getattr(obj, "radius", None) if obj is not None else None + + # ------------------------------------------------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------------------------------------------------ + + def ids(self) -> List[int]: + return sorted(set(self.filaments) | set(self.pending)) + + def next_id(self) -> int: + ids = self.ids() + return (max(ids) + 1) if ids else 1 + + def controls(self, instance_id: int) -> np.ndarray: + """(n, 3) control points of one filament (Angstrom, [x, y, z]).""" + if instance_id in self.pending: + return self.pending[instance_id].copy() + if instance_id not in self.filaments: + return np.zeros((0, 3)) + return self._handle(instance_id)[0].copy() + + def kind(self, instance_id: int) -> Optional[str]: + if instance_id in self.pending: + return "catmull-rom" + if instance_id not in self.filaments: + return None + return self._handle(instance_id)[1] + + def can_add_remove(self, instance_id: int) -> bool: + """False for a B-spline (its knots fix the number of control points).""" + if instance_id not in self.filaments: + return True + return self._handle(instance_id)[2] + + def polylines(self) -> Dict[int, np.ndarray]: + """Regenerated centrelines, ``{id: (M, 3)}`` in Angstrom.""" + return {i: np.asarray(f.points, dtype=float) for i, f in sorted(self.filaments.items())} + + def all_controls(self) -> Dict[int, np.ndarray]: + return {i: self.controls(i) for i in self.ids()} + + def _handle(self, instance_id: int) -> Tuple[np.ndarray, str, bool]: + if instance_id not in self._handles: + self._handles[instance_id] = editable_handles(self.filaments[instance_id]) + return self._handles[instance_id] + + # ------------------------------------------------------------------------------------------------------------ + # Undo / redo + # ------------------------------------------------------------------------------------------------------------ + + def _state(self) -> _State: + # CopickFilament models are replaced, never mutated, so a shallow copy of the dict is enough. + return dict(self.filaments), {i: cps.copy() for i, cps in self.pending.items()}, self.active_id + + def _restore(self, state: _State) -> None: + filaments, pending, active_id = state + self.filaments = dict(filaments) + self.pending = {i: cps.copy() for i, cps in pending.items()} + self.active_id = active_id + self._handles.clear() + self.dirty = True + + def _changed(self) -> None: + self.dirty = True + self._step_changed = True + + def begin_step(self, label: str) -> None: + """Start an undo step; edits until the matching ``end_step()`` undo together. Steps nest into the outermost.""" + if self._step_depth == 0: + self._step_label = label + self._step_state = self._state() + self._step_changed = False + self._step_depth += 1 + + def end_step(self) -> None: + """Finish an undo step; it is recorded only if something changed.""" + if self._step_depth == 0: + return + self._step_depth -= 1 + if self._step_depth or not self._step_changed or self._step_state is None: + return + self._undo.append((self._step_label, self._step_state)) + del self._undo[:-MAX_HISTORY] + self._redo.clear() + self._step_state = None + if self.on_history is not None: + self.on_history(self._step_label) + + @contextmanager + def history_step(self, label: str) -> Iterator[None]: + """``with session.history_step("Move point"): ...`` groups the edits inside into one undo step.""" + self.begin_step(label) + try: + yield + finally: + self.end_step() + + @property + def can_undo(self) -> bool: + return bool(self._undo) + + @property + def can_redo(self) -> bool: + return bool(self._redo) + + def undo(self) -> Optional[str]: + """Undo the last step; returns its label, or None if there is nothing to undo.""" + if not self._undo: + return None + label, state = self._undo.pop() + self._redo.append((label, self._state())) + self._restore(state) + return label + + def redo(self) -> Optional[str]: + """Redo the last undone step; returns its label, or None if there is nothing to redo.""" + if not self._redo: + return None + label, state = self._redo.pop() + self._undo.append((label, self._state())) + self._restore(state) + return label + + # ------------------------------------------------------------------------------------------------------------ + # Edits (each regenerates the affected filament through copick core and is one undo step) + # ------------------------------------------------------------------------------------------------------------ + + def set_controls(self, instance_id: int, control_points: Sequence[Sequence[float]]) -> None: + """Replace the control points of one filament (creating it if new). Fewer than two points keep it pending.""" + cps = np.asarray(control_points, dtype=float).reshape(-1, 3) + instance_id = int(instance_id) + with self.history_step("Edit filament"): + self._set_controls(instance_id, cps) + + def _set_controls(self, instance_id: int, cps: np.ndarray) -> None: + from copick.models import CopickFilament + + self._handles.pop(instance_id, None) + if len(cps) == 0: + self.filaments.pop(instance_id, None) + self.pending.pop(instance_id, None) + elif len(cps) < 2: + self.filaments.pop(instance_id, None) + self.pending[instance_id] = cps + elif instance_id in self.filaments: + # keep the curve's own step (and kind); new filaments use the session step + self.filaments[instance_id] = self.filaments[instance_id].with_control_points(cps) + else: + self.pending.pop(instance_id, None) + self.filaments[instance_id] = CopickFilament.from_control_points( + instance_id, + cps, + step=self.step, + radius=self.radius, + ) + self._changed() + + def insert_point( + self, + point: Sequence[float], + instance_id: Optional[int] = None, + mode: Optional[str] = None, + ) -> int: + """Insert a control point into a filament (default: the active one, by the session's insert mode). + + Returns: + The index of the new point within the filament's control points. + + Raises: + ValueError: If the filament's curve is a B-spline (fixed number of points). + """ + instance_id = self.active_id if instance_id is None else int(instance_id) + if not self.can_add_remove(instance_id): + raise ValueError( + f"Filament {instance_id} is a B-spline fit; convert it to Catmull-Rom to add or remove points.", + ) + cps, index = insert_control_point(self.controls(instance_id), point, mode or self.insert_mode) + with self.history_step("Add filament point"): + self.set_controls(instance_id, cps) + return index + + def move_point(self, instance_id: int, index: int, point: Sequence[float]) -> None: + cps = self.controls(instance_id) + cps[index] = np.asarray(point, dtype=float) + with self.history_step("Move filament point"): + self.set_controls(instance_id, cps) + + def remove_point(self, instance_id: int, index: int) -> None: + if not self.can_add_remove(instance_id): + raise ValueError( + f"Filament {instance_id} is a B-spline fit; convert it to Catmull-Rom to add or remove points.", + ) + cps = self.controls(instance_id) + with self.history_step("Delete filament point"): + self.set_controls(instance_id, np.delete(cps, index, axis=0)) + + def new_filament(self) -> int: + """Start a new (empty) filament and make it active.""" + next_id = self.next_id() + if next_id != self.active_id: # already on an empty new filament: nothing to undo + with self.history_step("New filament"): + self.active_id = next_id + self._step_changed = True + return self.active_id + + def delete_filament(self, instance_id: int) -> None: + if int(instance_id) not in self.filaments and int(instance_id) not in self.pending: + return + with self.history_step("Delete filament"): + before = self.ids() + self.filaments.pop(int(instance_id), None) + self.pending.pop(int(instance_id), None) + self._handles.pop(int(instance_id), None) + if self.active_id == instance_id: # stay in place: the next filament, or the previous at the end + ids = self.ids() + k = before.index(int(instance_id)) if int(instance_id) in before else 0 + self.active_id = ids[min(k, len(ids) - 1)] if ids else 1 + self._changed() + + def reverse(self, instance_id: int) -> None: + instance_id = int(instance_id) + with self.history_step("Reverse filament"): + if instance_id in self.filaments: + self.filaments[instance_id] = self.filaments[instance_id].reversed() + elif instance_id in self.pending: + self.pending[instance_id] = self.pending[instance_id][::-1].copy() + else: + return + self._handles.pop(instance_id, None) + self._changed() + + def convert_to_catmull_rom(self, instance_id: int) -> None: + """Make a B-spline (or any curve) editable with add/remove: derive Catmull-Rom handles from its points.""" + from copick.models import CopickFilament + + f = self.filaments[int(instance_id)] + curve = f.editable_curve(kinds=("catmull-rom",)) + fields = f.model_dump(exclude={"instance_id", "points", "curve"}) + with self.history_step("Convert filament to Catmull-Rom"): + self.filaments[int(instance_id)] = CopickFilament.from_curve(int(instance_id), curve, **fields) + self._handles.pop(int(instance_id), None) + self._changed() + + def nearest_filament(self, point: Sequence[float]) -> Optional[Tuple[int, float]]: + """The filament whose centreline passes closest to ``point``: ``(instance_id, distance)``, or None.""" + best = None + for instance_id, f in self.filaments.items(): + line = np.asarray(f.points, dtype=float) + if len(line) < 2: + continue + dist = nearest_on_polyline(line, point)[2] + if best is None or dist < best[1]: + best = (instance_id, dist) + return best + + def cut( + self, + point: Sequence[float], + instance_id: Optional[int] = None, + tolerance: Optional[float] = None, + ) -> Tuple[int, int]: + """Cut a filament in two where its centreline passes closest to ``point`` (one undo step). + + The first piece keeps the filament's ID, the second gets the next free ID; both keep the filament's other + fields (polarity, score, radius, metadata). Catmull-Rom and linear filaments are split at their control points + with the cut point added to both pieces, so their handles stay where they were. Other curves (B-spline fits) + are split along their centreline and get Catmull-Rom handles derived from each piece. + + Args: + point: Where to cut (Angstrom). + instance_id: The filament to cut; default the one passing closest to ``point``. + tolerance: Largest distance of ``point`` from the centreline. + + Returns: + The IDs of the two pieces, in order along the original filament. + + Raises: + ValueError: If no filament is close enough, or the cut is too close to an end. + """ + from copick.models import CopickFilament + from copick.util.filaments import control_points_from_polyline + + if instance_id is None: + nearest = self.nearest_filament(point) + if nearest is None: + raise ValueError("There is no filament to cut.") + instance_id = nearest[0] + instance_id = int(instance_id) + if instance_id not in self.filaments: + raise ValueError(f"Filament {instance_id} has fewer than two control points; there is nothing to cut.") + f = self.filaments[instance_id] + line = np.asarray(f.points, dtype=float) + distance = nearest_on_polyline(line, point)[2] + if tolerance is not None and distance > tolerance: + raise ValueError(f"No filament within {tolerance:g} Å of the click.") + curve = f.curve if f.curve_is_current() else None + step = float(curve.step) if curve is not None else self.step + min_gap = step + if self.can_add_remove(instance_id): + kind = self.kind(instance_id) if self.kind(instance_id) in ("catmull-rom", "linear") else "catmull-rom" + pieces = split_control_points(self.controls(instance_id), line, point, min_gap) + else: + kind = "catmull-rom" + pieces = tuple( + control_points_from_polyline(piece, tolerance=step / 2) + for piece in split_polyline(line, point, min_gap) + ) + alpha = curve.alpha if curve is not None and curve.alpha is not None else 0.5 + fields = f.model_dump(exclude={"instance_id", "points", "curve"}) + new_id = self.next_id() + if new_id == self.active_id: # the active filament is a new, still empty one: keep its ID for it + new_id += 1 + with self.history_step("Cut filament"): + for piece_id, cps in zip((instance_id, new_id), pieces): + self.filaments[piece_id] = CopickFilament.from_control_points( + piece_id, + cps, + step=step, + kind=kind, + alpha=alpha, + **fields, + ) + self._handles.pop(piece_id, None) + self.pending.pop(new_id, None) + self._changed() + return instance_id, new_id + + def _join_handles(self, instance_id: int) -> np.ndarray: + """Control points to join with: the filament's own (Catmull-Rom, linear) or Catmull-Rom handles derived from + it (B-spline fits, filaments without a curve).""" + if self.kind(instance_id) in ("catmull-rom", "linear"): + return self.controls(instance_id) + curve = self.filaments[instance_id].editable_curve(kinds=("catmull-rom",)) + return np.asarray(curve.control_points, dtype=float) + + def join(self, instance_ids: Sequence[int], target: Optional[int] = None) -> int: + """Join filaments end to end into one (one undo step). + + Starting from ``target`` (default: the first of ``instance_ids``), the filament whose end is nearest to either + end of the chain is attached there, reversed if needed, until all are joined; the target keeps its direction, + ID and fields (polarity, score, radius, metadata), the others are removed. The joined curve is Catmull-Rom + (linear if every part is linear) through all control points. + + Returns: + The ID of the joined filament. + + Raises: + ValueError: If fewer than two of ``instance_ids`` are complete filaments. + """ + from copick.models import CopickFilament + + ids = [i for i in dict.fromkeys(int(i) for i in instance_ids) if i in self.filaments] + if len(ids) < 2: + raise ValueError("Select at least two filaments to join.") + target = int(target) if target is not None and int(target) in ids else ids[0] + kinds = {self.kind(i) for i in ids} + kind = "linear" if kinds == {"linear"} else "catmull-rom" + f = self.filaments[target] + curve = f.curve if f.curve_is_current() else None + step = float(curve.step) if curve is not None else self.step + alpha = curve.alpha if curve is not None and curve.alpha is not None else 0.5 + chain = self._join_handles(target) + remaining = {i: self._join_handles(i) for i in ids if i != target} + while remaining: + # (distance, id, attach at the end?, reverse the part?) + options = [] + for i, cps in remaining.items(): + options += [ + (np.linalg.norm(chain[-1] - cps[0]), i, True, False), + (np.linalg.norm(chain[-1] - cps[-1]), i, True, True), + (np.linalg.norm(chain[0] - cps[-1]), i, False, False), + (np.linalg.norm(chain[0] - cps[0]), i, False, True), + ] + dist, i, at_end, flip = min(options, key=lambda o: o[0]) + part = remaining.pop(i)[::-1] if flip else remaining.pop(i) + if at_end: + if dist < step / 2: # touching ends: keep one point + part = part[1:] + chain = np.vstack([chain, part]) + else: + if dist < step / 2: + part = part[:-1] + chain = np.vstack([part, chain]) + fields = f.model_dump(exclude={"instance_id", "points", "curve"}) + with self.history_step("Join filaments"): + for i in ids: + if i != target: + self.filaments.pop(i, None) + self.pending.pop(i, None) + self._handles.pop(i, None) + self.filaments[target] = CopickFilament.from_control_points( + target, + chain, + step=step, + kind=kind, + alpha=alpha, + **fields, + ) + self._handles.pop(target, None) + self.active_id = target + self._changed() + return target + + def set_polarity_known(self, instance_id: int, known: bool) -> None: + f = self.filaments[int(instance_id)] + with self.history_step("Set filament polarity"): + self.filaments[int(instance_id)] = f.model_copy(update={"polarity_known": bool(known)}) + self._changed() + + # ------------------------------------------------------------------------------------------------------------ + # Saving + # ------------------------------------------------------------------------------------------------------------ + + def to_list(self) -> List[Any]: + """The complete filaments (pending ones with < 2 points are left out), in ID order.""" + return [self.filaments[i] for i in sorted(self.filaments)] + + def save( + self, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + voxel_spacing: Optional[float] = None, + pick_spacing: Optional[float] = None, + exist_ok: bool = True, + ) -> Dict[str, Any]: + """Store the filaments (and optionally picks sampled along them, which replace the whole picks set of the same + object / user / session). + + Returns: + ``{"filaments": CopickFilaments, "picks": CopickPicks or None, "n_filaments": int, "n_picks": int}``. + """ + user_id = user_id or self.user_id + session_id = session_id or self.session_id + filaments = self.to_list() + target = self.run.new_filaments(self.object_name, session_id=session_id, user_id=user_id, exist_ok=exist_ok) + target.filaments = filaments + target.meta.voxel_spacing = voxel_spacing if voxel_spacing is not None else self.step + target.store() + result = {"filaments": target, "picks": None, "n_filaments": len(filaments), "n_picks": 0} + if pick_spacing: + positions, transforms, ids, scores = filaments_to_picks(filaments, pick_spacing) + picks = self.run.new_picks(self.object_name, session_id=session_id, user_id=user_id, exist_ok=True) + picks.from_numpy(positions, transforms, instance_ids=ids, scores=scores) + result["picks"] = picks + result["n_picks"] = len(positions) + self.user_id, self.session_id, self.source = user_id, session_id, target + self.dirty = False + return result diff --git a/src/copick_shared_ui/util/filaments.py b/src/copick_shared_ui/util/filaments.py new file mode 100644 index 0000000..3b4497b --- /dev/null +++ b/src/copick_shared_ui/util/filaments.py @@ -0,0 +1,341 @@ +"""Filament helpers for the viewers: sampling picks along centrelines, tangent frames, and control-point editing. + +Curves themselves (evaluation, regeneration of ``points``, derived handles, reversal, the stale-curve check) come from +copick core (``CopickFilament.from_control_points`` / ``with_control_points`` / ``editable_curve`` / ``reversed`` and +``copick.util.filaments``); nothing here re-implements them. Qt-free, numpy only. + +Conventions (copick ``geometry.md`` 2.4): picks of a filament object are grouped by filament, ordered along it, carry +the filament's ``instance_id``, and their transform's +Z axis is the local tangent in point order. +""" + +from typing import Iterable, List, Optional, Sequence, Tuple + +import numpy as np + +EDITABLE_KINDS = ("catmull-rom", "linear", "bspline") + + +def polyline_length(points: np.ndarray) -> float: + """Length of a polyline (same units as the points).""" + pts = np.asarray(points, dtype=float).reshape(-1, 3) + if len(pts) < 2: + return 0.0 + return float(np.linalg.norm(np.diff(pts, axis=0), axis=1).sum()) + + +def _arc_lengths(points: np.ndarray) -> np.ndarray: + seg = np.linalg.norm(np.diff(points, axis=0), axis=1) + return np.concatenate([[0.0], np.cumsum(seg)]) + + +def point_at_arc_length(points: np.ndarray, fraction: float = 0.5) -> np.ndarray: + """The point a ``fraction`` of the way along a polyline (0.5 = midpoint by length).""" + pts = np.asarray(points, dtype=float).reshape(-1, 3) + if len(pts) == 1: + return pts[0].copy() + s = _arc_lengths(pts) + target = float(np.clip(fraction, 0.0, 1.0)) * s[-1] + return np.array([np.interp(target, s, pts[:, i]) for i in range(3)]) + + +def resample_by_arc_length(points: np.ndarray, spacing: float, include_end: bool = False) -> np.ndarray: + """Points ``spacing`` apart along a polyline, starting at its first point. + + Args: + points: (M, 3) ordered polyline. + spacing: Distance between samples (same units as the points), > 0. + include_end: Also add the last point if it is not already a sample. + """ + if spacing <= 0: + raise ValueError("spacing must be positive") + pts = np.asarray(points, dtype=float).reshape(-1, 3) + if len(pts) < 2: + return pts.copy() + s = _arc_lengths(pts) + targets = np.arange(0.0, s[-1] + 1e-9, spacing) + if include_end and s[-1] - targets[-1] > 1e-6: + targets = np.append(targets, s[-1]) + return np.stack([np.interp(targets, s, pts[:, i]) for i in range(3)], axis=1) + + +def polyline_tangents(points: np.ndarray) -> np.ndarray: + """Unit tangents in point order (central differences inside, one-sided at the ends).""" + pts = np.asarray(points, dtype=float).reshape(-1, 3) + if len(pts) < 2: + return np.tile([0.0, 0.0, 1.0], (len(pts), 1)) + t = np.gradient(pts, axis=0) + norms = np.linalg.norm(t, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return t / norms + + +def parallel_transport_frames(tangents: np.ndarray, reference: Optional[Sequence[float]] = None) -> np.ndarray: + """Rotation-minimising frames along a curve: (N, 3, 3) matrices whose columns are x, y, z with z = tangent. + + The first frame's x axis is ``reference`` (default the coordinate axis least aligned with the first tangent) + made orthogonal to the tangent; later x axes are transported along the curve, so the frame does not spin. + """ + t = np.asarray(tangents, dtype=float).reshape(-1, 3) + n = len(t) + frames = np.zeros((n, 3, 3)) + if n == 0: + return frames + if reference is None: + reference = np.eye(3)[int(np.argmin(np.abs(t[0])))] + x = np.asarray(reference, dtype=float) - t[0] * float(np.dot(reference, t[0])) + if np.linalg.norm(x) < 1e-9: + x = np.eye(3)[int(np.argmin(np.abs(t[0])))] - t[0] * t[0][int(np.argmin(np.abs(t[0])))] + x /= np.linalg.norm(x) + for i in range(n): + if i: + x = x - t[i] * float(np.dot(x, t[i])) + norm = np.linalg.norm(x) + if norm < 1e-9: # tangent flipped onto x; restart from a fresh orthogonal axis + x = np.eye(3)[int(np.argmin(np.abs(t[i])))] - t[i] * t[i][int(np.argmin(np.abs(t[i])))] + norm = np.linalg.norm(x) + x /= norm + frames[i, :, 0] = x + frames[i, :, 1] = np.cross(t[i], x) + frames[i, :, 2] = t[i] + return frames + + +def sample_filament_poses(points: np.ndarray, spacing: float) -> Tuple[np.ndarray, np.ndarray]: + """Sample a centreline every ``spacing``: positions (N, 3) and transforms (N, 4, 4) with +Z along the tangent and + no translation.""" + pos = resample_by_arc_length(points, spacing) + if len(pos) < 2: + tangents = polyline_tangents(np.asarray(points, dtype=float).reshape(-1, 3))[:1] + else: + tangents = polyline_tangents(pos) + frames = parallel_transport_frames(tangents) + transforms = np.tile(np.eye(4), (len(pos), 1, 1)) + transforms[:, :3, :3] = frames + return pos, transforms + + +def filaments_to_picks( + filaments: Iterable, + spacing: float, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Picks sampled along ``CopickFilament`` centrelines, grouped by filament (file order) and ordered along each. + + Returns: + positions (N, 3), transforms (N, 4, 4), instance_ids (N,) and scores (N,), ready for + ``CopickPicks.from_numpy(positions, transforms, instance_ids=..., scores=...)``. + """ + pos, tr, ids, sc = [], [], [], [] + for f in filaments: + p, t = sample_filament_poses(np.asarray(f.points, dtype=float), spacing) + pos.append(p) + tr.append(t) + ids.append(np.full(len(p), int(f.instance_id), dtype=np.int64)) + sc.append(np.full(len(p), float(f.score))) + if not pos: + return np.zeros((0, 3)), np.zeros((0, 4, 4)), np.zeros(0, dtype=np.int64), np.zeros(0) + return np.concatenate(pos), np.concatenate(tr), np.concatenate(ids), np.concatenate(sc) + + +def editable_handles(filament, kinds: Sequence[str] = EDITABLE_KINDS) -> Tuple[np.ndarray, str, bool]: + """The handles an editor shows for a ``CopickFilament``. + + Returns: + ``(control_points (n, 3), kind, can_add_remove)``. A current curve of one of ``kinds`` is reused; otherwise core + derives a ``catmull-rom`` curve from the points. A ``bspline`` keeps its knots, so its handles can be moved but + not added or removed. + """ + curve = filament.editable_curve(kinds=tuple(kinds)) + return np.asarray(curve.control_points, dtype=float), curve.kind, curve.kind != "bspline" + + +def insert_control_point( + control_points: np.ndarray, + point: Sequence[float], + mode: str = "append", +) -> Tuple[np.ndarray, int]: + """Insert ``point`` into an ordered control-point list. + + Args: + control_points: (n, 3) existing control points (n may be 0). + point: The new point. + mode: ``"append"`` (after the last), ``"prepend"`` (before the first), or ``"nearest"`` (into the segment it is + closest to; before the first or after the last if it lies beyond an end). + + Returns: + The new (n + 1, 3) array and the index of the inserted point. + """ + cps = np.asarray(control_points, dtype=float).reshape(-1, 3) + p = np.asarray(point, dtype=float).reshape(3) + if mode == "prepend": + index = 0 + elif mode == "append" or (mode == "nearest" and len(cps) < 2): + index = len(cps) + elif mode == "nearest": + a, b = cps[:-1], cps[1:] + ab = b - a + denom = np.einsum("ij,ij->i", ab, ab) + denom[denom == 0] = 1.0 + t = np.einsum("ij,ij->i", p - a, ab) / denom + closest = a + np.clip(t, 0, 1)[:, None] * ab + seg = int(np.argmin(np.linalg.norm(closest - p, axis=1))) + if seg == 0 and t[0] < 0: + index = 0 + elif seg == len(ab) - 1 and t[-1] > 1: + index = len(cps) + else: + index = seg + 1 + else: + raise ValueError(f"Unknown insert mode {mode!r}") + return np.insert(cps, index, p, axis=0), index + + +def nearest_point_to_line( + points: np.ndarray, + line_start: Sequence[float], + line_end: Sequence[float], + tolerance: float, +) -> Optional[int]: + """Index of the point closest to the viewer along a pick ray, if any lies within ``tolerance`` of it. + + Args: + points: (n, 3) candidate points. + line_start, line_end: The ray from the near to the far clip plane. + tolerance: Largest distance from the ray (same units as the points). + """ + pts = np.asarray(points, dtype=float).reshape(-1, 3) + if len(pts) == 0: + return None + a = np.asarray(line_start, dtype=float) + d = np.asarray(line_end, dtype=float) - a + length = np.linalg.norm(d) + if length == 0: + return None + d /= length + rel = pts - a + along = rel @ d + dist = np.linalg.norm(rel - along[:, None] * d, axis=1) + ok = np.nonzero((dist <= tolerance) & (along >= 0) & (along <= length))[0] + if len(ok) == 0: + return None + return int(ok[np.argmin(along[ok])]) + + +def nearest_on_polyline( + points: np.ndarray, + query: Sequence[float], + from_arc_length: float = 0.0, +) -> Tuple[float, np.ndarray, float]: + """The point of a polyline closest to ``query``: ``(arc length, point, distance)``. + + Args: + points: (n, 3) polyline, n >= 2. + query: The point to project. + from_arc_length: Only consider the polyline from this arc length on (keeps projections of successive points + in order on a filament that passes close to itself). + """ + pts = np.asarray(points, dtype=float).reshape(-1, 3) + q = np.asarray(query, dtype=float) + start, d = pts[:-1], np.diff(pts, axis=0) + seg_len = np.linalg.norm(d, axis=1) + safe_len = np.where(seg_len > 0, seg_len, 1.0) + s0 = _arc_lengths(pts)[:-1] + t = np.einsum("ij,ij->i", q - start, d) / safe_len**2 + t_min = np.clip((from_arc_length - s0) / safe_len, 0.0, 1.0) # no point before from_arc_length + t = np.clip(np.maximum(t, t_min), 0.0, 1.0) + closest = start + t[:, None] * d + dist = np.linalg.norm(closest - q, axis=1) + dist[s0 + seg_len < from_arc_length] = np.inf + i = int(np.argmin(dist)) + s = s0 + t * seg_len + return float(s[i]), closest[i], float(dist[i]) + + +def split_control_points( + controls: np.ndarray, + polyline: np.ndarray, + cut: Sequence[float], + min_gap: float, +) -> Tuple[np.ndarray, np.ndarray]: + """Split the control points of an interpolating curve (Catmull-Rom, linear) at a point on its centreline. + + The cut point becomes the last control point of the first piece and the first of the second, so both pieces keep + the curve's shape away from the cut. A cut within ``min_gap`` (arc length) of a control point splits at that + control point instead. + + Args: + controls: (n, 3) control points, in order along the curve. + polyline: (m, 3) the centreline regenerated from them. + cut: A point on (or near) the centreline. + min_gap: Smallest arc length between the cut and a control point. + + Returns: + The control points of the two pieces. + + Raises: + ValueError: If a piece would have fewer than two control points (the cut is too close to an end). + """ + cps = np.asarray(controls, dtype=float).reshape(-1, 3) + line = np.asarray(polyline, dtype=float).reshape(-1, 3) + s_cut, at, _ = nearest_on_polyline(line, cut) + s_cps, s_prev = [], 0.0 + for c in cps: + s_prev = nearest_on_polyline(line, c, from_arc_length=s_prev)[0] + s_cps.append(s_prev) + s_cps = np.asarray(s_cps) + near = np.nonzero(np.abs(s_cps - s_cut) < min_gap)[0] + if len(near): + k = int(near[np.argmin(np.abs(s_cps[near] - s_cut))]) + first, second = cps[: k + 1], cps[k:] + else: + k = int(np.searchsorted(s_cps, s_cut)) - 1 # last control point before the cut + first = np.vstack([cps[: k + 1], at]) if k >= 0 else at[None] + second = np.vstack([at, cps[k + 1 :]]) + if len(first) < 2 or len(second) < 2: + raise ValueError("The cut is too close to an end of the filament.") + return first, second + + +def split_polyline(polyline: np.ndarray, cut: Sequence[float], min_gap: float) -> Tuple[np.ndarray, np.ndarray]: + """Split a polyline at the point closest to ``cut``; both pieces include that point. + + Raises: + ValueError: If a piece would be shorter than ``min_gap``. + """ + line = np.asarray(polyline, dtype=float).reshape(-1, 3) + s_cut, at, _ = nearest_on_polyline(line, cut) + s = _arc_lengths(line) + if s_cut < min_gap or s[-1] - s_cut < min_gap: + raise ValueError("The cut is too close to an end of the filament.") + first = np.vstack([line[s < s_cut], at]) + second = np.vstack([at, line[s > s_cut]]) + return first, second + + +def direction_markers(points: np.ndarray, spacing: float) -> Tuple[np.ndarray, np.ndarray]: + """Where to draw arrows showing a filament's direction (its point order, what "reverse" flips). + + Returns ``(positions, directions)``: (K, 3) points along the polyline, half a ``spacing`` in and then every + ``spacing``, plus one just before its end; (K, 3) unit vectors pointing along the filament there. + """ + pts = np.asarray(points, dtype=float).reshape(-1, 3) + if len(pts) < 2: + return np.zeros((0, 3)), np.zeros((0, 3)) + s = _arc_lengths(pts) + total = s[-1] + if total <= 0: + return np.zeros((0, 3)), np.zeros((0, 3)) + targets = list(np.arange(spacing / 2, total - spacing / 4, spacing)) if spacing > 0 else [] + end = max(0.0, total - min(spacing / 4, total / 10) if spacing > 0 else total * 0.9) + targets.append(end) + targets = np.asarray(targets) + positions = np.stack([np.interp(targets, s, pts[:, i]) for i in range(3)], axis=1) + seg = np.clip(np.searchsorted(s, targets, side="right") - 1, 0, len(pts) - 2) + directions = pts[seg + 1] - pts[seg] + norms = np.linalg.norm(directions, axis=1) + keep = norms > 0 + return positions[keep], directions[keep] / norms[keep, None] + + +def filament_ids(filaments: Iterable) -> List[int]: + """Instance IDs of ``CopickFilament`` objects.""" + return [int(f.instance_id) for f in filaments] diff --git a/src/copick_shared_ui/util/instances.py b/src/copick_shared_ui/util/instances.py new file mode 100644 index 0000000..5459699 --- /dev/null +++ b/src/copick_shared_ui/util/instances.py @@ -0,0 +1,332 @@ +"""Instance IDs shared by picks, filaments and instance or panoptic segmentations: colours, ID sets, label statistics +and in-place label edits. Qt-free (numpy; scipy only where noted, imported lazily). + +Colours follow one rule everywhere (napari, ChimeraX and copick-web): instance ``i > 0`` gets the HSV colour +``((i * 0.618033988749895) % 1, 0.65, 0.95)``; instance 0 (unassigned) keeps the object's colour. +""" + +import colorsys +import re +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple + +import numpy as np + +GOLDEN_RATIO_CONJUGATE = 0.618033988749895 +INSTANCE_SATURATION = 0.65 +INSTANCE_VALUE = 0.95 + + +def instance_rgb(instance_id: int) -> Tuple[float, float, float]: + """The RGB colour (floats 0-1) of one instance ID (> 0).""" + return colorsys.hsv_to_rgb((int(instance_id) * GOLDEN_RATIO_CONJUGATE) % 1.0, INSTANCE_SATURATION, INSTANCE_VALUE) + + +def instance_colors( + instance_ids: Sequence[int], + base_rgba: Sequence[float], + dtype=float, +) -> np.ndarray: + """One RGBA colour per entry of ``instance_ids``. + + Args: + instance_ids: Instance ID per item (0 = unassigned). + base_rgba: Colour of unassigned items, and the alpha of all items; floats 0-1 for ``dtype=float``, 0-255 for + ``dtype=np.uint8``. + dtype: ``float`` (0-1) or ``np.uint8`` (0-255). + + Returns: + (N, 4) array. + """ + ids = np.asarray(instance_ids, dtype=np.int64).reshape(-1) + scale = 255.0 if np.dtype(dtype) == np.uint8 else 1.0 + colors = np.tile(np.asarray(base_rgba, dtype=float)[:4], (len(ids), 1)) + for value in np.unique(ids[ids > 0]): + colors[ids == value, :3] = np.asarray(instance_rgb(value)) * scale + if np.dtype(dtype) == np.uint8: + return np.round(colors).astype(np.uint8) + return colors + + +def instance_color_dict( + instance_ids: Iterable[int], + alpha: float = 1.0, + hidden: Iterable[int] = (), +) -> Dict[int, Tuple[float, float, float, float]]: + """``{id: (r, g, b, a)}`` (floats 0-1) for the given IDs; 0 and ``hidden`` IDs are transparent.""" + hidden = {int(h) for h in hidden} + out = {0: (0.0, 0.0, 0.0, 0.0)} + for i in instance_ids: + i = int(i) + if i <= 0: + continue + out[i] = (0.0, 0.0, 0.0, 0.0) if i in hidden else (*instance_rgb(i), float(alpha)) + return out + + +def instance_color_table(max_id: int, alpha: int = 255, hidden: Iterable[int] = ()) -> np.ndarray: + """(max_id + 1, 4) uint8 lookup table indexed by instance ID; ID 0 and ``hidden`` IDs are transparent.""" + table = np.zeros((int(max_id) + 1, 4), dtype=np.uint8) + if max_id > 0: + ids = np.arange(1, int(max_id) + 1) + hues = (ids * GOLDEN_RATIO_CONJUGATE) % 1.0 + rgb = np.array([colorsys.hsv_to_rgb(h, INSTANCE_SATURATION, INSTANCE_VALUE) for h in hues]) + table[1:, :3] = np.round(rgb * 255).astype(np.uint8) + table[1:, 3] = alpha + for h in hidden: + if 0 < int(h) <= max_id: + table[int(h)] = 0 + return table + + +def next_instance_id(*id_collections: Iterable[int]) -> int: + """One more than the largest ID in any of the collections (arrays, lists, sets); at least 1.""" + best = 0 + for ids in id_collections: + if ids is None: + continue + arr = ids if isinstance(ids, np.ndarray) else np.asarray(list(ids)) + if arr.size: + best = max(best, int(arr.max())) + return best + 1 + + +_RANGE = re.compile(r"^\s*(\d+)\s*(?:-\s*(\d+))?\s*$") + + +def parse_id_set(text: str) -> Set[int]: + """Parse ``"1-5, 9, 12"`` into ``{1, 2, 3, 4, 5, 9, 12}``. + + Raises: + ValueError: If a part is neither a number nor a range. + """ + ids: Set[int] = set() + normalized = re.sub(r"\s*-\s*", "-", text or "") + for part in filter(None, re.split(r"[,\s;]+", normalized)): + m = _RANGE.match(part) + if not m: + raise ValueError(f"Not an ID or range: {part!r}") + lo = int(m.group(1)) + hi = int(m.group(2)) if m.group(2) else lo + if hi < lo: + lo, hi = hi, lo + ids.update(range(lo, hi + 1)) + return ids + + +def format_id_set(ids: Iterable[int]) -> str: + """Format IDs compactly: ``{1, 2, 3, 5}`` -> ``"1-3, 5"``.""" + values = sorted({int(i) for i in ids}) + parts: List[str] = [] + i = 0 + while i < len(values): + j = i + while j + 1 < len(values) and values[j + 1] == values[j] + 1: + j += 1 + parts.append(str(values[i]) if i == j else f"{values[i]}-{values[j]}") + i = j + 1 + return ", ".join(parts) + + +def label_counts(volume: np.ndarray) -> Dict[int, int]: + """Voxel count per non-zero label.""" + flat = np.asarray(volume).ravel() + if flat.size == 0: + return {} + if flat.dtype.kind in "ui" and flat.dtype.itemsize <= 2 and flat.min() >= 0: + counts = np.bincount(flat.astype(np.int64, copy=False)) + ids = np.nonzero(counts)[0] + return {int(i): int(counts[i]) for i in ids if i != 0} + ids, counts = np.unique(flat, return_counts=True) + return {int(i): int(c) for i, c in zip(ids, counts) if i != 0} + + +def label_bboxes(volume: np.ndarray) -> Dict[int, Tuple[slice, slice, slice]]: + """Bounding box (tuple of slices, in array axis order) per non-zero label (uses scipy).""" + volume = np.asarray(volume) + if volume.size == 0 or not volume.any(): + return {} + max_id = int(volume.max()) + if max_id <= 2_000_000: + from scipy import ndimage + + boxes = ndimage.find_objects(volume.astype(np.int64, copy=False) if volume.dtype.kind != "i" else volume) + return {i + 1: b for i, b in enumerate(boxes) if b is not None} + out = {} + for i in np.unique(volume): + if i == 0: + continue + idx = np.nonzero(volume == i) + out[int(i)] = tuple(slice(int(a.min()), int(a.max()) + 1) for a in idx) + return out + + +def bbox_center(bbox: Tuple[slice, ...]) -> np.ndarray: + """Centre of a bounding box, in array index units (axis order of the box).""" + return np.array([(s.start + s.stop - 1) / 2.0 for s in bbox]) + + +def merge_labels(volume: np.ndarray, source_ids: Iterable[int], target_id: int) -> int: + """Relabel ``source_ids`` to ``target_id`` in place; returns the number of voxels changed.""" + src = [int(s) for s in source_ids if int(s) != int(target_id)] + if not src: + return 0 + mask = np.isin(volume, src) + n = int(mask.sum()) + volume[mask] = target_id + return n + + +def delete_labels(volume: np.ndarray, ids: Iterable[int]) -> int: + """Set ``ids`` to 0 in place; returns the number of voxels cleared.""" + return merge_labels(volume, ids, 0) + + +@dataclass +class InstanceRow: + """One row of an instance browser.""" + + instance_id: int + count: int = 0 + score: Optional[float] = None + length: Optional[float] = None + color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0) + label: str = "" + visible: bool = True + key: Optional[int] = None # unique key when instance IDs repeat (panoptic: the segment index) + + @property + def row_key(self) -> int: + return self.instance_id if self.key is None else self.key + + +def format_length(angstrom: Optional[float]) -> str: + """A length given in Angstrom, in µm from 1 µm on and in nm below (e.g. ``1.42 µm``, ``842 nm``).""" + if angstrom is None: + return "" + if abs(angstrom) >= 1e4: + return f"{angstrom / 1e4:.2f} µm" + return f"{angstrom / 10:.0f} nm" + + +def instance_row_title(row: InstanceRow, kind: str = "") -> str: + """First line of a compact instance row: ``#12``; ``unassigned`` for picks without an ID; ``ribosome #3`` / + ``membrane (stuff)`` for panoptic segments.""" + if kind == "panoptic": + name = row.label or "segment" + return f"{name} #{row.instance_id}" if row.instance_id else f"{name} (stuff)" + if kind == "picks" and row.instance_id == 0: + return row.label or "unassigned" + return f"#{row.instance_id}" + + +def instance_row_caption(row: InstanceRow, kind: str = "") -> str: + """Second line of a compact instance row: what the row's numbers mean for its kind; empty values are left out.""" + parts: List[str] = [] + if kind in ("instance", "panoptic"): + parts.append(f"{row.count:,} voxels") + elif kind == "filaments" and row.label == "pending": + parts.append(f"pending · {row.count} control point{'s' if row.count != 1 else ''}") + elif kind == "filaments": + if row.length is not None: + parts.append(format_length(row.length)) + else: + parts.append(f"{row.count:,} pt{'s' if row.count != 1 else ''}") + if row.score is not None: + parts.append(f"score {row.score:.2f}") + if ( + row.label + and kind not in ("panoptic", "instance") + and row.label != "pending" + and not (kind == "picks" and row.instance_id == 0) + ): + parts.append(row.label) + return " · ".join(parts) + + +def rows_from_points( + instance_ids: Sequence[int], + scores: Optional[Sequence[float]] = None, + base_rgba: Sequence[float] = (1.0, 1.0, 1.0, 1.0), +) -> List[InstanceRow]: + """Rows for points grouped by instance ID: point count and mean score. ID 0 (unassigned) gets a row too.""" + ids = np.asarray(instance_ids, dtype=np.int64).reshape(-1) + sc = None if scores is None else np.asarray(scores, dtype=float).reshape(-1) + rows = [] + for i in np.unique(ids): + m = ids == i + rows.append( + InstanceRow( + instance_id=int(i), + count=int(m.sum()), + score=None if sc is None else float(np.nanmean(sc[m])), + color=tuple(base_rgba[:4]) if i == 0 else (*instance_rgb(i), 1.0), + label="unassigned" if i == 0 else "", + ), + ) + return rows + + +def rows_from_filaments(filaments: Iterable, base_rgba: Sequence[float] = (1.0, 1.0, 1.0, 1.0)) -> List[InstanceRow]: + """Rows for ``CopickFilament`` objects: point count, centreline length (Angstrom), score, curve kind.""" + from copick_shared_ui.util.filaments import polyline_length + + rows = [] + for f in filaments: + pts = np.asarray(f.points, dtype=float) + curve = getattr(f, "curve", None) + rows.append( + InstanceRow( + instance_id=int(f.instance_id), + count=len(pts), + score=float(f.score), + length=polyline_length(pts), + color=(*instance_rgb(f.instance_id), 1.0), + label=curve.kind if curve is not None else "", + ), + ) + return sorted(rows, key=lambda r: r.instance_id) + + +def rows_from_counts(counts: Dict[int, int], label: str = "") -> List[InstanceRow]: + """Rows for an instance segmentation: voxel count per ID.""" + return [ + InstanceRow(instance_id=int(i), count=int(c), color=(*instance_rgb(i), 1.0), label=label) + for i, c in sorted(counts.items()) + ] + + +def panoptic_segments( + labels: np.ndarray, + instances: np.ndarray, + objects: Dict[int, Tuple[str, Sequence[float]]], +) -> Tuple[np.ndarray, List[InstanceRow]]: + """Index the segments ``(label, instance)`` of a panoptic segmentation. + + Args: + labels: Label channel (object labels, 0 = background). + instances: Instance channel (per-object instance IDs, 0 = stuff / not split). + objects: ``{label: (object_name, rgba floats 0-1)}``. + + Returns: + A ``uint32`` volume where each segment has its own index (1..S, 0 = background), and one row per segment + (``key`` = segment index, ``label`` = object name). Stuff segments (instance 0) use the object's colour, + things their instance colour. + """ + labels = np.asarray(labels) + instances = np.asarray(instances) + mask = labels != 0 + seg = np.zeros(labels.shape, dtype=np.uint32) + if not mask.any(): + return seg, [] + keys = (labels[mask].astype(np.uint64) << np.uint64(32)) | instances[mask].astype(np.uint64) + uniq, inverse, counts = np.unique(keys, return_inverse=True, return_counts=True) + seg[mask] = (inverse + 1).astype(np.uint32) + rows = [] + for index, (key, count) in enumerate(zip(uniq, counts), start=1): + label = int(key >> np.uint64(32)) + inst = int(key & np.uint64(0xFFFFFFFF)) + name, rgba = objects.get(label, (str(label), (0.5, 0.5, 0.5, 1.0))) + color = tuple(rgba[:4]) if inst == 0 else (*instance_rgb(inst), 1.0) + rows.append(InstanceRow(instance_id=inst, count=int(count), color=color, label=name, key=index)) + return seg, rows diff --git a/src/copick_shared_ui/widgets/cli/actions_bar.py b/src/copick_shared_ui/widgets/cli/actions_bar.py index d4b0890..1140b80 100644 --- a/src/copick_shared_ui/widgets/cli/actions_bar.py +++ b/src/copick_shared_ui/widgets/cli/actions_bar.py @@ -148,17 +148,6 @@ def _make_handler(s): @staticmethod def _get_object_type(copick_obj: Any) -> Optional[str]: """Map a copick model object to its URI object type string.""" - if copick_obj is None: - return None - cls_name = type(copick_obj).__name__ - if "Picks" in cls_name: - return "picks" - elif "Mesh" in cls_name: - return "mesh" - elif "Segmentation" in cls_name: - return "segmentation" - elif "Tomogram" in cls_name: - return "tomogram" - elif "Features" in cls_name: - return "feature" - return None + from copick_shared_ui.core.types import uri_object_type + + return uri_object_type(copick_obj) diff --git a/src/copick_shared_ui/widgets/cli/command_form.py b/src/copick_shared_ui/widgets/cli/command_form.py index 277ae77..d28a030 100644 --- a/src/copick_shared_ui/widgets/cli/command_form.py +++ b/src/copick_shared_ui/widgets/cli/command_form.py @@ -514,18 +514,9 @@ def _try_prefill_from_selection(self, copick_obj: Any) -> None: @staticmethod def _get_copick_object_type(copick_obj: Any) -> Optional[str]: """Map a copick model object to its URI object type string.""" - cls_name = type(copick_obj).__name__ - if "Picks" in cls_name: - return "picks" - elif "Mesh" in cls_name: - return "mesh" - elif "Segmentation" in cls_name: - return "segmentation" - elif "Tomogram" in cls_name: - return "tomogram" - elif "Features" in cls_name: - return "feature" - return None + from copick_shared_ui.core.types import uri_object_type + + return uri_object_type(copick_obj) def prefill_uri(self, uri: str, object_type: str = "") -> None: """Pre-fill a URI widget matching the given object type. diff --git a/src/copick_shared_ui/widgets/cli/uri_widget.py b/src/copick_shared_ui/widgets/cli/uri_widget.py index 65434cd..a584430 100644 --- a/src/copick_shared_ui/widgets/cli/uri_widget.py +++ b/src/copick_shared_ui/widgets/cli/uri_widget.py @@ -45,8 +45,9 @@ # URI format templates per object type _URI_FIELDS = { "picks": ["object_name", "user_id", "session_id"], + "filaments": ["object_name", "user_id", "session_id"], "mesh": ["object_name", "user_id", "session_id"], - "segmentation": ["object_name", "user_id", "session_id", "voxel_spacing"], + "segmentation": ["object_name", "user_id", "session_id", "voxel_spacing", "segmentation_type"], "tomogram": ["tomo_type", "voxel_spacing"], "feature": ["tomo_type", "voxel_spacing", "feature_type"], "any": ["object_name", "user_id", "session_id"], @@ -59,12 +60,22 @@ "voxel_spacing": "Voxel Spacing", "tomo_type": "Tomogram Type", "feature_type": "Feature Type", + "segmentation_type": "Segmentation Type", +} + +# Segmentation type -> URI query (copick URIs: name:user/session@vs[?multilabel=true|?instance=true|?panoptic=true]) +_SEGMENTATION_TYPE_QUERIES = { + "": "", + "binary": "", + "multilabel": "multilabel=true", + "instance": "instance=true", + "panoptic": "panoptic=true", } def _assemble_uri(object_type: str, values: dict) -> str: """Assemble a URI string from component values.""" - if object_type in ("picks", "mesh", "any"): + if object_type in ("picks", "filaments", "mesh", "any"): obj = values.get("object_name", "") user = values.get("user_id", "") session = values.get("session_id", "") @@ -87,6 +98,9 @@ def _assemble_uri(object_type: str, values: dict) -> str: parts += f":{user}/{session}" if vs: parts += f"@{vs}" + query = _SEGMENTATION_TYPE_QUERIES.get(values.get("segmentation_type", ""), "") + if query: + parts += f"?{query}" return parts elif object_type == "tomogram": @@ -120,7 +134,7 @@ def _parse_uri_simple(object_type: str, uri: str) -> dict: if not uri: return result - if object_type in ("picks", "mesh", "any"): + if object_type in ("picks", "filaments", "mesh", "any"): # object_name:user_id/session_id if ":" in uri: obj, rest = uri.split(":", 1) @@ -135,9 +149,15 @@ def _parse_uri_simple(object_type: str, uri: str) -> dict: result["object_name"] = uri elif object_type == "segmentation": - # name:user_id/session_id@voxel_spacing + # name:user_id/session_id@voxel_spacing[?type=true]; strip the query before splitting on "@" vs_part = "" main = uri + if "?" in uri: + uri, query = uri.split("?", 1) + main = uri + for kind, q in _SEGMENTATION_TYPE_QUERIES.items(): + if q and q in query.split("&"): + result["segmentation_type"] = kind if "@" in uri: main, vs_part = uri.rsplit("@", 1) result["voxel_spacing"] = vs_part @@ -194,6 +214,8 @@ def _get_field_items( return [str(v) for v in context.get_voxel_spacings()] elif field_name == "tomo_type": return context.get_tomo_types() + elif field_name == "segmentation_type": + return ["", "multilabel", "instance", "panoptic"] return [] diff --git a/src/copick_shared_ui/widgets/compact_delegate.py b/src/copick_shared_ui/widgets/compact_delegate.py new file mode 100644 index 0000000..1fc97ce --- /dev/null +++ b/src/copick_shared_ui/widgets/compact_delegate.py @@ -0,0 +1,466 @@ +"""Compact two-line rows for item views (napari and ChimeraX). + +Line 1: optional icon, colour swatch, title (the display text) and a small type chip; line 2: a dimmer caption. +Models keep their data; the delegate only paints it, so filtering, selection, double-click and background tints +work as before. Roles: + +- ``Qt.DisplayRole``: the title; +- ``CAPTION_ROLE``: the second line; +- ``CHIP_ROLE`` / ``CHIP_COLOR_ROLE``: chip text and an optional ``QColor``; +- ``SWATCH_ROLE``: a ``QColor`` (or anything ``QColor`` accepts), or ``"multi"`` for a multi-colour swatch; +- ``Qt.DecorationRole``: an optional icon at the left, centred over both lines. + +``make_compact(view)`` installs the delegate on a table view (one stretching column, uniform row height) and +``install_sort_menu`` turns its header into a "sort by" menu, since one column can no longer sort by clicking. +""" + +import contextlib +from typing import Callable, Optional, Sequence, Tuple + +from qtpy.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt +from qtpy.QtGui import QBrush, QColor, QConicalGradient, QFont, QFontMetrics, QIcon, QPainter, QPalette, QPen +from qtpy.QtWidgets import ( + QAbstractItemView, + QApplication, + QHeaderView, + QMenu, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, + QTableView, + QTableWidget, + QTableWidgetItem, +) + +CAPTION_ROLE = Qt.UserRole + 101 +CHIP_ROLE = Qt.UserRole + 102 +CHIP_COLOR_ROLE = Qt.UserRole + 103 +SWATCH_ROLE = Qt.UserRole + 104 + +MULTI_SWATCH = "multi" + +_PAD_X = 6 +_PAD_Y = 3 +_GAP = 5 +_SWATCH = 10 +_ICON = 16 +_MIN_TITLE = 40 +_CAPTION_SCALE = 0.85 +_CHIP_SCALE = 0.75 + + +def _outline_color(palette: QPalette) -> QColor: + """The selected-row outline: the application's highlight colour (the view's own is cleared), lightened when the + background is dark so it stands out against tinted rows.""" + color = QColor(QApplication.palette().color(QPalette.Active, QPalette.Highlight)) + if color.alpha() == 0: + color = QColor(42, 130, 218) + if palette.color(QPalette.Base).lightness() < 128 and color.lightness() < 150: + color = color.lighter(170) + color.setAlpha(255) + return color + + +def _scaled(font: QFont, factor: float, bold: Optional[bool] = None) -> QFont: + f = QFont(font) + if f.pointSizeF() > 0: + f.setPointSizeF(max(6.0, f.pointSizeF() * factor)) + elif f.pixelSize() > 0: + f.setPixelSize(max(8, round(f.pixelSize() * factor))) + if bold is not None: + f.setBold(bold) + return f + + +class CompactItemDelegate(QStyledItemDelegate): + """Paints an item as two lines, ``[icon] [swatch] title [chip] / caption``, or with ``inline=True`` as one line, + ``[icon] [swatch] title [chip] caption`` (dense lists whose captions are short, like instance browsers). + + Selected rows keep their background (the row tint carries the object's or instance's colour) and get an outline + in the palette's highlight colour instead of a fill. Installed for a whole view (``make_compact``), the delegate + draws other columns the standard way (check boxes, text) with the same outline, so it spans the row. + """ + + def __init__(self, parent: Optional[QObject] = None, min_width: int = 60, inline: bool = False): + super().__init__(parent) + self._min_width = min_width + self.inline = inline + self.compact_column = 0 + #: Colour of the selected row's outline; default: the application's highlight colour, lightened on dark themes. + self.outline_color: Optional[QColor] = None + + # -- geometry ------------------------------------------------------------------------------------------------ + + def row_height(self, font: QFont) -> int: + """Height of a two-line row for ``font`` (use for uniform row heights).""" + title = QFontMetrics(font).height() + if self.inline: + return title + 2 * _PAD_Y + 2 + caption = QFontMetrics(_scaled(font, _CAPTION_SCALE)).height() + return title + caption + 2 * _PAD_Y + 1 + + def sizeHint(self, option: QStyleOptionViewItem, index) -> QSize: # noqa: N802 (Qt API) + height = self.row_height(option.font) + if index.column() != self.compact_column: # check boxes, plain text: their natural width + return QSize(super().sizeHint(option, index).width(), height) + return QSize(self._min_width, height) + + # -- painting ------------------------------------------------------------------------------------------------ + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index) -> None: + selected = bool(option.state & QStyle.State_Selected) + if index.column() != self.compact_column: # check boxes and plain text, outlined like the compact cell + opt = QStyleOptionViewItem(option) + opt.state &= ~(QStyle.State_Selected | QStyle.State_HasFocus) + super().paint(painter, opt, index) + if selected: + self._paint_outline(painter, option, index) + return + + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + title = opt.text or "" + icon = QIcon(opt.icon) if not opt.icon.isNull() else None # a copy: opt.icon is cleared below + # Native background (hover, stylesheets, BackgroundRole tints) without the selection fill. + opt.text = "" + opt.icon = QIcon() + opt.state &= ~(QStyle.State_Selected | QStyle.State_HasFocus) + widget = opt.widget + style = widget.style() if widget is not None else QApplication.style() + style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget) + + text_color = opt.palette.color(QPalette.Text) + caption_color = QColor(text_color) + caption_color.setAlpha(165) + + caption = index.data(CAPTION_ROLE) or "" + chip = index.data(CHIP_ROLE) or "" + swatch = index.data(SWATCH_ROLE) + + title_font = QFont(opt.font) + caption_font = _scaled(opt.font, _CAPTION_SCALE) + title_fm, caption_fm = QFontMetrics(title_font), QFontMetrics(caption_font) + + rect = opt.rect.adjusted(_PAD_X, _PAD_Y, -_PAD_X, -_PAD_Y) + top = rect.top() + (rect.height() - title_fm.height()) // 2 if self.inline else rect.top() + line1 = QRect(rect.left(), top, rect.width(), title_fm.height()) + line2 = QRect(rect.left(), line1.bottom() + 1, rect.width(), caption_fm.height()) + + painter.save() + painter.setRenderHint(QPainter.Antialiasing, True) + x = rect.left() + if icon is not None: + size = min(_ICON, rect.height()) + icon_rect = QRect(x, rect.top() + (rect.height() - size) // 2, size, size) + mode = QIcon.Selected if selected else QIcon.Normal + icon.paint(painter, icon_rect, Qt.AlignCenter, mode) + x += size + _GAP + text_left = x # the caption lines up with the title's swatch + + if swatch is not None and swatch != "": + sw = QRectF(x, line1.center().y() - _SWATCH / 2 + 0.5, _SWATCH, _SWATCH) + self._paint_swatch(painter, sw, swatch, text_color) + x += _SWATCH + _GAP + + # Title and chip share line 1: the title elides first, the chip goes when the title would get too short. + chip_font = _scaled(opt.font, _CHIP_SCALE) + chip_fm = QFontMetrics(chip_font) + chip_w = chip_fm.horizontalAdvance(chip) + 8 if chip else 0 + avail = line1.right() - x + 1 + show_chip = bool(chip) and avail - chip_w - _GAP >= _MIN_TITLE + title_avail = avail - (chip_w + _GAP if show_chip else 0) + shown = title_fm.elidedText(title, Qt.ElideRight, max(0, title_avail)) + painter.setFont(title_font) + painter.setPen(text_color) + painter.drawText( + QRect(x, line1.top(), max(0, title_avail), line1.height()), + Qt.AlignLeft | Qt.AlignVCenter, + shown, + ) + end = x + title_fm.horizontalAdvance(shown) + if show_chip: + cx = end + _GAP + chip_rect = QRectF(cx, line1.center().y() - chip_fm.height() / 2, chip_w, chip_fm.height()) + self._paint_chip(painter, chip_rect, chip, chip_font, index.data(CHIP_COLOR_ROLE), text_color) + end = cx + chip_w + + if caption and self.inline: # the rest of line 1 + cap_rect = QRect(end + 2 * _GAP, line1.top(), line1.right() - end - 2 * _GAP + 1, line1.height()) + if cap_rect.width() > 20: + painter.setFont(caption_font) + painter.setPen(caption_color) + painter.drawText( + cap_rect, + Qt.AlignLeft | Qt.AlignVCenter, + caption_fm.elidedText(caption, Qt.ElideRight, cap_rect.width()), + ) + elif caption: + painter.setFont(caption_font) + painter.setPen(caption_color) + cap_rect = QRect(text_left, line2.top(), line2.right() - text_left + 1, line2.height()) + painter.drawText( + cap_rect, + Qt.AlignLeft | Qt.AlignVCenter, + caption_fm.elidedText(caption, Qt.ElideRight, cap_rect.width()), + ) + painter.restore() + if selected: + self._paint_outline(painter, option, index) + + def _paint_outline(self, painter: QPainter, option: QStyleOptionViewItem, index) -> None: + """The selected row's outline: top and bottom edges on every cell, left on the first column, right on the + last, so adjacent cells join into one frame around the row.""" + model = index.model() + first = index.column() == 0 + last = model is None or index.column() == model.columnCount(index.parent()) - 1 + color = self.outline_color or _outline_color(option.palette) + width = 2 + r = QRectF(option.rect).adjusted(width / 2, width / 2, -width / 2, -width / 2) + painter.save() + painter.setRenderHint(QPainter.Antialiasing, False) + painter.setPen(QPen(color, width)) + left = r.left() if first else option.rect.left() + right = r.right() if last else option.rect.right() + 1 + painter.drawLine(QPointF(left, r.top()), QPointF(right, r.top())) + painter.drawLine(QPointF(left, r.bottom()), QPointF(right, r.bottom())) + if first: + painter.drawLine(QPointF(r.left(), r.top()), QPointF(r.left(), r.bottom())) + if last: + painter.drawLine(QPointF(r.right(), r.top()), QPointF(r.right(), r.bottom())) + painter.restore() + + @staticmethod + def _paint_swatch(painter: QPainter, rect: QRectF, swatch, outline: QColor) -> None: + if swatch == MULTI_SWATCH: + gradient = QConicalGradient(rect.center(), 90) + for stop, hue in ((0.0, 0), (0.17, 40), (0.33, 60), (0.5, 120), (0.67, 210), (0.83, 280), (1.0, 360)): + gradient.setColorAt(stop, QColor.fromHsv(hue % 360, 200, 240)) + brush = QBrush(gradient) + else: + color = QColor(swatch) if not isinstance(swatch, QColor) else swatch + if not color.isValid(): + return + brush = QBrush(color) + edge = QColor(outline) + edge.setAlpha(90) + painter.setPen(QPen(edge, 1)) + painter.setBrush(brush) + painter.drawEllipse(rect) + + @staticmethod + def _paint_chip(painter: QPainter, rect: QRectF, text: str, font: QFont, color, fallback: QColor) -> None: + c = QColor(color) if color is not None else QColor(fallback) + if not c.isValid(): + c = QColor(fallback) + edge = QColor(c) + edge.setAlpha(200) + painter.setPen(QPen(edge, 1)) + painter.setBrush(Qt.NoBrush) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + painter.setFont(font) + painter.setPen(c) + painter.drawText(rect, Qt.AlignCenter, text) + + +class _CompactKeeper(QObject): + """Keeps a compact view compact: re-applies the column resize modes when the view gets a model (Qt resets + per-section modes when the section count changes) and the uniform row height when its font or style changes.""" + + def __init__(self, view: QTableView, delegate: CompactItemDelegate, column: int): + super().__init__(view) + self._view, self._delegate, self._column = view, delegate, column + view.horizontalHeader().sectionCountChanged.connect(lambda *_: self.apply_columns()) + self.clear_selection_fill() + self.apply() + + def clear_selection_fill(self) -> None: + """No selection fill, so a selected row keeps its colour (the delegate outlines it instead). + + Native styles fill a selected row with the palette's highlight colour before the delegate paints (the row + background), so the view's own highlight becomes transparent and the delegate keeps the real one for the + outline. Under an inherited style sheet (napari) a ``::item:selected`` rule fills it instead, so the view gets + a transparent rule of its own, which wins over inherited ones. + """ + view = self._view + pal = view.palette() + for group in (QPalette.Active, QPalette.Inactive): + pal.setColor(group, QPalette.Highlight, QColor(0, 0, 0, 0)) + view.setPalette(pal) + + def _inherits_style_sheet(self) -> bool: + w = self._view.parentWidget() + while w is not None: + if w.styleSheet(): + return True + w = w.parentWidget() + app = QApplication.instance() + return bool(app is not None and app.styleSheet()) + + def ensure_style_sheet_rule(self) -> None: + rule = "QTableView::item:selected { background: transparent; }" + if rule not in self._view.styleSheet() and self._inherits_style_sheet(): + self._view.setStyleSheet(f"{self._view.styleSheet()}\n{rule}".strip()) + + def apply(self) -> None: + self.apply_columns() + h = self._delegate.row_height(self._view.font()) + vh = self._view.verticalHeader() + vh.setMinimumSectionSize(h) + vh.setDefaultSectionSize(h) + + def apply_columns(self) -> None: + header = self._view.horizontalHeader() + for section in range(header.count()): + mode = QHeaderView.Stretch if section == self._column else QHeaderView.ResizeToContents + header.setSectionResizeMode(section, mode) + + def _wrap(self, key) -> bool: + """Up on the first row goes to the last, Down on the last to the first (rows hidden by a filter skipped). + Returns True if the key was handled; at the ends Qt would otherwise ignore it and pass it on.""" + view = self._view + model = view.model() + if model is None or model.rowCount() == 0: + return False + shown = [r for r in range(model.rowCount()) if not view.isRowHidden(r)] + if not shown: + return False + current = view.currentIndex() + row = current.row() if current.isValid() else -1 + if key == Qt.Key_Up and (row < 0 or row <= shown[0]): + target = shown[-1] + elif key == Qt.Key_Down and (row < 0 or row >= shown[-1]): + target = shown[0] + else: + return False + column = current.column() if current.isValid() else self._column + index = model.index(target, column) + view.setCurrentIndex(index) + view.scrollTo(index) + return True + + def eventFilter(self, obj, event): # noqa: N802 (Qt API) + if event.type() == QEvent.KeyPress and event.key() in (Qt.Key_Up, Qt.Key_Down) and not event.modifiers(): + return self._wrap(event.key()) + if event.type() in (QEvent.FontChange, QEvent.StyleChange): + self.apply() + elif event.type() in (QEvent.Show, QEvent.ParentChange): + self.ensure_style_sheet_rule() + return False + + +def make_compact( + view: QTableView, + delegate: Optional[CompactItemDelegate] = None, + column: int = 0, +) -> CompactItemDelegate: + """Draw ``column`` of a table view with a ``CompactItemDelegate``: it stretches, other columns fit their + contents, rows have a uniform two-line height, selected rows are outlined and the vertical header is hidden.""" + delegate = delegate or CompactItemDelegate(view) + delegate.compact_column = column + view.setItemDelegate(delegate) + view.setWordWrap(False) + view.setShowGrid(False) + view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + view.setTextElideMode(Qt.ElideRight) + view.verticalHeader().setVisible(False) + header = view.horizontalHeader() + header.setMinimumSectionSize(16) + header.setSectionResizeMode(QHeaderView.ResizeToContents) + header.setStretchLastSection(True) + header.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + view.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) + keeper = _CompactKeeper(view, delegate, column) + view.installEventFilter(keeper) + view._compact_keeper = keeper # keep a reference with the view + return delegate + + +class SortMenu(QObject): + """A "sort by" menu on a header: clicking the header shows the keys (plus Descending); the chosen key is shown + in the header text, e.g. ``Name ▾ · by user``.""" + + def __init__( + self, + header: QHeaderView, + label: str, + options: Sequence[Tuple[str, str]], + on_select: Callable[[str, bool], None], + current: Optional[str] = None, + descending: bool = False, + column: int = 0, + ): + super().__init__(header) + self._header = header + self._label = label + self._options = list(options) + self._on_select = on_select + self._column = column + self.key = current or (self._options[0][0] if self._options else "") + self.descending = descending + header.setSectionsClickable(True) + header.setSortIndicatorShown(False) + header.setToolTip("Click to choose the sort order") + # A clickable header would also select the whole column (all rows) when pressed; the click is a menu here. + with contextlib.suppress(TypeError, RuntimeError): + header.sectionPressed.disconnect() + header.sectionClicked.connect(self._on_section_clicked) + self._update_label() + + def refresh(self) -> None: + """Re-apply the header text (after the view got a new model).""" + self._update_label() + + def set_options(self, options: Sequence[Tuple[str, str]]) -> None: + self._options = list(options) + if self._options and self.key not in {k for k, _ in self._options}: + self.key = self._options[0][0] + self._update_label() + + def set_label(self, label: str) -> None: + self._label = label + self._update_label() + + def select(self, key: str, descending: Optional[bool] = None) -> None: + self.key = key + if descending is not None: + self.descending = descending + self._update_label() + self._on_select(self.key, self.descending) + + def menu(self) -> QMenu: + menu = QMenu(self._header) + for key, text in self._options: + action = menu.addAction(text) + action.setCheckable(True) + action.setChecked(key == self.key) + action.triggered.connect(lambda _checked=False, k=key: self.select(k)) + menu.addSeparator() + desc = menu.addAction("Descending") + desc.setCheckable(True) + desc.setChecked(self.descending) + desc.triggered.connect(lambda checked: self.select(self.key, bool(checked))) + return menu + + def _on_section_clicked(self, section: int) -> None: + if section != self._column: + return + x = self._header.sectionViewportPosition(section) + self.menu().exec_(self._header.mapToGlobal(QPoint(x, self._header.height()))) + + def _update_label(self) -> None: + names = dict(self._options) + arrow = " ▾" + text = self._label + arrow + if self.key and names.get(self.key) and self.key != (self._options[0][0] if self._options else None): + text += f" · by {names[self.key].lower()}" + if self.descending: + text += " ↓" + model = self._header.model() + done = model is not None and model.setHeaderData(self._column, Qt.Horizontal, text, Qt.DisplayRole) + view = self._header.parent() + if not done and isinstance(view, QTableWidget): # a QTableWidget needs a header item to hold the text + item = view.horizontalHeaderItem(self._column) or QTableWidgetItem() + item.setText(text) + view.setHorizontalHeaderItem(self._column, item) + self._header.viewport().update() + self.text = text diff --git a/src/copick_shared_ui/widgets/flow_layout.py b/src/copick_shared_ui/widgets/flow_layout.py new file mode 100644 index 0000000..1f04228 --- /dev/null +++ b/src/copick_shared_ui/widgets/flow_layout.py @@ -0,0 +1,105 @@ +"""A layout that wraps its items onto new lines, so a row of buttons never sets a wide minimum width on a narrow dock +(the Qt flow-layout example, with height-for-width).""" + +from qtpy.QtCore import QPoint, QRect, QSize, Qt +from qtpy.QtWidgets import QLayout, QSizePolicy + + +class FlowLayout(QLayout): + """Items left to right, wrapping onto new lines; each line's items are centred vertically (buttons, spin boxes + and combo boxes of different heights line up), and with ``center=True`` each line is centred horizontally.""" + + def __init__(self, parent=None, margin: int = 0, h_spacing: int = 4, v_spacing: int = 4, center: bool = False): + super().__init__(parent) + self._items = [] + self._h_spacing = h_spacing + self._v_spacing = v_spacing + self._center = center + self.setContentsMargins(margin, margin, margin, margin) + + def addItem(self, item): # noqa: N802 (Qt API) + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): # noqa: N802 + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index): # noqa: N802 + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self): # noqa: N802 + return Qt.Orientation(0) + + def hasHeightForWidth(self): # noqa: N802 + return True + + def heightForWidth(self, width): # noqa: N802 + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect): # noqa: N802 + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self): # noqa: N802 + return self.minimumSize() + + def minimumSize(self): # noqa: N802 + """The widest single item: the layout wraps everything else.""" + size = QSize() + for item in self._items: + widget = item.widget() + size = size.expandedTo(widget.minimumSizeHint() if widget is not None else item.minimumSize()) + m = self.contentsMargins() + return size + QSize(m.left() + m.right(), m.top() + m.bottom()) + + def _do_layout(self, rect, test_only: bool) -> int: + m = self.contentsMargins() + effective = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom()) + # Break the items into lines, then place each line (vertically centred, optionally horizontally centred). + lines, line, width = [], [], 0 + for item in self._items: + widget = item.widget() + if widget is not None and widget.isHidden(): + continue + # Widgets get their own size hint: through the layout item, macOS shaves the push button's visual margins + # off its height, and a push button shorter than its native height is drawn as a square bevel button. + hint = widget.sizeHint().expandedTo(widget.minimumSizeHint()) if widget is not None else item.sizeHint() + needed = hint.width() if not line else width + self._h_spacing + hint.width() + if line and needed > effective.width(): + lines.append((line, width)) + line, width = [], 0 + needed = hint.width() + line.append((item, widget, hint)) + width = needed + if line: + lines.append((line, width)) + y = effective.y() + for i, (line, width) in enumerate(lines): + line_height = max(h.height() for _i, _w, h in line) + x = effective.x() + (max(0, effective.width() - width) // 2 if self._center else 0) + if not test_only: + for item, widget, hint in line: + pos = QPoint(x, y + (line_height - hint.height()) // 2) + if widget is not None: + widget.setGeometry(QRect(pos, hint)) + else: + item.setGeometry(QRect(pos, hint)) + x += hint.width() + self._h_spacing + y += line_height + (self._v_spacing if i < len(lines) - 1 else 0) + return y - rect.y() + m.bottom() + + +def flow_container(*widgets, parent=None): + """A widget holding ``widgets`` in a FlowLayout (preferred width, grows in height when narrow).""" + from qtpy.QtWidgets import QWidget + + w = QWidget(parent) + layout = FlowLayout(w) + for child in widgets: + layout.addWidget(child) + policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + policy.setHeightForWidth(True) + w.setSizePolicy(policy) + return w diff --git a/src/copick_shared_ui/widgets/info/info_widget.py b/src/copick_shared_ui/widgets/info/info_widget.py index 8f35b3c..9c15e75 100644 --- a/src/copick_shared_ui/widgets/info/info_widget.py +++ b/src/copick_shared_ui/widgets/info/info_widget.py @@ -22,6 +22,12 @@ AbstractThemeInterface, AbstractWorkerInterface, ) +from copick_shared_ui.core.types import ( + SEGMENTATION_TYPE_LABELS, + is_filament_object, + segmentation_type_of, + supports_filaments, +) if TYPE_CHECKING: from copick.models import CopickRun, CopickTomogram, CopickVoxelSpacing @@ -302,6 +308,8 @@ def _start_async_loading(self) -> None: return data_types = ["voxel_spacings", "tomograms", "picks", "meshes", "segmentations"] + if supports_filaments(): + data_types.insert(3, "filaments") for data_type in data_types: if data_type not in self._loading_states: @@ -453,6 +461,8 @@ def _add_annotations_section(self) -> None: picks_status = self._loading_states.get("picks", "not_started") meshes_status = self._loading_states.get("meshes", "not_started") seg_status = self._loading_states.get("segmentations", "not_started") + with_filaments = supports_filaments() + filaments_status = self._loading_states.get("filaments", "not_started") if with_filaments else "loaded" # Create section frame section_frame = self._create_section_frame() @@ -477,10 +487,12 @@ def _add_annotations_section(self) -> None: picks_count = len(self._loaded_data.get("picks", [])) meshes_count = len(self._loaded_data.get("meshes", [])) seg_count = len(self._loaded_data.get("segmentations", [])) - total_count = picks_count + meshes_count + seg_count + filaments_count = len(self._loaded_data.get("filaments", [])) + total_count = picks_count + meshes_count + seg_count + filaments_count - all_loaded = all(status == "loaded" for status in [picks_status, meshes_status, seg_status]) - any_loading = any(status == "loading" for status in [picks_status, meshes_status, seg_status]) + statuses = [picks_status, meshes_status, seg_status, filaments_status] + all_loaded = all(status == "loaded" for status in statuses) + any_loading = any(status == "loading" for status in statuses) if any_loading: status_label = self._create_status_label("loading", "") @@ -507,6 +519,10 @@ def _add_annotations_section(self) -> None: # Add each annotation type subsections_layout.addWidget(self._create_annotation_subsection("picks", "📍 Picks", picks_status)) + if with_filaments: + subsections_layout.addWidget( + self._create_annotation_subsection("filaments", "〰 Filaments", filaments_status), + ) subsections_layout.addWidget(self._create_annotation_subsection("meshes", "🕸 Meshes", meshes_status)) subsections_layout.addWidget(self._create_annotation_subsection("segmentations", "🖌 Segmentations", seg_status)) @@ -998,6 +1014,16 @@ def _create_annotation_item_widget(self, data_type: str, item: Any) -> QWidget: name = f"📍 {item.pickable_object_name}" point_count = len(item.points) if hasattr(item, "points") else "N/A" details = f"User: {item.user_id} | Session: {item.session_id} | Points: {point_count}" + n_filaments = self._filament_count_of_picks(item) + if n_filaments is not None: + details += f" | Filaments: {n_filaments}" + elif data_type == "filaments": + name = f"〰 {item.pickable_object_name}" + filaments = item.filaments + kinds = sorted({f.curve.kind for f in filaments if getattr(f, "curve", None) is not None}) + details = f"User: {item.user_id} | Session: {item.session_id} | Filaments: {len(filaments)}" + if kinds: + details += f" | Curves: {', '.join(kinds)}" elif data_type == "meshes": name = f"🕸 {item.pickable_object_name}" details = f"User: {item.user_id} | Session: {item.session_id}" @@ -1008,7 +1034,11 @@ def _create_annotation_item_widget(self, data_type: str, item: Any) -> QWidget: item.pickable_object_name if hasattr(item, "pickable_object_name") else "Unknown", ) name = f"🖌 {seg_name}" - details = f"User: {item.user_id} | Session: {item.session_id}" + seg_type = SEGMENTATION_TYPE_LABELS[segmentation_type_of(item)] + details = ( + f"User: {item.user_id} | Session: {item.session_id} | Type: {seg_type} | " + f"Voxel: {item.voxel_size:g} Å" + ) else: name = str(item) details = "" @@ -1050,6 +1080,16 @@ def _create_annotation_item_widget(self, data_type: str, item: Any) -> QWidget: ) return widget + def _filament_count_of_picks(self, picks: Any) -> Optional[int]: + """Number of filaments (distinct instance IDs > 0) in picks of a filament object, else None.""" + try: + obj = picks.run.root.get_object(picks.pickable_object_name) + if obj is None or not is_filament_object(obj): + return None + return len({int(p.instance_id) for p in picks.points if getattr(p, "instance_id", 0)}) + except Exception: + return None + def _create_portal_link_button(self, item: Any) -> Optional[QPushButton]: """Create a CryoET Data Portal link button for an item if applicable.""" portal_url = self.session_interface.get_portal_link(item) diff --git a/src/copick_shared_ui/widgets/instances/__init__.py b/src/copick_shared_ui/widgets/instances/__init__.py new file mode 100644 index 0000000..d17c050 --- /dev/null +++ b/src/copick_shared_ui/widgets/instances/__init__.py @@ -0,0 +1,5 @@ +"""Instance browsing widgets.""" + +from copick_shared_ui.widgets.instances.instance_browser import InstanceBrowserWidget + +__all__ = ["InstanceBrowserWidget"] diff --git a/src/copick_shared_ui/widgets/instances/instance_browser.py b/src/copick_shared_ui/widgets/instances/instance_browser.py new file mode 100644 index 0000000..f0c8636 --- /dev/null +++ b/src/copick_shared_ui/widgets/instances/instance_browser.py @@ -0,0 +1,490 @@ +"""A compact table of instances (filaments, pick groups, instance or panoptic segments) with stepping, focus, +visibility and simple edit actions. Host applications fill it with ``InstanceRow``s and react to its signals; it +never touches copick data itself. + +Each instance is one two-line row (``CompactItemDelegate``): swatch and title (``#12``, ``ribosome #3``), and a +caption with its numbers (``245 pts · score 0.93``, ``1.42 µm · catmull-rom``, ``12,345 voxels``). The header is a +"sort by" menu (ID, count, length, score, label).""" + +from typing import Callable, Iterable, List, Optional, Set + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QBrush, QColor +from qtpy.QtWidgets import ( + QAbstractButton, + QAbstractItemView, + QCheckBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +from copick_shared_ui.util.instances import ( + InstanceRow, + format_id_set, + format_length, + instance_row_caption, + instance_row_title, + parse_id_set, +) +from copick_shared_ui.widgets.compact_delegate import ( + CAPTION_ROLE, + SWATCH_ROLE, + CompactItemDelegate, + SortMenu, + make_compact, +) +from copick_shared_ui.widgets.flow_layout import FlowLayout + +_COLUMNS = ["", "Instances"] +_COL_VISIBLE, _COL_MAIN = range(len(_COLUMNS)) +_KEY_ROLE = Qt.UserRole + 1 # the row key (instance ID, or segment index for panoptic rows) +_ID_ROLE = Qt.UserRole + 2 # the instance ID + +#: Sort keys of the header menu: (key, menu text, InstanceRow attribute). +_SORT_KEYS = ( + ("id", "ID", "instance_id"), + ("count", "Count", "count"), + ("length", "Length", "length"), + ("score", "Score", "score"), + ("label", "Label", "label"), +) + + +class InstanceBrowserWidget(QWidget): + """Browse and act on the instances of one entity. + + Signals carry row keys (``InstanceRow.row_key``: the instance ID, or the segment index for panoptic rows). + """ + + current_changed = Signal(int) + focus_requested = Signal(int) + visibility_changed = Signal(object) # set of visible row keys + new_requested = Signal() + delete_requested = Signal(object) # list of row keys + merge_requested = Signal(object, int) # (source row keys, target row key) + reverse_requested = Signal(int) + color_by_instance_toggled = Signal(bool) + selection_changed = Signal(object) # list of selected row keys (user selection only, not set_selected_keys) + + def __init__( + self, + parent: Optional[QWidget] = None, + title: str = "Instances", + button_factory: Optional[Callable[[str, str, Callable], QAbstractButton]] = None, + ): + """``button_factory(text, tooltip, slot)`` makes the action buttons, so a host can match its own buttons + (default: flat tool buttons).""" + super().__init__(parent) + self._button_factory = button_factory + self._rows: List[InstanceRow] = [] + self._kind = "" + self._title = title + self._updating = False + self._setup_ui() + self.set_capabilities() + self._update_header() + + # ------------------------------------------------------------------------------------------------------------ + # UI + # ------------------------------------------------------------------------------------------------------------ + + def _tool(self, text: str, tip: str, slot) -> QAbstractButton: + if self._button_factory is not None: + return self._button_factory(text, tip, slot) + b = QToolButton() + b.setText(text) + b.setToolTip(tip) + b.setAutoRaise(True) + b.clicked.connect(slot) + return b + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + head = QWidget() + head_layout = FlowLayout(head, h_spacing=8) + self._header = QLabel() + head_layout.addWidget(self._header) + self._color_cb = QCheckBox("Colour by instance") + self._color_cb.setChecked(True) + self._color_cb.toggled.connect(self.color_by_instance_toggled) + head_layout.addWidget(self._color_cb) + layout.addWidget(head) + + self._filter = QLineEdit() + self._filter.setPlaceholderText("Filter: IDs (e.g. 1-5, 9) or text") + self._filter.setClearButtonEnabled(True) + self._filter.textChanged.connect(self._apply_filter) + layout.addWidget(self._filter) + + self._table = QTableWidget(0, len(_COLUMNS)) + self._table.setHorizontalHeaderLabels(_COLUMNS) + self._table.setSelectionBehavior(QAbstractItemView.SelectRows) + self._table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._table.setSortingEnabled(False) # rows are filled in the order of the sort menu + self._table.setAlternatingRowColors(False) + make_compact(self._table, CompactItemDelegate(self._table, inline=True), column=_COL_MAIN) + self._table.horizontalHeader().setSectionResizeMode(_COL_VISIBLE, QHeaderView.ResizeToContents) + self._table.setMinimumHeight(60) + self._sort = SortMenu( + self._table.horizontalHeader(), + "Instances", + [(k, t) for k, t, _a in _SORT_KEYS], + lambda _key, _desc: self._refill(), + column=_COL_MAIN, + ) + self._table.itemChanged.connect(self._on_item_changed) + self._table.currentCellChanged.connect(self._on_current_cell_changed) + self._table.itemSelectionChanged.connect(self._on_selection_changed) + self._table.cellDoubleClicked.connect(lambda r, _c: self._emit_focus_row(r)) + layout.addWidget(self._table, 1) + + bar_widget = QWidget() + bar = FlowLayout(bar_widget, h_spacing=2, v_spacing=2, center=True) + self._prev_btn = self._tool("◀", "Previous instance", lambda: self.step(-1)) + self._next_btn = self._tool("▶", "Next instance", lambda: self.step(1)) + self._focus_btn = self._tool("🎯", "Focus the current instance", self._on_focus) + self._show_all_btn = self._tool("👁", "Show all instances", self.show_all) + self._isolate_btn = self._tool("◎", "Show only the selected instances", self._on_isolate) + self._hide_btn = self._tool("🚫", "Hide the selected instances", self._on_hide) + self._new_btn = self._tool("+", "New instance ID", self.new_requested.emit) + self._delete_btn = self._tool("🗑", "Delete the selected instances", self._on_delete) + self._merge_btn = self._tool("⧉", "Merge the selected instances into the current one", self._on_merge) + self._reverse_btn = self._tool("⇄", "Reverse the current filament", self._on_reverse) + for b in ( + self._prev_btn, + self._next_btn, + self._focus_btn, + self._show_all_btn, + self._isolate_btn, + self._hide_btn, + self._new_btn, + self._merge_btn, + self._reverse_btn, + self._delete_btn, + ): + bar.addWidget(b) + layout.addWidget(bar_widget) + + # ------------------------------------------------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------------------------------------------------ + + def set_title(self, title: str) -> None: + self._title = title + self._update_header() + + def set_capabilities( + self, + new: bool = False, + delete: bool = False, + merge: bool = False, + reverse: bool = False, + color_toggle: bool = False, + merge_tip: Optional[str] = None, + ) -> None: + """Show only the actions the current entity supports (``merge_tip`` renames the merge action, e.g. "Join the + selected filaments end to end").""" + self._new_btn.setVisible(new) + self._delete_btn.setVisible(delete) + self._merge_btn.setVisible(merge) + self._merge_btn.setToolTip(merge_tip or "Merge the selected instances into the current one") + self._reverse_btn.setVisible(reverse) + self._color_cb.setVisible(color_toggle) + + def set_color_by_instance(self, on: bool) -> None: + self._color_cb.blockSignals(True) + self._color_cb.setChecked(on) + self._color_cb.blockSignals(False) + + def set_rows(self, rows: Iterable[InstanceRow], kind: str = "") -> None: + """Replace the rows, keeping the current row and the visibility of rows that stay.""" + previous_current = self.current_key() + hidden = {r.row_key for r in self._rows if not r.visible} + self._rows = list(rows) + for r in self._rows: + if r.row_key in hidden: + r.visible = False + self._kind = kind + # Offer only the sort keys some row has (no lengths for picks, no scores for segmentations, ...). + self._sort.set_options( + [(k, t) for k, t, a in _SORT_KEYS if k == "id" or any(getattr(r, a) not in (None, "") for r in self._rows)], + ) + self._refill(previous_current) + self._update_header() + + def set_sort(self, key: str, descending: bool = False) -> None: + """Sort by ``id``, ``count``, ``length``, ``score`` or ``label`` (what the header menu does).""" + self._sort.select(key, descending) + + def _sorted_rows(self) -> List[InstanceRow]: + attr = {k: a for k, _t, a in _SORT_KEYS}.get(self._sort.key, "instance_id") + + def key(r: InstanceRow): + v = getattr(r, attr) + missing = v is None or v == "" + return (missing, v if not missing else 0, r.instance_id, r.row_key) + + rows = sorted(self._rows, key=key, reverse=self._sort.descending) + if self._sort.descending: # keep rows without a value last + rows = [r for r in rows if not key(r)[0]] + [r for r in rows if key(r)[0]] + return rows + + def _refill(self, keep_current: Optional[int] = None) -> None: + """Fill the table in sort order, keeping the current row.""" + current = self.current_key() if keep_current is None else keep_current + self._updating = True + rows = self._sorted_rows() + self._table.setRowCount(len(rows)) + for i, row in enumerate(rows): + self._fill_row(i, row) + self._updating = False + self._apply_filter() + if current is not None: + self.set_current(current, emit=False) + + def clear(self) -> None: + self.set_rows([], "") + + def rows(self) -> List[InstanceRow]: + return list(self._rows) + + def current_key(self) -> Optional[int]: + r = self._table.currentRow() + if r < 0: + return None + item = self._table.item(r, _COL_MAIN) + return None if item is None else int(item.data(_KEY_ROLE)) + + def selected_keys(self) -> List[int]: + keys = [] + for index in self._table.selectionModel().selectedRows(_COL_MAIN): + item = self._table.item(index.row(), _COL_MAIN) + if item is not None: + keys.append(int(item.data(_KEY_ROLE))) + return keys + + def set_selected_keys(self, keys: Iterable[int], emit: bool = False) -> None: + """Select the rows of ``keys`` (e.g. to mirror a selection made in the 3D view).""" + keys = {int(k) for k in keys} + if set(self.selected_keys()) == keys: + return + updating, self._updating = self._updating, not emit + selection = self._table.selectionModel() + selection.clearSelection() + model = self._table.model() + for k in keys: + row = self._table_row_of(k) + if row is not None: + selection.select(model.index(row, _COL_MAIN), selection.Select | selection.Rows) + self._updating = updating + if emit: + self.selection_changed.emit(self.selected_keys()) + + def visible_keys(self) -> Set[int]: + return {r.row_key for r in self._rows if r.visible} + + def set_current(self, key: int, emit: bool = True) -> None: + row = self._table_row_of(key) + if row is None: + return + if not emit: + self._updating = True + self._table.setCurrentCell(row, _COL_MAIN) + self._table.scrollToItem(self._table.item(row, _COL_MAIN)) + self._updating = False + + def set_visible(self, keys: Iterable[int], emit: bool = True) -> None: + keys = set(keys) + for r in self._rows: + r.visible = r.row_key in keys + self._sync_checkboxes() + if emit: + self.visibility_changed.emit(self.visible_keys()) + + def show_all(self) -> None: + self.set_visible([r.row_key for r in self._rows]) + + def step(self, delta: int) -> None: + """Move to the next/previous visible, unfiltered row (in table order) and request focus on it.""" + candidates = [ + r for r in range(self._table.rowCount()) if not self._table.isRowHidden(r) and self._row_visible(r) + ] + if not candidates: + return + current = self._table.currentRow() + if current in candidates: + target = candidates[(candidates.index(current) + delta) % len(candidates)] + elif current < 0: + target = candidates[0] if delta > 0 else candidates[-1] + elif delta > 0: # the current row is hidden: continue from its position + target = next((c for c in candidates if c > current), candidates[0]) + else: + target = next((c for c in reversed(candidates) if c < current), candidates[-1]) + self._table.setCurrentCell(target, _COL_MAIN) + self._emit_focus_row(target) + + # ------------------------------------------------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------------------------------------------------ + + def _fill_row(self, i: int, row: InstanceRow) -> None: + vis = QTableWidgetItem() + vis.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) + vis.setCheckState(Qt.Checked if row.visible else Qt.Unchecked) + vis.setToolTip("Show / hide") + self._table.setItem(i, _COL_VISIBLE, vis) + r, g, b, a = (float(c) for c in row.color[:4]) + tint = QBrush(QColor.fromRgbF(r, g, b, 0.2)) # the row carries the instance colour, as in the object tables + vis.setBackground(tint) + + main = QTableWidgetItem(instance_row_title(row, self._kind)) + main.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + main.setData(_KEY_ROLE, int(row.row_key)) + main.setData(_ID_ROLE, int(row.instance_id)) + main.setData(CAPTION_ROLE, instance_row_caption(row, self._kind)) + main.setData(SWATCH_ROLE, QColor.fromRgbF(r, g, b, max(a, 0.2))) + main.setBackground(tint) + main.setToolTip(self._tooltip(row)) + self._table.setItem(i, _COL_MAIN, main) + + @staticmethod + def _tooltip(row: InstanceRow) -> str: + lines = [f"ID {row.instance_id}", f"count {row.count:,}"] + if row.score is not None: + lines.append(f"score {row.score:.3f}") + if row.length is not None: + lines.append(f"length {row.length:,.0f} Å ({format_length(row.length)})") + if row.label: + lines.append(row.label) + return "\n".join(lines) + + def _table_row_of(self, key: int) -> Optional[int]: + for r in range(self._table.rowCount()): + item = self._table.item(r, _COL_MAIN) + if item is not None and int(item.data(_KEY_ROLE)) == int(key): + return r + return None + + def _row_by_key(self, key: int) -> Optional[InstanceRow]: + for r in self._rows: + if r.row_key == key: + return r + return None + + def _row_visible(self, table_row: int) -> bool: + item = self._table.item(table_row, _COL_VISIBLE) + return item is not None and item.checkState() == Qt.Checked + + def _sync_checkboxes(self) -> None: + self._updating = True + for t in range(self._table.rowCount()): + key = int(self._table.item(t, _COL_MAIN).data(_KEY_ROLE)) + row = self._row_by_key(key) + if row is not None: + self._table.item(t, _COL_VISIBLE).setCheckState(Qt.Checked if row.visible else Qt.Unchecked) + self._updating = False + self._update_header() + + def _apply_filter(self) -> None: + text = self._filter.text().strip() + ids: Optional[Set[int]] = None + if text: + try: + ids = parse_id_set(text) + except ValueError: + ids = None + for t in range(self._table.rowCount()): + if not text: + self._table.setRowHidden(t, False) + continue + item = self._table.item(t, _COL_MAIN) + ident = int(item.data(_ID_ROLE)) + haystack = f"{item.text()} {item.data(CAPTION_ROLE) or ''}".lower() + match = (ident in ids) if ids is not None else (text.lower() in haystack) + self._table.setRowHidden(t, not match) + + def _update_header(self) -> None: + n = len(self._rows) + hidden = n - len(self.visible_keys()) + text = f"{self._title} — {n}" + if hidden: + text += f" ({hidden} hidden)" + self._header.setText(text) + hidden_ids = [r.instance_id for r in self._rows if not r.visible] + self._header.setToolTip(f"Hidden: {format_id_set(hidden_ids)}" if hidden_ids else "") + has_rows = n > 0 + for b in (self._prev_btn, self._next_btn, self._focus_btn, self._show_all_btn, self._isolate_btn): + b.setEnabled(has_rows) + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + if self._updating or item.column() != _COL_VISIBLE: + return + key = int(self._table.item(item.row(), _COL_MAIN).data(_KEY_ROLE)) + row = self._row_by_key(key) + if row is None: + return + row.visible = item.checkState() == Qt.Checked + self._update_header() + self.visibility_changed.emit(self.visible_keys()) + + def _on_selection_changed(self) -> None: + if not self._updating: + self.selection_changed.emit(self.selected_keys()) + + def _on_current_cell_changed(self, row: int, _col: int, prev_row: int, _prev_col: int) -> None: + if self._updating or row < 0 or row == prev_row: + return + item = self._table.item(row, _COL_MAIN) + if item is not None: + self.current_changed.emit(int(item.data(_KEY_ROLE))) + + def _emit_focus_row(self, table_row: int) -> None: + item = self._table.item(table_row, _COL_MAIN) + if item is not None: + self.focus_requested.emit(int(item.data(_KEY_ROLE))) + + def _on_focus(self) -> None: + key = self.current_key() + if key is not None: + self.focus_requested.emit(key) + + def _on_isolate(self) -> None: + keys = self.selected_keys() or ([self.current_key()] if self.current_key() is not None else []) + if keys: + self.set_visible(keys) + + def _on_hide(self) -> None: + keys = set(self.selected_keys()) + if keys: + self.set_visible(self.visible_keys() - keys) + + def _on_delete(self) -> None: + keys = self.selected_keys() + if keys: + self.delete_requested.emit(keys) + + def _on_merge(self) -> None: + keys = self.selected_keys() + target = self.current_key() + if len(keys) < 2: + return + if target not in keys: + target = min(keys) + self.merge_requested.emit([k for k in keys if k != target], target) + + def _on_reverse(self) -> None: + key = self.current_key() + if key is not None: + self.reverse_requested.emit(key) diff --git a/src/copick_shared_ui/workers/data_worker.py b/src/copick_shared_ui/workers/data_worker.py index c6f1f19..117b6ec 100644 --- a/src/copick_shared_ui/workers/data_worker.py +++ b/src/copick_shared_ui/workers/data_worker.py @@ -48,6 +48,18 @@ def load_data(self) -> tuple[Optional[List[Any]], Optional[str]]: data = tomograms elif self.data_type == "picks": data = list(self.run.picks) + for picks in data: # read the point files here, not on the UI thread + if self._cancelled: + return None, "Cancelled" + _ = picks.points + elif self.data_type == "filaments": + if not hasattr(self.run, "filaments"): # copick without the Filaments entity + return [], None + data = list(self.run.filaments) + for filaments in data: # read the filament files here, not on the UI thread + if self._cancelled: + return None, "Cancelled" + _ = filaments.filaments elif self.data_type == "meshes": data = list(self.run.meshes) elif self.data_type == "segmentations": diff --git a/tests/conftest.py b/tests/conftest.py index 9b0c750..07ac783 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,3 +33,19 @@ def setup_cache_directory(cache): yield Path(cache_root) manager._caches.clear() + + +@pytest.fixture +def project(tmp_path): + """A small filesystem copick project with a filament object.""" + import copick + from copick.ops.open import new_config + + cfg = tmp_path / "config.json" + new_config(str(cfg), str(tmp_path / "overlay")) + root = copick.from_file(str(cfg)) + root.new_object(name="microtubule", is_particle=True, radius=120, filament={"polar": True}) + root.new_object(name="ribosome", is_particle=True, radius=150) + root.new_object(name="membrane", is_particle=False) + root.save_config(str(cfg)) + return copick.from_file(str(cfg)), cfg diff --git a/tests/test_compact_rows.py b/tests/test_compact_rows.py new file mode 100644 index 0000000..aede322 --- /dev/null +++ b/tests/test_compact_rows.py @@ -0,0 +1,203 @@ +"""Compact two-line rows: the delegate, the sort menu, the instance browser and its title/caption helpers.""" + +import pytest + +from copick_shared_ui.util.instances import InstanceRow, format_length, instance_row_caption, instance_row_title + + +def test_titles_and_captions_per_kind(): + assert instance_row_title(InstanceRow(12, count=245, score=0.934), "picks") == "#12" + assert instance_row_caption(InstanceRow(12, count=245, score=0.934), "picks") == "245 pts · score 0.93" + assert instance_row_title(InstanceRow(0, count=3, label="unassigned"), "picks") == "unassigned" + assert instance_row_caption(InstanceRow(0, count=1, label="unassigned"), "picks") == "1 pt" + + fil = InstanceRow(3, count=900, score=1.0, length=14_210.0, label="catmull-rom") + assert instance_row_caption(fil, "filaments") == "1.42 µm · score 1.00 · catmull-rom" + assert instance_row_caption(InstanceRow(4, count=1, label="pending"), "filaments") == "pending · 1 control point" + + assert instance_row_caption(InstanceRow(7, count=12345, label="ribosome"), "instance") == "12,345 voxels" + assert instance_row_title(InstanceRow(3, count=10, label="virion", key=5), "panoptic") == "virion #3" + assert instance_row_title(InstanceRow(0, count=10, label="membrane", key=6), "panoptic") == "membrane (stuff)" + + assert format_length(8420.0) == "842 nm" and format_length(None) == "" + + +def test_delegate_size_and_paint(qtbot): + from qtpy.QtCore import QRect, Qt + from qtpy.QtGui import QColor, QFontMetrics, QImage, QPainter + from qtpy.QtWidgets import QStyleOptionViewItem, QTableWidget, QTableWidgetItem + + from copick_shared_ui.widgets.compact_delegate import ( + CAPTION_ROLE, + CHIP_ROLE, + MULTI_SWATCH, + SWATCH_ROLE, + CompactItemDelegate, + make_compact, + ) + + table = QTableWidget(2, 1) + qtbot.addWidget(table) + delegate = make_compact(table) + for row, swatch in ((0, QColor("red")), (1, MULTI_SWATCH)): + item = QTableWidgetItem("a-very-long-object-name-that-does-not-fit " * 3) + item.setData(CAPTION_ROLE, "data-portal · 237075 · 6.13 Å") + item.setData(CHIP_ROLE, "inst") + item.setData(SWATCH_ROLE, swatch) + table.setItem(row, 0, item) + + fm = QFontMetrics(table.font()) + h = delegate.row_height(table.font()) + assert h > 2 * fm.height() * 0.85 and table.rowHeight(0) == h # two lines, applied to the view + option = QStyleOptionViewItem() + option.font = table.font() + assert delegate.sizeHint(option, table.model().index(0, 0)).width() <= 80 # never forces the column wide + + image = QImage(220, h, QImage.Format_ARGB32) + image.fill(Qt.white) + painter = QPainter(image) + option.rect = QRect(0, 0, 220, h) + option.palette = table.palette() + for row in (0, 1): + CompactItemDelegate().paint(painter, option, table.model().index(row, 0)) + painter.end() + assert any(image.pixelColor(x, h // 4) != QColor(Qt.white) for x in range(0, 220, 3)) # something was drawn + + +def test_sort_menu_labels_and_selects(qtbot): + from qtpy.QtCore import Qt + from qtpy.QtWidgets import QTableWidget + + from copick_shared_ui.widgets.compact_delegate import SortMenu, make_compact + + table = QTableWidget(0, 1) + qtbot.addWidget(table) + make_compact(table) + chosen = [] + menu = SortMenu( + table.horizontalHeader(), + "Object", + [("default", "Default"), ("user", "User")], + lambda k, d: chosen.append((k, d)), + ) + assert menu.text == "Object ▾" + menu.select("user") + assert chosen[-1] == ("user", False) and "by user" in menu.text + assert table.model().headerData(0, Qt.Horizontal) == menu.text + actions = [a.text() for a in menu.menu().actions() if a.text()] + assert actions == ["Default", "User", "Descending"] + menu.select("user", True) + assert chosen[-1] == ("user", True) and menu.text.endswith("↓") + + +@pytest.fixture +def browser(qtbot): + from copick_shared_ui.widgets.instances import InstanceBrowserWidget + + w = InstanceBrowserWidget(title="Pick instances") + qtbot.addWidget(w) + rows = [ + InstanceRow(1, count=30, score=0.5), + InstanceRow(2, count=10, score=0.9), + InstanceRow(3, count=20, score=None), + InstanceRow(0, count=5, label="unassigned"), + ] + w.set_rows(rows, kind="picks") + return w + + +def _order(w): + from copick_shared_ui.widgets.instances.instance_browser import _COL_MAIN, _KEY_ROLE + + return [int(w._table.item(r, _COL_MAIN).data(_KEY_ROLE)) for r in range(w._table.rowCount())] + + +def test_browser_is_two_columns_with_captions(browser): + from copick_shared_ui.widgets.compact_delegate import CAPTION_ROLE + from copick_shared_ui.widgets.instances.instance_browser import _COL_MAIN + + assert browser._table.columnCount() == 2 + first = browser._table.item(0, _COL_MAIN) + assert first.text() == "unassigned" and first.data(CAPTION_ROLE) == "5 pts" + assert browser._table.item(1, _COL_MAIN).data(CAPTION_ROLE) == "30 pts · score 0.50" + # sort keys are offered only when some row has them: no lengths for picks + keys = [a.text() for a in browser._sort.menu().actions() if a.text()] + assert keys == ["ID", "Count", "Score", "Label", "Descending"] + + +def test_browser_sort_menu_orders_rows_and_stepping(browser): + assert _order(browser) == [0, 1, 2, 3] + browser.set_sort("count", descending=True) + assert _order(browser) == [1, 3, 2, 0] + browser.set_sort("score", descending=True) + assert _order(browser) == [2, 1, 3, 0] # rows without a score last + seen = [] + browser.focus_requested.connect(seen.append) + browser.set_current(2, emit=False) + browser.step(1) + assert seen == [1] # stepping follows the sorted order + # a refresh keeps the order and the current row + rows = [InstanceRow(i, count=c, score=s) for i, c, s in ((1, 30, 0.5), (2, 10, 0.9), (3, 20, None))] + browser.set_rows(rows, kind="picks") + assert _order(browser)[:2] == [2, 1] and browser.current_key() == 1 + + +def test_browser_filter_matches_ids_and_caption_text(browser): + from copick_shared_ui.widgets.instances.instance_browser import _COL_MAIN, _KEY_ROLE + + def shown(): + t = browser._table + return sorted(int(t.item(r, _COL_MAIN).data(_KEY_ROLE)) for r in range(t.rowCount()) if not t.isRowHidden(r)) + + browser._filter.setText("1-2") + assert shown() == [1, 2] + browser._filter.setText("score 0.9") + assert shown() == [2] + browser._filter.setText("unassigned") + assert shown() == [0] + + +def test_browser_panoptic_titles(qtbot): + from copick_shared_ui.widgets.instances import InstanceBrowserWidget + from copick_shared_ui.widgets.instances.instance_browser import _COL_MAIN + + w = InstanceBrowserWidget(title="Panoptic segments") + qtbot.addWidget(w) + w.set_rows( + [InstanceRow(0, count=100, label="membrane", key=1), InstanceRow(2, count=40, label="virion", key=2)], + kind="panoptic", + ) + assert {w._table.item(r, _COL_MAIN).text() for r in range(2)} == {"membrane (stuff)", "virion #2"} + w.set_current(2, emit=False) + assert w.current_key() == 2 + + +def test_arrow_keys_wrap_around(qtbot, browser): + from qtpy.QtCore import Qt + + from copick_shared_ui.widgets.instances.instance_browser import _COL_MAIN + + table = browser._table + table.setFocus() + table.setCurrentCell(0, _COL_MAIN) + qtbot.keyClick(table, Qt.Key_Up) + assert table.currentRow() == table.rowCount() - 1 # up on the first row wraps to the last + qtbot.keyClick(table, Qt.Key_Down) + assert table.currentRow() == 0 # down on the last wraps to the first + qtbot.keyClick(table, Qt.Key_Down) + assert table.currentRow() == 1 # in between, the normal move + browser._filter.setText("1-2") # rows 0 (unassigned) and 3 hidden + table.setCurrentCell(2, _COL_MAIN) + qtbot.keyClick(table, Qt.Key_Down) + assert table.currentRow() == 1 # wraps to the first shown row + + +def test_browser_selection_signal_and_mirroring(browser): + seen = [] + browser.selection_changed.connect(seen.append) + browser.set_selected_keys([1, 3]) # mirrored from elsewhere: no signal + assert sorted(browser.selected_keys()) == [1, 3] and seen == [] + browser._table.selectRow(0) # the user selects: signal with the keys + assert seen and seen[-1] == [0] + browser.set_selected_keys([2], emit=True) + assert seen[-1] == [2] diff --git a/tests/test_filament_session.py b/tests/test_filament_session.py new file mode 100644 index 0000000..4fe6c33 --- /dev/null +++ b/tests/test_filament_session.py @@ -0,0 +1,302 @@ +import numpy as np +import pytest + +from copick_shared_ui.util.filament_session import FilamentEditSession + + +@pytest.fixture +def run(project): + root, _ = project + r = root.new_run("r1") + r.new_voxel_spacing(10.0) + return r + + +def test_trace_new_filaments_and_save_with_picks(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + assert s.active_id == 1 and not s.read_only + s.insert_point([0, 0, 0]) + assert s.ids() == [1] and 1 in s.pending and not s.polylines() + s.insert_point([100, 0, 0]) + s.insert_point([200, 50, 0]) + assert len(s.controls(1)) == 3 and s.kind(1) == "catmull-rom" + line = s.polylines()[1] + assert np.allclose(line[0], [0, 0, 0]) and np.allclose(line[-1], [200, 50, 0]) + assert np.linalg.norm(np.diff(line, axis=0), axis=1).max() <= 10.0 + 1e-6 + # nearest insert lands between the existing points + s.insert_point([50, -5, 0], mode="nearest") + assert np.allclose(s.controls(1)[1], [50, -5, 0]) + s.new_filament() + assert s.active_id == 2 + s.insert_point([0, 300, 0]) + s.insert_point([0, 300, 200]) + res = s.save(pick_spacing=40.0) + assert res["n_filaments"] == 2 and res["n_picks"] > 0 + fil = run.get_filaments(object_name="microtubule", user_id="tracer", session_id="1")[0] + fil.refresh() + assert sorted(fil.instance_ids().tolist()) == [1, 2] + assert all(f.curve_is_current() and f.curve.kind == "catmull-rom" for f in fil.filaments) + picks = run.get_picks(object_name="microtubule", user_id="tracer", session_id="1")[0] + assert set(picks.instance_ids().tolist()) == {1, 2} + assert not s.dirty + + +def test_reopen_edit_reverse_delete(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + for p in ([0, 0, 0], [100, 0, 0], [200, 0, 0]): + s.insert_point(p) + s.save() + fil = run.get_filaments(object_name="microtubule", user_id="tracer", session_id="1")[0] + s2 = FilamentEditSession.from_filaments(fil) + assert np.allclose(s2.controls(1), [[0, 0, 0], [100, 0, 0], [200, 0, 0]]) + s2.move_point(1, 1, [100, 40, 0]) + assert s2.dirty and s2.filaments[1].curve_is_current() + s2.reverse(1) + assert np.allclose(s2.controls(1)[0], [200, 0, 0]) + s2.remove_point(1, 0) + assert len(s2.controls(1)) == 2 + s2.delete_filament(1) + assert s2.ids() == [] and s2.to_list() == [] + + +def test_bspline_handles_fixed_count_and_convert(run): + from copick.models import CopickFilament, CopickFilamentCurve + from scipy.interpolate import splprep + + t = np.linspace(0, 3 * np.pi, 80) + pts = np.stack([300 * np.cos(t / 3), 300 * np.sin(t / 3), 20 * t], axis=1) + tck, _ = splprep(pts.T, s=len(pts) * 9.0) + fil = run.new_filaments("microtubule", "0", user_id="fit") + fil.filaments = [CopickFilament.from_curve(1, CopickFilamentCurve.from_tck(tck, step=10.0))] + fil.meta.voxel_spacing = 10.0 + fil.store() + s = FilamentEditSession.from_filaments(fil) + assert s.read_only # session 0 + assert s.kind(1) == "bspline" and not s.can_add_remove(1) + n = len(s.controls(1)) + cps = s.controls(1) + cps[2] += [0, 0, 30] + s.move_point(1, 2, cps[2]) + assert s.kind(1) == "bspline" and len(s.controls(1)) == n and s.filaments[1].curve_is_current() + with pytest.raises(ValueError): + s.insert_point([0, 0, 0], instance_id=1) + s.convert_to_catmull_rom(1) + assert s.kind(1) == "catmull-rom" and s.can_add_remove(1) + s.insert_point([0, 0, 0], instance_id=1, mode="prepend") + # saving a tool session under a user identity + res = s.save(user_id="me", session_id="2") + assert res["filaments"].session_id == "2" and res["filaments"].filaments[0].curve.kind == "catmull-rom" + + +def test_filament_without_curve_gets_derived_handles(run): + from copick.models import CopickFilament + + line = [(float(x), 0.0, 0.0) for x in range(0, 201, 10)] + fil = run.new_filaments("microtubule", "1", user_id="u") + fil.filaments = [CopickFilament(instance_id=5, points=line)] + fil.meta.voxel_spacing = 10.0 + fil.store() + s = FilamentEditSession.from_filaments(fil) + assert s.kind(5) == "catmull-rom" and len(s.controls(5)) >= 2 + s.move_point(5, 0, [0, 20, 0]) + assert s.filaments[5].curve is not None and s.filaments[5].curve_is_current() + + +def test_undo_redo_steps_and_grouped_drag(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + recorded = [] + s.on_history = recorded.append + s.insert_point([0, 0, 0]) + s.insert_point([100, 0, 0]) + s.insert_point([200, 0, 0]) + assert recorded == ["Add filament point"] * 3 and s.can_undo and not s.can_redo + + # a drag (many moves) is one step + s.begin_step("Move filament point") + for x in (110, 120, 130): + s.move_point(1, 1, [x, 10, 0]) + s.end_step() + assert recorded[-1] == "Move filament point" and len(recorded) == 4 + assert np.allclose(s.controls(1)[1], [130, 10, 0]) + + assert s.undo() == "Move filament point" + assert np.allclose(s.controls(1)[1], [100, 0, 0]) # back before the whole drag + assert s.undo() == "Add filament point" + assert len(s.controls(1)) == 2 + assert s.redo() == "Add filament point" and len(s.controls(1)) == 3 + assert s.redo() == "Move filament point" and np.allclose(s.controls(1)[1], [130, 10, 0]) + assert s.redo() is None + + # undoing down to one point makes the filament pending again, and the curve regenerates on redo + s.undo(), s.undo(), s.undo() + assert 1 in s.pending and 1 not in s.filaments + s.redo() + assert s.filaments[1].curve_is_current() + + # a new edit clears the redo stack + s.insert_point([0, 50, 0]) + assert not s.can_redo + + +def test_undo_filament_level_edits(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + for p in ([0, 0, 0], [100, 0, 0], [200, 0, 0]): + s.insert_point(p) + before = s.controls(1) + s.reverse(1) + assert np.allclose(s.controls(1), before[::-1]) + assert s.undo() == "Reverse filament" and np.allclose(s.controls(1), before) + + s.new_filament() + assert s.active_id == 2 + assert s.undo() == "New filament" and s.active_id == 1 + + s.delete_filament(1) + assert s.ids() == [] + assert s.undo() == "Delete filament" and s.ids() == [1] and np.allclose(s.controls(1), before) + + # no-ops and failed edits record nothing + n = len(s._undo) + s.delete_filament(99) + s.reverse(99) + s.begin_step("nothing") + s.end_step() + assert len(s._undo) == n + + # undo after saving marks the set dirty again + s.save() + assert not s.dirty + s.undo() + assert s.dirty + + +def _straight(s, instance_id=None, n=5): + for x in np.linspace(0, 400, n): + s.insert_point([x, 0.0, 0.0], instance_id=instance_id) + + +def test_cut_catmull_rom_keeps_handles_and_is_undoable(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + for p in ([0, 0, 0], [100, 50, 0], [200, 0, 0], [300, 50, 0], [400, 0, 0]): + s.insert_point(p) + before = s.controls(1) + s.filaments[1] = s.filaments[1].model_copy(update={"score": 0.7, "polarity_known": True}) + near = s.filaments[1].points[len(s.filaments[1].points) // 2 + 3] # just past the middle control point + a, b = s.cut(np.asarray(near) + [0, 0, 5.0], tolerance=20.0) + assert (a, b) == (1, 2) + ca, cb = s.controls(1), s.controls(2) + assert np.allclose(ca[:3], before[:3]) and np.allclose(cb[1:], before[3:]) # untouched handles + assert np.allclose(ca[-1], cb[0]) # the cut point ends one piece and starts the other + for i in (1, 2): + f = s.filaments[i] + assert f.curve.kind == "catmull-rom" and f.curve_is_current() + assert f.score == 0.7 and f.polarity_known + assert np.allclose(s.filaments[1].points[-1], s.filaments[2].points[0], atol=1e-6) + + assert s.undo() == "Cut filament" + assert s.ids() == [1] and np.allclose(s.controls(1), before) + assert s.redo() == "Cut filament" and s.ids() == [1, 2] + + +def test_cut_at_a_control_point_and_errors(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + _straight(s) # control points every 100 Å + a, b = s.cut([203, 4, 0]) # within one step of the middle control point: split there + assert len(s.controls(a)) == 3 and len(s.controls(b)) == 3 + assert np.allclose(s.controls(a)[-1], [200, 0, 0]) and np.allclose(s.controls(b)[0], [200, 0, 0]) + + with pytest.raises(ValueError, match="too close to an end"): + s.cut([2, 0, 0], instance_id=a) + with pytest.raises(ValueError, match="No filament within"): + s.cut([100, 500, 0], tolerance=50.0) + assert s.nearest_filament([390, 10, 0])[0] == b + + +def test_cut_linear_and_bspline(run): + from copick.models import CopickFilament, CopickFilamentCurve + + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + s.filaments[1] = CopickFilament.from_control_points( + 1, + [[0, 0, 0], [200, 0, 0], [400, 100, 0]], + step=10.0, + kind="linear", + ) + s.cut([100, 0, 0]) + assert s.kind(1) == "linear" and s.kind(2) == "linear" + + t = np.linspace(0, 1, 8) + ctrl = np.stack([t * 400, 60 * np.sin(t * 3), np.zeros_like(t)], axis=1) + knots = np.concatenate([[0, 0, 0], np.linspace(0, 1, 6), [1, 1, 1]]) + curve = CopickFilamentCurve(kind="bspline", control_points=ctrl.tolist(), step=10.0, degree=3, knots=knots.tolist()) + s.filaments[5] = CopickFilament.from_curve(5, curve) + pts = np.asarray(s.filaments[5].points) + mid = pts[len(pts) // 2] + a, b = s.cut(mid, instance_id=5) + assert s.kind(a) == "catmull-rom" and s.kind(b) == "catmull-rom" + joined = np.vstack([s.filaments[a].points, s.filaments[b].points]) + from copick.util.filaments import distances_to_polyline + + assert distances_to_polyline(joined, pts).max() < 10.0 # the pieces follow the fit within half a step-ish + + +def test_cut_keeps_an_empty_new_filament_active(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + _straight(s) + s.new_filament() + assert s.active_id == 2 and 2 not in s.ids() + a, b = s.cut([150, 0, 0]) + assert (a, b) == (1, 3) and s.active_id == 2 + + +def test_delete_keeps_the_position(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + for i in (1, 2, 3): + _straight(s, instance_id=i) + s.active_id = 2 + s.delete_filament(2) + assert s.active_id == 3 # the next one, as removing a particle steps on + s.delete_filament(3) + assert s.active_id == 1 # the previous one at the end + s.undo() + assert s.active_id == 3 + + +def test_join_chains_nearest_ends_and_is_undoable(run): + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + for x in (0, 100, 200): + s.insert_point([x, 0, 0], instance_id=1) + for x in (600, 500, 400): # traced backwards: joining must flip it + s.insert_point([x, 0, 0], instance_id=2) + for x in (-400, -300, -200): # before the start of 1 + s.insert_point([x, 0, 0], instance_id=3) + s.filaments[1] = s.filaments[1].model_copy(update={"score": 0.8}) + before = {i: s.filaments[i] for i in (1, 2, 3)} + target = s.join([2, 3, 1], target=1) + assert target == 1 and s.ids() == [1] and s.active_id == 1 + cps = s.controls(1) + assert np.allclose(cps[:, 0], [-400, -300, -200, 0, 100, 200, 400, 500, 600]) # ordered along 1's direction + f = s.filaments[1] + assert f.curve_is_current() and f.score == 0.8 + assert s.undo() == "Join filaments" + assert s.ids() == [1, 2, 3] and all(s.filaments[i] == before[i] for i in (1, 2, 3)) + with pytest.raises(ValueError, match="at least two"): + s.join([1]) + + +def test_join_touching_ends_keeps_one_point_and_bspline_parts(run): + from copick.models import CopickFilament, CopickFilamentCurve + + s = FilamentEditSession(run, "microtubule", "tracer", "1", step=10.0) + s.filaments[1] = CopickFilament.from_control_points(1, [[0, 0, 0], [100, 0, 0]], step=10.0, kind="linear") + s.filaments[2] = CopickFilament.from_control_points(2, [[100, 0, 0], [200, 50, 0]], step=10.0, kind="linear") + s.join([1, 2]) + assert s.kind(1) == "linear" and len(s.controls(1)) == 3 # the shared end point once + + t = np.linspace(0, 1, 8) + ctrl = np.stack([300 + t * 400, 60 * np.sin(t * 3), np.zeros_like(t)], axis=1) + knots = np.concatenate([[0, 0, 0], np.linspace(0, 1, 6), [1, 1, 1]]) + curve = CopickFilamentCurve(kind="bspline", control_points=ctrl.tolist(), step=10.0, degree=3, knots=knots.tolist()) + s.filaments[5] = CopickFilament.from_curve(5, curve) + s.join([1, 5]) + assert s.ids() == [1] and s.kind(1) == "catmull-rom" and s.filaments[1].curve_is_current() diff --git a/tests/test_filaments.py b/tests/test_filaments.py new file mode 100644 index 0000000..843399b --- /dev/null +++ b/tests/test_filaments.py @@ -0,0 +1,131 @@ +import numpy as np +import pytest + +from copick_shared_ui.util import filaments as fil + + +def helix(n=200, r=50.0, pitch=400.0, turns=2.0): + t = np.linspace(0, 2 * np.pi * turns, n) + return np.stack([r * np.cos(t), r * np.sin(t), pitch * t / (2 * np.pi)], axis=1) + + +def test_resample_spacing_and_length(): + line = np.array([[0.0, 0, 0], [100.0, 0, 0]]) + pts = fil.resample_by_arc_length(line, 30.0) + assert np.allclose(pts[:, 0], [0, 30, 60, 90]) + assert fil.resample_by_arc_length(line, 30.0, include_end=True)[-1, 0] == pytest.approx(100.0) + assert fil.polyline_length(line) == pytest.approx(100.0) + assert fil.point_at_arc_length(line, 0.5) == pytest.approx([50.0, 0, 0]) + + +def test_frames_are_rotations_with_z_tangent_and_no_spin(): + pts = fil.resample_by_arc_length(helix(), 10.0) + t = fil.polyline_tangents(pts) + R = fil.parallel_transport_frames(t) + for i in range(len(R)): + assert np.allclose(R[i].T @ R[i], np.eye(3), atol=1e-9) + assert np.linalg.det(R[i]) == pytest.approx(1.0) + assert np.allclose(R[i][:, 2], t[i]) + # rotation-minimising: consecutive x axes stay close + dots = np.einsum("ij,ij->i", R[1:, :, 0], R[:-1, :, 0]) + assert dots.min() > 0.95 + + +def test_sample_filament_poses(): + pos, tr = fil.sample_filament_poses(np.array([[0.0, 0, 0], [0, 0, 100.0]]), 25.0) + assert len(pos) == 5 and tr.shape == (5, 4, 4) + assert np.allclose(tr[:, :3, 2], [0, 0, 1]) + assert np.allclose(tr[:, :3, 3], 0) and np.allclose(tr[:, 3], [0, 0, 0, 1]) + + +def test_filaments_to_picks_groups_and_orders(): + from copick.models import CopickFilament + + a = CopickFilament.from_control_points(3, [[0, 0, 0], [0, 0, 200]], step=10.0, score=0.5) + b = CopickFilament.from_control_points(7, [[100, 0, 0], [300, 0, 0]], step=10.0) + pos, tr, ids, sc = fil.filaments_to_picks([a, b], spacing=50.0) + assert list(ids) == [3] * 5 + [7] * 5 + assert np.allclose(sc[:5], 0.5) and np.allclose(sc[5:], 1.0) + assert np.all(np.diff(pos[:5, 2]) > 0) and np.all(np.diff(pos[5:, 0]) > 0) + assert np.allclose(tr[:5, :3, 2], [0, 0, 1]) and np.allclose(tr[5:, :3, 2], [1, 0, 0]) + + +def test_filaments_to_picks_empty(): + pos, tr, ids, sc = fil.filaments_to_picks([], 10.0) + assert pos.shape == (0, 3) and tr.shape == (0, 4, 4) and len(ids) == len(sc) == 0 + + +def test_editable_handles_kinds(): + from copick.models import CopickFilament, CopickFilamentCurve + + cr = CopickFilament.from_control_points(1, [[0, 0, 0], [50, 10, 0], [100, 0, 0]], step=5.0) + cps, kind, can = fil.editable_handles(cr) + assert kind == "catmull-rom" and can and len(cps) == 3 + # a filament without a curve gets derived catmull-rom handles + plain = CopickFilament(instance_id=2, points=[tuple(p) for p in cr.points]) + cps, kind, can = fil.editable_handles(plain) + assert kind == "catmull-rom" and can and len(cps) >= 2 + # a bspline keeps its knots: handles can move but not be added/removed + from scipy.interpolate import splprep + + pts = helix(60) + tck, _ = splprep(pts.T, s=len(pts) * 4.0) + bs = CopickFilament.from_curve(3, CopickFilamentCurve.from_tck(tck, step=5.0)) + cps, kind, can = fil.editable_handles(bs) + assert kind == "bspline" and not can and len(cps) == len(bs.curve.control_points) + + +@pytest.mark.parametrize( + "mode, point, index", + [ + ("append", [5, 0, 0], 3), + ("prepend", [5, 0, 0], 0), + ("nearest", [15, 1, 0], 2), + ("nearest", [-5, 0, 0], 0), + ("nearest", [30, 0, 0], 3), + ("nearest", [4, -1, 0], 1), + ], +) +def test_insert_control_point(mode, point, index): + cps = np.array([[0.0, 0, 0], [10, 0, 0], [20, 0, 0]]) + new, i = fil.insert_control_point(cps, point, mode) + assert i == index and len(new) == 4 and np.allclose(new[i], point) + + +def test_insert_into_empty_and_single(): + new, i = fil.insert_control_point(np.zeros((0, 3)), [1, 2, 3], "nearest") + assert i == 0 and new.shape == (1, 3) + new, i = fil.insert_control_point(new, [4, 5, 6], "nearest") + assert i == 1 + + +def test_nearest_point_to_line(): + pts = np.array([[0.0, 0, 50], [0, 0, 10], [30, 0, 10]]) + # ray along -z from z=100 down to z=-100 passing x=y=0: hits (0,0,50) first + assert fil.nearest_point_to_line(pts, [0, 0, 100], [0, 0, -100], 2.0) == 0 + assert fil.nearest_point_to_line(pts, [30, 0, 100], [30, 0, -100], 2.0) == 2 + assert fil.nearest_point_to_line(pts, [15, 0, 100], [15, 0, -100], 2.0) is None + + +def test_nearest_on_polyline_respects_order_on_a_hairpin(): + from copick_shared_ui.util.filaments import nearest_on_polyline + + # out along y=0, back along y=10: a point near the start is near both legs + line = np.array([[0, 0, 0], [100, 0, 0], [100, 10, 0], [0, 10, 0]], dtype=float) + s, p, d = nearest_on_polyline(line, [20, 4, 0]) + assert s == pytest.approx(20) and d == pytest.approx(4) + s, p, d = nearest_on_polyline(line, [20, 4, 0], from_arc_length=110) + assert s == pytest.approx(190) and np.allclose(p, [20, 10, 0]) + + +def test_direction_markers_point_along_the_filament(): + from copick_shared_ui.util.filaments import direction_markers + + line = np.array([[0, 0, 0], [1000, 0, 0], [1000, 1000, 0]], dtype=float) + pos, d = direction_markers(line, spacing=400.0) + assert len(pos) == len(d) >= 5 + assert np.allclose(np.linalg.norm(d, axis=1), 1.0) + assert np.allclose(d[0], [1, 0, 0]) and np.allclose(d[-1], [0, 1, 0]) # first leg along x, last along y + assert np.allclose(pos[0], [200, 0, 0]) and pos[-1][1] >= 900 # starts half a spacing in, ends near the end + rpos, rd = direction_markers(line[::-1], spacing=400.0) + assert np.allclose(rd[-1], [-1, 0, 0]) # reversed: arrows flip diff --git a/tests/test_instances.py b/tests/test_instances.py new file mode 100644 index 0000000..9cdbe81 --- /dev/null +++ b/tests/test_instances.py @@ -0,0 +1,99 @@ +import colorsys + +import numpy as np +import pytest + +from copick_shared_ui.util import instances as inst + + +def test_instance_rgb_matches_pr_formula(): + for i in (1, 2, 7, 300, 70001): + assert inst.instance_rgb(i) == pytest.approx(colorsys.hsv_to_rgb((i * 0.618033988749895) % 1.0, 0.65, 0.95)) + + +def test_instance_colors_float_and_uint8(): + base = (0.1, 0.2, 0.3, 0.5) + c = inst.instance_colors([0, 3, 3, 5], base) + assert c.shape == (4, 4) + assert tuple(c[0]) == pytest.approx(base) + assert tuple(c[1, :3]) == pytest.approx(inst.instance_rgb(3)) + assert c[1, 3] == pytest.approx(0.5) # alpha from base + u = inst.instance_colors([0, 3], (10, 20, 30, 255), dtype=np.uint8) + assert u.dtype == np.uint8 and tuple(u[0]) == (10, 20, 30, 255) + assert tuple(u[1, :3]) == tuple(np.round(np.array(inst.instance_rgb(3)) * 255).astype(np.uint8)) + + +def test_color_table_and_dict(): + t = inst.instance_color_table(5, hidden=[2]) + assert t.shape == (6, 4) and t.dtype == np.uint8 + assert tuple(t[0]) == (0, 0, 0, 0) and tuple(t[2]) == (0, 0, 0, 0) + assert t[3, 3] == 255 + d = inst.instance_color_dict([1, 4], alpha=0.5, hidden=[4]) + assert d[0][3] == 0 and d[4][3] == 0 and d[1][3] == 0.5 + + +def test_next_instance_id(): + assert inst.next_instance_id() == 1 + assert inst.next_instance_id([], np.array([], dtype=int)) == 1 + assert inst.next_instance_id([3, 1], {7}, np.array([2])) == 8 + assert inst.next_instance_id({4: 10, 9: 1}.keys(), set()) == 10 + + +@pytest.mark.parametrize( + "text, expected", + [("1-3, 5", {1, 2, 3, 5}), ("9", {9}), ("4 - 2;7", {2, 3, 4, 7}), ("", set()), ("1 2 3", {1, 2, 3})], +) +def test_parse_id_set(text, expected): + assert inst.parse_id_set(text) == expected + + +def test_parse_id_set_rejects_garbage(): + with pytest.raises(ValueError): + inst.parse_id_set("abc") + + +def test_format_id_set_round_trip(): + ids = {1, 2, 3, 5, 9, 10} + assert inst.format_id_set(ids) == "1-3, 5, 9-10" + assert inst.parse_id_set(inst.format_id_set(ids)) == ids + + +def test_counts_bboxes_merge_delete(): + v = np.zeros((4, 5, 6), dtype=np.uint16) + v[0, 0, 0:2] = 1 + v[1:3, 1:4, 2] = 2 + v[3, 4, 5] = 300 + assert inst.label_counts(v) == {1: 2, 2: 6, 300: 1} + boxes = inst.label_bboxes(v) + assert boxes[2] == (slice(1, 3), slice(1, 4), slice(2, 3)) + assert inst.bbox_center(boxes[2]) == pytest.approx([1.5, 2.0, 2.0]) + assert inst.merge_labels(v, [1, 300], 2) == 3 + assert inst.label_counts(v) == {2: 9} + assert inst.delete_labels(v, [2]) == 9 and not v.any() + + +def test_label_counts_large_dtype(): + v = np.array([0, 70001, 70001, 5], dtype=np.uint32) + assert inst.label_counts(v) == {5: 1, 70001: 2} + + +def test_panoptic_segments(): + labels = np.array([[0, 2, 2, 3], [3, 3, 2, 0]], dtype=np.uint16) + instance_ch = np.array([[0, 0, 0, 1], [1, 2, 0, 0]], dtype=np.uint16) + objects = {2: ("membrane", (0.5, 0.5, 0.5, 1.0)), 3: ("ribosome", (1.0, 0.0, 0.0, 1.0))} + seg, rows = inst.panoptic_segments(labels, instance_ch, objects) + assert seg.dtype == np.uint32 and seg[0, 0] == 0 + by = {(r.label, r.instance_id): r for r in rows} + assert set(by) == {("membrane", 0), ("ribosome", 1), ("ribosome", 2)} + assert by[("membrane", 0)].count == 3 and by[("membrane", 0)].color == (0.5, 0.5, 0.5, 1.0) + assert by[("ribosome", 1)].count == 2 + assert by[("ribosome", 1)].color[:3] == pytest.approx(inst.instance_rgb(1)) + # every voxel of a segment carries that segment's key + assert set(np.unique(seg[(labels == 3) & (instance_ch == 1)])) == {by[("ribosome", 1)].key} + + +def test_rows_from_points(): + rows = inst.rows_from_points([0, 1, 1, 2], [0.5, 1.0, 0.0, 0.25]) + assert [(r.instance_id, r.count) for r in rows] == [(0, 1), (1, 2), (2, 1)] + assert rows[1].score == pytest.approx(0.5) + assert rows[0].label == "unassigned" diff --git a/tests/test_package_metadata.py b/tests/test_package_metadata.py index f1e73db..adbbc3e 100644 --- a/tests/test_package_metadata.py +++ b/tests/test_package_metadata.py @@ -11,7 +11,7 @@ def test_alpha_runtime_contract_is_explicit(): assert project["requires-python"] == ">=3.11" assert set(project["dependencies"]) >= { - "copick>=2.0.0a1,<3", + "copick>=2.0.0a2,<3", "numpy>=2.0.2", "zarr>=3.1.6,<4", } diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..c0751ae --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,67 @@ +from types import SimpleNamespace + +from copick_shared_ui.core import types as ct + + +def test_segmentation_type_of_new_and_fallback(project): + root, _ = project + run = root.new_run("r1") + run.new_voxel_spacing(10.0) + inst = run.new_segmentation(10.0, "ribosome", "1", user_id="u", is_instance=True) + ml = run.new_segmentation(10.0, "labels", "1", user_id="u", is_multilabel=True) + assert ct.segmentation_type_of(inst) == "instance" + assert ct.segmentation_type_of(ml) == "multilabel" + # old copick-like objects + assert ct.segmentation_type_of(SimpleNamespace(is_multilabel=False)) == "binary" + assert ct.segmentation_type_of(SimpleNamespace(is_multilabel=True)) == "multilabel" + + +def test_object_types(project): + root, _ = project + run = root.new_run("r1") + picks = run.new_picks("ribosome", "1", user_id="u") + fils = run.new_filaments("microtubule", "1", user_id="u") + assert ct.copick_object_type(picks) == "picks" + assert ct.copick_object_type(fils) == "filaments" + assert ct.uri_object_type(fils) == "filaments" + assert ct.uri_object_type(run) is None + assert ct.copick_object_type(object()) is None + + +def test_filament_detection(project): + root, _ = project + assert ct.is_filament_object(root.get_object("microtubule")) + assert not ct.is_filament_object(root.get_object("ribosome")) + assert ct.filament_spec_of(root.get_object("microtubule")) == {"polar": True} + # PickableObject (config level) and plain metadata dicts work too + po = next(o for o in root.config.pickable_objects if o.name == "microtubule") + assert ct.is_filament_object(po) + legacy = SimpleNamespace(metadata={"copick": {"filament": {"polar": False}}}) + assert ct.filament_spec_of(legacy) == {"polar": False} and ct.is_filament_object(legacy) + + +def test_segmentation_type_flags(): + assert ct.segmentation_type_flags("binary") == {"is_multilabel": False, "is_instance": False, "is_panoptic": False} + assert ct.segmentation_type_flags("instance")["is_instance"] is True + assert ct.segmentation_type_flags("binary", explicit=False) == {"is_multilabel": False} + assert ct.supports_filaments() and ct.supports_segmentation_types() + + +def test_delete_binary_keeps_same_key_instance(project): + import numpy as np + + root, _ = project + run = root.new_run("r1") + run.new_voxel_spacing(10.0) + run.new_segmentation(10.0, "ribosome", "1", user_id="u").from_numpy(np.ones((4, 4, 4), dtype=np.uint8)) + inst = run.new_segmentation(10.0, "ribosome", "1", user_id="u", is_instance=True) + inst.from_numpy(np.full((4, 4, 4), 3, dtype=np.uint16)) + run.delete_segmentations( + name="ribosome", + user_id="u", + session_id="1", + voxel_size=10.0, + **ct.segmentation_type_flags("binary"), + ) + run.refresh_segmentations() + assert [s.segmentation_type for s in run.segmentations] == ["instance"] diff --git a/tests/test_uri_and_dialog.py b/tests/test_uri_and_dialog.py new file mode 100644 index 0000000..79b83cd --- /dev/null +++ b/tests/test_uri_and_dialog.py @@ -0,0 +1,110 @@ +import pytest + +from copick_shared_ui.widgets.cli.uri_widget import _assemble_uri, _parse_uri_simple + + +@pytest.mark.parametrize( + "values, uri", + [ + ( + { + "object_name": "ribosome", + "user_id": "u", + "session_id": "1", + "voxel_spacing": "10.0", + "segmentation_type": "instance", + }, + "ribosome:u/1@10.0?instance=true", + ), + ( + { + "object_name": "labels", + "user_id": "u", + "session_id": "1", + "voxel_spacing": "10.0", + "segmentation_type": "multilabel", + }, + "labels:u/1@10.0?multilabel=true", + ), + ({"object_name": "membrane", "user_id": "u", "session_id": "1", "voxel_spacing": "10.0"}, "membrane:u/1@10.0"), + ], +) +def test_segmentation_uri_round_trip(values, uri): + assert _assemble_uri("segmentation", values) == uri + parsed = _parse_uri_simple("segmentation", uri) + assert parsed["voxel_spacing"] == "10.0" # the query does not leak into the voxel spacing + assert parsed.get("segmentation_type", "") == values.get("segmentation_type", "") + assert parsed["object_name"] == values["object_name"] + + +def test_filaments_uri(): + assert _assemble_uri("filaments", {"object_name": "microtubule", "user_id": "u", "session_id": "1"}) == ( + "microtubule:u/1" + ) + assert _parse_uri_simple("filaments", "microtubule:u/1") == { + "object_name": "microtubule", + "user_id": "u", + "session_id": "1", + } + + +def test_edit_dialog_keeps_metadata_and_edits_filament(qtbot, project): + from copick_shared_ui.ui.edit_object_types_dialog import EditObjectTypesDialog + + root, _ = project + objs = [o.model_copy(deep=True) for o in root.config.pickable_objects] + mt = next(o for o in objs if o.name == "microtubule") + mt.metadata["copick"]["filament"]["custom_key"] = 1 # extra spec keys survive + mt.metadata["other"] = {"keep": True} + dlg = EditObjectTypesDialog(existing_objects=objs) + qtbot.addWidget(dlg) + row = [o.name for o in dlg.get_objects()].index("microtubule") + dlg._objects_table.selectRow(row) + dlg._edit_selected_object() + assert dlg._is_filament_cb.isChecked() and dlg._polarity_combo.currentText() == "Polar" + dlg._radius_spin.setValue(130.0) + dlg._rise_spin.setValue(9.4) + dlg._apply_changes() + edited = next(o for o in dlg.get_objects() if o.name == "microtubule") + assert edited.radius == 130.0 + assert edited.metadata["other"] == {"keep": True} + assert edited.metadata["copick"]["filament"] == {"polar": True, "custom_key": 1, "helical_rise_a": 9.4} + assert dlg.has_changes() + # unchecking "Is Particle" removes the filament declaration + dlg._objects_table.selectRow(row) + dlg._edit_selected_object() + dlg._is_particle_cb.setChecked(False) + assert not dlg._is_filament_cb.isChecked() + dlg._apply_changes() + edited = next(o for o in dlg.get_objects() if o.name == "microtubule") + assert "filament" not in edited.metadata.get("copick", {}) and edited.metadata["other"] == {"keep": True} + + +def test_instance_browser(qtbot): + from copick_shared_ui.util.instances import InstanceRow + from copick_shared_ui.widgets.instances import InstanceBrowserWidget + + w = InstanceBrowserWidget(title="Filaments") + qtbot.addWidget(w) + w.set_capabilities(new=True, delete=True, merge=True, reverse=True) + w.set_rows([InstanceRow(i, count=10 * i) for i in (1, 2, 3)], kind="filaments") + seen = [] + w.focus_requested.connect(seen.append) + w.step(1) + w.step(1) + assert seen == [1, 2] + vis = [] + w.visibility_changed.connect(vis.append) + w.set_visible({1, 3}) + assert vis[-1] == {1, 3} + w.step(1) # skips hidden 2 + assert seen[-1] == 3 + # rows keep visibility across refresh + w.set_rows([InstanceRow(i) for i in (1, 2, 3, 4)]) + assert w.visible_keys() == {1, 3, 4} + merged = [] + w.merge_requested.connect(lambda s, t: merged.append((sorted(s), t))) + w.set_current(2, emit=False) + w._table.selectAll() + w._on_merge() + assert merged == [([1, 3, 4], 2)] From 3f04f0b7e5704587aeae5015ea04eb36479ce3ae Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 6 Oct 2026 01:15:23 -0700 Subject: [PATCH 2/4] chore: require copick 2.0.0a3 copick 2.0.0a2 never reached PyPI (the release setup skipped it); 2.0.0a3 is the first published v2.0 alpha with filaments, curves, instance and panoptic segmentations. Pin and lock it. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- tests/test_package_metadata.py | 2 +- uv.lock | 104 ++++++++++++++++----------------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 508eb1a..e74f87c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dynamic = ["version"] dependencies = [ "qtpy", "pydantic>=2", - "copick>=2.0.0a2,<3", + "copick>=2.0.0a3,<3", "zarr>=3.1.6,<4", "numpy>=2.0.2", "superqt" diff --git a/tests/test_package_metadata.py b/tests/test_package_metadata.py index adbbc3e..6855939 100644 --- a/tests/test_package_metadata.py +++ b/tests/test_package_metadata.py @@ -11,7 +11,7 @@ def test_alpha_runtime_contract_is_explicit(): assert project["requires-python"] == ">=3.11" assert set(project["dependencies"]) >= { - "copick>=2.0.0a2,<3", + "copick>=2.0.0a3,<3", "numpy>=2.0.2", "zarr>=3.1.6,<4", } diff --git a/uv.lock b/uv.lock index 6feab6c..3e761e3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -592,7 +592,7 @@ wheels = [ [[package]] name = "copick" -version = "2.0.0a1" +version = "2.0.0a3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -625,9 +625,9 @@ dependencies = [ { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/40/65bd5bb0c774b964e489683fa541d8fda09b3c2bbe51b0fa9ec73011ff97/copick-2.0.0a1.tar.gz", hash = "sha256:39256a1671d82088e8a3aebd237b322e498d5b38e2f5ff008ba9f586bf87c101", size = 47806055, upload-time = "2026-08-18T16:41:20.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/26/065b6dc5ea4685ec8212329b1e72bc25523f6fe6958902cc4398d622fd63/copick-2.0.0a3.tar.gz", hash = "sha256:63ebfb8ea6fd3e4106d3e3e47c93181ed89f42a2388e68dee19c14f276e39d60", size = 47884273, upload-time = "2026-10-06T08:10:42.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/d7/a44a4a97bebd025b484e5c49f59627ba7ba67136d16d0f83f50a392864c1/copick-2.0.0a1-py3-none-any.whl", hash = "sha256:6c8002976c2e485d4e1d8814ee8aa096f76be8f82531ff3559e11e2469285ed9", size = 235457, upload-time = "2026-08-18T16:41:18.84Z" }, + { url = "https://files.pythonhosted.org/packages/86/25/0ea1d3d5a9c9fed0b270bcd533f798f6356654dfd09dd0264f060b40f367/copick-2.0.0a3-py3-none-any.whl", hash = "sha256:f83efe9e5f52e40c5641ac3018f4e25f26a27ff51f6d6f117ec8390d0299ea3e", size = 277010, upload-time = "2026-10-06T08:10:38.833Z" }, ] [[package]] @@ -666,7 +666,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = "==26.5.1" }, - { name = "copick", specifier = ">=2.0.0a1,<3" }, + { name = "copick", specifier = ">=2.0.0a3,<3" }, { name = "hatch-vcs", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.25.0" }, { name = "moto", extras = ["server"], marker = "extra == 'testing'", specifier = ">=5.1.0" }, @@ -1271,7 +1271,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2013,16 +2013,16 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "aiohttp" }, - { name = "dask" }, - { name = "deprecated" }, - { name = "fsspec", extra = ["s3"] }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "rangehttpserver" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "toolz" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" } }, + { name = "aiohttp", marker = "python_full_version <= '3.11'" }, + { name = "dask", marker = "python_full_version <= '3.11'" }, + { name = "deprecated", marker = "python_full_version <= '3.11'" }, + { name = "fsspec", extra = ["s3"], marker = "python_full_version <= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, + { name = "rangehttpserver", marker = "python_full_version <= '3.11'" }, + { name = "requests", marker = "python_full_version <= '3.11'" }, + { name = "scikit-image", marker = "python_full_version <= '3.11'" }, + { name = "toolz", marker = "python_full_version <= '3.11'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/45/b5b9933ec62028453dc841d25ea66d65405d77d2eb5bb34043621b0c294c/ome_zarr-0.16.0.tar.gz", hash = "sha256:0d31f3409c0f4ad67f04b9a4e9e8855e6e6b040e84d5c9be1095638f90d6eeb9", size = 86252, upload-time = "2026-04-14T09:43:32.354Z" } wheels = [ @@ -2048,18 +2048,18 @@ resolution-markers = [ "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "aiohttp" }, - { name = "dask" }, - { name = "deprecated" }, - { name = "fsspec", extra = ["s3"] }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "aiohttp", marker = "python_full_version > '3.11'" }, + { name = "dask", marker = "python_full_version > '3.11'" }, + { name = "deprecated", marker = "python_full_version > '3.11'" }, + { name = "fsspec", extra = ["s3"], marker = "python_full_version > '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "ome-zarr-models" }, - { name = "rangehttpserver" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "toolz" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ome-zarr-models", marker = "python_full_version > '3.11'" }, + { name = "rangehttpserver", marker = "python_full_version > '3.11'" }, + { name = "requests", marker = "python_full_version > '3.11'" }, + { name = "scikit-image", marker = "python_full_version > '3.11'" }, + { name = "toolz", marker = "python_full_version > '3.11'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/98/f39cf0ec00ad64996ed2d2d6059d15a30c7b8f559cd2a216589885001a6a/ome_zarr-0.18.0.tar.gz", hash = "sha256:5cc0fc682b0c177fa32e54f4479b2436f79850a171e83ad4c6b08fec32e66cd0", size = 106039, upload-time = "2026-06-17T16:22:07.76Z" } @@ -2072,10 +2072,10 @@ name = "ome-zarr-models" version = "1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pydantic", marker = "python_full_version > '3.11'" }, + { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "pydantic-zarr", version = "0.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/e7/c37b8896a24eabc76d6a5e7855f722792de5bdf3f5ccc562001738be4f7e/ome_zarr_models-1.6.tar.gz", hash = "sha256:00735ad939d2b5f6a4f583cc19af1e8a5247fa1e93f1f08191143cd913b9633a", size = 106913, upload-time = "2026-03-10T10:14:28.369Z" } @@ -2707,9 +2707,9 @@ resolution-markers = [ "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pydantic" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "pydantic", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/19/ad4f22b7a395408c90cc0f213d70cf5b9ed5f3b0db2b319cacff93b66850/pydantic_zarr-0.9.2.tar.gz", hash = "sha256:59f536cbc456ff4dc64d887d651476e2a6a2ca9f3ea13228dce4e3aeb1cf00b9", size = 80379, upload-time = "2026-03-18T14:38:30.766Z" } wheels = [ @@ -2732,9 +2732,9 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pydantic" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/57/aac9e500abe15719b7406d55871fc8787e7d0ebf83de5f98d44471139f70/pydantic_zarr-0.10.0.tar.gz", hash = "sha256:996b5b4225fb05757e7fff831ee7a75c61f54514a4c2493c7b79635bade7b415", size = 80374, upload-time = "2026-04-23T14:23:12.575Z" } wheels = [ @@ -3491,7 +3491,7 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -3573,7 +3573,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -3745,7 +3745,7 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -3768,7 +3768,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/38/5e2ecef5af2f4fd4a89bb8d6240de9458bab4d51a4cbd97aeb3a0cd618e2/tifffile-2026.6.1.tar.gz", hash = "sha256:626c892c0e899d959b9438e7c0e1491dc154a7fead1f1f37a991724a50eceba9", size = 429694, upload-time = "2026-05-31T23:57:12.165Z" } wheels = [ @@ -4248,12 +4248,12 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version < '3.12'" }, + { name = "google-crc32c", marker = "python_full_version < '3.12'" }, + { name = "numcodecs", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } wheels = [ @@ -4276,12 +4276,12 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version >= '3.12'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, + { name = "numcodecs", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/15/436cb1d3bbe86173bd44ce7a34ecb210d0c0416946e337858149a905ef5a/zarr-3.3.0.tar.gz", hash = "sha256:cd0c8cf738b4bb4807815bc1255acad5bdf1a7b7264b606c5a1bc0d0392a306b", size = 943626, upload-time = "2026-07-30T16:35:10.491Z" } wheels = [ From 3443f9c4547977dfcd3217c55cfbed9291497d16 Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 6 Oct 2026 01:17:38 -0700 Subject: [PATCH 3/4] ci: expect copick 2.0.0a3 in the release-wheel checks The wheel / bundle metadata checks still expected the 2.0.0a1 floor; the requirements now start at 2.0.0a3. Co-Authored-By: Claude Opus 5.5 --- tests/inspect_wheel_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/inspect_wheel_metadata.py b/tests/inspect_wheel_metadata.py index 96ebad3..e25fad1 100644 --- a/tests/inspect_wheel_metadata.py +++ b/tests/inspect_wheel_metadata.py @@ -13,7 +13,7 @@ def inspect_wheel(path: Path) -> None: requirements = metadata.get_all("Requires-Dist", []) assert metadata["Requires-Python"] == ">=3.11" - assert any(requirement.startswith("copick<3,>=2.0.0a1") for requirement in requirements), requirements + assert any(requirement.startswith("copick<3,>=2.0.0a3") for requirement in requirements), requirements assert any(requirement.startswith("numpy>=2.0.2") for requirement in requirements), requirements assert any(requirement.startswith("zarr<4,>=3.1.6") for requirement in requirements), requirements assert all("copick[all]" not in requirement for requirement in requirements), requirements From f6711135405b9d8fc0dfdf63a21cd50bab79c4c9 Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 6 Oct 2026 01:18:12 -0700 Subject: [PATCH 4/4] ci: drop the wheel metadata check It only restated the dependency floors from pyproject.toml and had to be edited on every version bump. The test workflow still builds the wheel. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-please.yml | 4 +--- .github/workflows/tests.yml | 3 --- tests/inspect_wheel_metadata.py | 30 ---------------------------- 3 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 tests/inspect_wheel_metadata.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 255f30a..736b518 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -78,9 +78,7 @@ jobs: run: uv lock --check - name: build - run: | - uv build - uv run --no-project --with packaging python tests/inspect_wheel_metadata.py dist/*.whl + run: uv build - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 139229a..e88c78b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,6 +64,3 @@ jobs: - name: Build artifacts run: uv build - - - name: Inspect wheel metadata - run: uv run --no-sync python tests/inspect_wheel_metadata.py dist/*.whl diff --git a/tests/inspect_wheel_metadata.py b/tests/inspect_wheel_metadata.py deleted file mode 100644 index e25fad1..0000000 --- a/tests/inspect_wheel_metadata.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Inspect built wheel metadata for the shared-UI alpha runtime contract.""" - -import argparse -import email -import zipfile -from pathlib import Path - - -def inspect_wheel(path: Path) -> None: - with zipfile.ZipFile(path) as archive: - metadata_name = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) - metadata = email.message_from_bytes(archive.read(metadata_name)) - - requirements = metadata.get_all("Requires-Dist", []) - assert metadata["Requires-Python"] == ">=3.11" - assert any(requirement.startswith("copick<3,>=2.0.0a3") for requirement in requirements), requirements - assert any(requirement.startswith("numpy>=2.0.2") for requirement in requirements), requirements - assert any(requirement.startswith("zarr<4,>=3.1.6") for requirement in requirements), requirements - assert all("copick[all]" not in requirement for requirement in requirements), requirements - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("wheel", type=Path) - arguments = parser.parse_args() - inspect_wheel(arguments.wheel) - - -if __name__ == "__main__": - main()