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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dynamic = ["version"]
dependencies = [
"qtpy",
"pydantic>=2",
"copick>=2.0.0a1,<3",
"copick>=2.0.0a3,<3",
"zarr>=3.1.6,<4",
"numpy>=2.0.2",
"superqt"
Expand Down
2 changes: 1 addition & 1 deletion src/copick_shared_ui/core/click_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
132 changes: 132 additions & 0 deletions src/copick_shared_ui/core/types.py
Original file line number Diff line number Diff line change
@@ -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")
118 changes: 115 additions & 3 deletions src/copick_shared_ui/ui/edit_object_types_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
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
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
Expand All @@ -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"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading