diff --git a/TUTORIAL.md b/TUTORIAL.md
index e8d62a8..5952b43 100644
--- a/TUTORIAL.md
+++ b/TUTORIAL.md
@@ -303,18 +303,21 @@ After creating the pick set:
### Understanding the Picks Table
-The Picks table displays:
+Each row of the Picks table shows one pick set on two lines:
-| Column | Description |
-|--------|-------------|
-| **User/Tool** | Who created the picks |
-| **Object** | Particle type (color-coded) |
-| **Session** | Session identifier |
+| Part | Description |
+|------|-------------|
+| **Eye** | Whether the set is shown (double-click the row to show or hide it) |
+| **Swatch and name** | Particle type, in its object color (segmentations also show a type chip: binary, multi, inst, pan) |
+| **Caption** | `user · session`; a 🔒 marks tool output, which is read-only |
+Click the table header to sort by name, user or session (and, for segmentations, type); the default order lists
+tool sets first. The search box (🔍) matches names, users, sessions and types.
+
**Table buttons** (bottom-right, on hover):
diff --git a/pyproject.toml b/pyproject.toml
index 1c24227..12caea5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,8 +9,8 @@ dependencies = [
"ChimeraX-Core>=1.13.dev202608172052",
"ChimeraX-ArtiaX>=0.7.0",
"ChimeraX-OME-Zarr>=1.0.0a1,<2",
- "copick[all]>=2.0.0a1,<3",
- "copick-shared-ui>=2.0.0a1,<3",
+ "copick[all]>=2.0.0a3,<3",
+ "copick-shared-ui>=2.0.0a3,<3",
"numpy>=2.0.2",
]
authors = [
@@ -63,6 +63,15 @@ tool_name = "Copick"
category = ["Volume Data", "General"]
synopsis = "Collaborative annotation of cryo-electron tomograms."
+[chimerax.tool."Copick Annotate"]
+tool_name = "Copick Annotate"
+category = ["Volume Data", "General"]
+synopsis = "Trace filaments, edit and browse instances of a copick run."
+
+[chimerax.command."copick annotate"]
+category = "General"
+synopsis = "Show or hide the Copick Annotate window."
+
[chimerax.command."copick start"]
category = "General"
synopsis = "Start a copick session or load a new config file."
@@ -143,6 +152,70 @@ synopsis = "Dock a tool window to an edge, float it, or tab it with another tool
category = "General"
synopsis = "Show a spherical spotlight of tomogram data around the active particle."
+[chimerax.command."copick open filaments"]
+category = "General"
+synopsis = "Show filament sets in the active run matching a copick URI."
+
+[chimerax.command."copick show filaments"]
+category = "General"
+synopsis = "Show filament sets in the active run matching a copick URI."
+
+[chimerax.command."copick hide filaments"]
+category = "General"
+synopsis = "Hide filament sets in the active run matching a copick URI."
+
+[chimerax.command."copick new filaments"]
+category = "General"
+synopsis = "Start tracing a new set of filaments in the active run."
+
+[chimerax.command."copick filament"]
+category = "General"
+synopsis = "Filament tracing actions on the active filament set."
+
+[chimerax.command."copick filament trace"]
+category = "General"
+synopsis = "Switch the filament trace mouse mode on or off."
+
+[chimerax.command."copick filament join"]
+category = "General"
+synopsis = "Join filaments end to end (default: those selected in the Annotate window)."
+
+[chimerax.command."copick filament cut"]
+category = "General"
+synopsis = "Cut mode on or off, or cut a filament in two at a point."
+
+[chimerax.command."copick filament save"]
+category = "General"
+synopsis = "Save the active filament set, optionally with sampled picks."
+
+[chimerax.command."copick new segmentation"]
+category = "General"
+synopsis = "Start a new instance segmentation in the active run."
+
+[chimerax.command."copick instance"]
+category = "General"
+synopsis = "Instance editing and browsing actions."
+
+[chimerax.command."copick instance edit"]
+category = "General"
+synopsis = "Edit an instance segmentation of the active run."
+
+[chimerax.command."copick instance brush"]
+category = "General"
+synopsis = "Set the instance paint brush radius."
+
+[chimerax.command."copick instance save"]
+category = "General"
+synopsis = "Save the edited instance segmentation."
+
+[chimerax.command."copick colorpicks"]
+category = "General"
+synopsis = "Colour particle lists by instance ID or by object."
+
+[chimerax.command."copick save"]
+category = "General"
+synopsis = "Save all edited picks, filaments and instance segmentations."
+
[chimerax.package-data]
"src/icons/" = ["*.png"]
"src/fonts/" = ["*.ttf", "*.txt"]
@@ -455,6 +528,274 @@ display_name = "Delete Picked"
icon = "delete.png"
description = "Delete picked particle."
+
+# Copick Edit tab: annotation, a few particle tools (duplicated from the Copick tab), filament tracing and instance
+# (segmentation) editing; at most as many buttons as the Copick tab
+
+[chimerax.provider."Edit Annotate"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Annotate"
+display_name = "Annotate"
+icon = "annotate.png"
+description = "Show or hide the Copick Annotate window (tracing, instance editing and browsing)."
+
+[chimerax.provider."Edit New Picks"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Annotate"
+display_name = "New Picks"
+icon = "new_picks.png"
+description = "Start a new pick set."
+
+[chimerax.provider."Edit New Filaments"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Annotate"
+display_name = "New Filaments"
+icon = "new_filament_set.png"
+description = "Start tracing a new filament set."
+
+[chimerax.provider."Edit New Instances"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Annotate"
+display_name = "New Instances"
+icon = "new_instance_seg.png"
+description = "Start a new instance segmentation."
+
+[chimerax.provider."Edit Save All"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Annotate"
+display_name = "Save All"
+icon = "save_all.png"
+description = "Save all edited picks, filaments and instances."
+
+[chimerax.provider."Edit mark plane"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Particles"
+mouse_mode = "mark plane"
+display_name = "Plane"
+icon = "plane.png"
+description = "Place a particle on the tomogram plane."
+
+[chimerax.provider."Edit Select"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Particles"
+mouse_mode = "select"
+display_name = "Select"
+icon = "select.png"
+description = "Select particles."
+
+[chimerax.provider."Edit Translate Picked"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Particles"
+mouse_mode = "translate picked particle"
+display_name = "Translate Picked"
+icon = "translate_picked.png"
+description = "Translate the particle under the mouse."
+
+[chimerax.provider."Edit Rotate Picked"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Particles"
+mouse_mode = "rotate picked particle"
+display_name = "Rotate Picked"
+icon = "rotate_picked.png"
+description = "Rotate the particle under the mouse."
+
+[chimerax.provider."Edit Delete Selected"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Particles"
+display_name = "Delete Selected"
+icon = "delete_selected.png"
+description = "Delete the selected particles."
+
+[chimerax.provider."Edit Delete Picked"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Particles"
+mouse_mode = "delete picked particle"
+display_name = "Delete Picked"
+icon = "delete.png"
+description = "Delete the particle under the mouse."
+
+[chimerax.provider."Trace Filament"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+mouse_mode = "trace copick filament"
+display_name = "Trace"
+icon = "trace_filament.png"
+description = "Trace filaments: click the tomogram plane to add control points, drag to move, shift-click to remove."
+
+[chimerax.provider."Cut Filament"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+mouse_mode = "cut copick filament"
+display_name = "Cut"
+icon = "cut_filament.png"
+description = "Cut filaments: click a filament on the tomogram plane to split it in two there."
+
+[chimerax.provider."Filament New"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "New"
+icon = "new_filament.png"
+description = "Start a new filament in the active set."
+
+[chimerax.provider."Filament Previous"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "Previous"
+icon = "prev_filament.png"
+description = "Previous filament."
+
+[chimerax.provider."Filament Next"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "Next"
+icon = "next_filament.png"
+description = "Next filament."
+
+[chimerax.provider."Filament Reverse"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "Reverse"
+icon = "reverse_filament.png"
+description = "Reverse the active filament."
+
+[chimerax.provider."Filament Join"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "Join"
+icon = "join_filament.png"
+description = "Join the filaments selected in the Copick Annotate filament list end to end."
+
+[chimerax.provider."Filament Delete"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "Delete"
+icon = "delete_filament.png"
+description = "Delete the active filament."
+
+[chimerax.provider."Filament Save"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Filaments"
+display_name = "Save"
+icon = "save_filaments.png"
+description = "Save the active filament set."
+
+[chimerax.provider."Paint Instance"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Instances"
+mouse_mode = "paint copick instance"
+display_name = "Paint"
+icon = "paint_instance.png"
+description = "Paint the current instance ID."
+
+[chimerax.provider."Erase Instance"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Instances"
+mouse_mode = "erase copick instance"
+display_name = "Erase"
+icon = "erase_instance.png"
+description = "Erase instance voxels."
+
+[chimerax.provider."Pick Instance"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Instances"
+mouse_mode = "pick copick instance"
+display_name = "Pick ID"
+icon = "pick_instance.png"
+description = "Make the clicked instance ID current."
+
+[chimerax.provider."Instance New ID"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Instances"
+display_name = "New ID"
+icon = "new_instance.png"
+description = "Use a new instance ID."
+
+[chimerax.provider."Instance Undo"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Instances"
+display_name = "Undo"
+icon = "undo_instance.png"
+description = "Undo the last instance edit."
+
+[chimerax.provider."Instance Save"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Instances"
+display_name = "Save"
+icon = "save_instances.png"
+description = "Save the edited instance segmentation."
+
+[chimerax.provider."layout-brush"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Brush"
+compact = "true"
+
+[chimerax.provider."Brush Small"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Brush"
+display_name = "S"
+icon = "brush_small.png"
+description = "Brush radius 25 Å."
+
+[chimerax.provider."Brush Medium"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Brush"
+display_name = "M"
+icon = "brush_medium.png"
+description = "Brush radius 75 Å."
+
+[chimerax.provider."Brush Large"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Brush"
+display_name = "L"
+icon = "brush_large.png"
+description = "Brush radius 200 Å."
+
+[chimerax.provider."Color By Instance"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Pick Colours"
+display_name = "By Instance"
+icon = "color_instances.png"
+description = "Colour particle lists by instance ID (matches filaments and instance segmentations)."
+
+[chimerax.provider."Color By Object"]
+manager = "toolbar"
+tab = "Copick Edit"
+section = "Pick Colours"
+display_name = "By Object"
+icon = "color_object.png"
+description = "Colour particle lists by their object's colour."
+
[tool.black]
line-length = 120
target_version = ['py311']
diff --git a/scripts/inspect_bundle.py b/scripts/inspect_bundle.py
index cb835f8..0c94984 100644
--- a/scripts/inspect_bundle.py
+++ b/scripts/inspect_bundle.py
@@ -17,8 +17,8 @@
EXPECTED_REQUIREMENTS = {
"chimerax-core": ">=1.13.dev202608172052",
"chimerax-ome-zarr": ">=1.0.0a1,<2",
- "copick": ">=2.0.0a1,<3",
- "copick-shared-ui": ">=2.0.0a1,<3",
+ "copick": ">=2.0.0a3,<3",
+ "copick-shared-ui": ">=2.0.0a3,<3",
"numpy": ">=2.0.2",
}
FORBIDDEN_DIRECT_REQUIREMENTS = {"aiohttp", "hatchling", "pydantic", "s3fs"}
diff --git a/src/__init__.py b/src/__init__.py
index e3f3b29..bf39a06 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -12,6 +12,13 @@ def start_tool(session, bi, ti):
from . import tool
return tool.CopickTool(session, ti.name)
+ if ti.name == "Copick Annotate":
+ if getattr(session, "copick", None) is None:
+ session.logger.warning("Start Copick first (copick start ).")
+ return None
+ from .ui.annotate_tool import get_annotate_tool
+
+ return get_annotate_tool(session)
@staticmethod
def register_command(bi, ci, logger):
diff --git a/src/cmd/core.py b/src/cmd/core.py
index ad1599f..19e51a5 100644
--- a/src/cmd/core.py
+++ b/src/cmd/core.py
@@ -244,9 +244,11 @@ def _find_tomogram_by_type(run, tomo_type: str, voxel_size: Optional[float] = No
return matches[0]
-def _next_session_id(run) -> str:
- """Generate the next available 'manual-X' session id for a run (see NewPickDialog)."""
- existing = {p.session_id.lower() for p in run.picks if p.session_id}
+def _next_session_id(run, entities=None) -> str:
+ """Generate the next available 'manual-X' session id for a run (see NewPickDialog); ``entities`` defaults to the
+ run's picks."""
+ entities = run.picks if entities is None else entities
+ existing = {str(p.session_id).lower() for p in entities if p.session_id}
counter = 1
while f"manual-{counter}" in existing:
counter += 1
@@ -600,6 +602,306 @@ def copick_spotlight(
session.logger.info(tool.spotlight.status())
+# ---------------------------------------------------------------------------------------------------------------------
+# Filaments
+# ---------------------------------------------------------------------------------------------------------------------
+
+
+def copick_open_filaments(session, uri: Optional[str] = None):
+ """Show filament sets in the active run matching the given copick URI (default: all)."""
+ _apply_to_entities(session, "filaments", uri, "_show_filaments_entity", "Showed")
+
+
+def copick_hide_filaments(session, uri: Optional[str] = None):
+ """Hide filament sets in the active run matching the given copick URI (default: all)."""
+ _apply_to_entities(session, "filaments", uri, "_hide_filaments_entity", "Hid")
+
+
+def _object_or_error(session, tool, object_name: str):
+ obj = tool.root.get_object(object_name)
+ if obj is None:
+ session.logger.error(f"Object '{object_name}' is not defined in the config. Add it via 'Edit Object Types'.")
+ return obj
+
+
+def copick_new_filaments(session, object_name: str, user_id: Optional[str] = None, session_id: Optional[str] = None):
+ """Start tracing a new set of filaments of ``object_name`` in the active run."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ run = _active_run(session, tool)
+ if run is None:
+ return
+ obj = _object_or_error(session, tool, object_name)
+ if obj is None:
+ return
+ from copick_shared_ui.core.types import is_filament_object
+
+ if not is_filament_object(obj):
+ session.logger.warning(f"'{object_name}' is not declared a filament (Edit Object Types → Is Filament).")
+ user_id = user_id or tool.root.user_id or "ArtiaX"
+ session_id = session_id or _next_session_id(run, list(getattr(run, "filaments", [])))
+ tool.new_filaments(object_name, user_id, session_id)
+ session.logger.info(f"Tracing new filaments '{object_name}:{user_id}/{session_id}' (right mouse: trace).")
+
+
+def _filament_controller(session):
+ tool = _get_running_tool(session)
+ if tool is None:
+ return None
+ if tool.filaments.active is None:
+ session.logger.warning("No active filament set. Open one (copick open filaments) or start one.")
+ return None
+ return tool.filaments
+
+
+def copick_filament(session, action: str, instance_id: Optional[int] = None):
+ """Filament tracing actions on the active filament set."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ ctl = tool.filaments
+ if action in ("on", "off", "toggle"): # 'copick filament trace on|off|toggle'
+ from ..filaments.controller import TRACE_MODE
+
+ tracing = ctl.editing and ctl.right_mode == TRACE_MODE
+ on = (not tracing) if action == "toggle" else action == "on"
+ if on:
+ ctl.start_tracing()
+ elif tracing or action == "off":
+ ctl.stop_tracing()
+ return
+ if _filament_controller(session) is None:
+ return
+ if action == "new":
+ i = ctl.new_filament()
+ session.logger.info(f"New filament {i}: click on the tomogram plane to add control points.")
+ elif action == "select":
+ if instance_id is not None:
+ ctl.set_active_filament(instance_id)
+ elif action == "focus":
+ ctl.focus(instance_id if instance_id is not None else ctl.edit_session.active_id)
+ elif action == "go":
+ if instance_id is not None:
+ ctl.go_to_filament(instance_id)
+ elif action == "next":
+ ctl.step_filament(1)
+ elif action == "previous":
+ ctl.step_filament(-1)
+ elif action == "reverse":
+ ctl.reverse(instance_id)
+ elif action == "delete":
+ ctl.delete_filament(instance_id)
+ elif action == "convert":
+ ctl.convert(instance_id)
+
+
+def copick_filament_trace(session, state: str = "toggle"):
+ copick_filament(session, state)
+
+
+def copick_filament_join(session, ids=None, target: Optional[int] = None):
+ """Join filaments end to end; default: the filaments selected in the 3D view (Ctrl-click their tubes), or else in
+ the Copick Annotate window's filament list (the two are kept in step)."""
+ tool = _get_running_tool(session)
+ if tool is None or _filament_controller(session) is None:
+ return
+ if not ids:
+ ids = tool.filaments.scene_selected_ids()
+ annotate = tool.annotate(create=False)
+ if not ids and annotate is not None:
+ ids = annotate.filament_panel.browser.selected_keys()
+ if target is None:
+ active = tool.filaments.edit_session.active_id
+ target = active if active in ids else None
+ if len(ids or []) < 2:
+ session.logger.warning("copick: select two or more filaments in the Copick Annotate filament list to join.")
+ return
+ tool.filaments.join(ids, target=target)
+
+
+def copick_filament_cut(session, state: str = "toggle", at=None, instance_id: Optional[int] = None):
+ """Cut mode on / off, or cut the filament nearest to the point ``at`` (Angstrom) in two."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ ctl = tool.filaments
+ if at is not None:
+ if _filament_controller(session) is not None:
+ ctl.cut_at(at, instance_id=instance_id, tolerance=float("inf"))
+ return
+ from ..filaments.controller import CUT_MODE
+
+ cutting = ctl.editing and ctl.right_mode == CUT_MODE
+ on = (not cutting) if state == "toggle" else state == "on"
+ if on:
+ ctl.start_cutting()
+ elif cutting:
+ ctl.stop_tracing()
+
+
+def copick_filament_save(
+ session,
+ user_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ pick_spacing: Optional[float] = None,
+):
+ """Save the active filament set (optionally with picks sampled every ``pick_spacing`` Angstrom)."""
+ ctl = _filament_controller(session)
+ if ctl is not None:
+ ctl.save(user_id=user_id, session_id=session_id, pick_spacing=pick_spacing)
+
+
+# ---------------------------------------------------------------------------------------------------------------------
+# Instance segmentations
+# ---------------------------------------------------------------------------------------------------------------------
+
+
+def copick_new_segmentation(
+ session,
+ object_name: str,
+ user_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+):
+ """Start a new (empty) instance segmentation of ``object_name`` at the shown tomogram's voxel spacing."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ run = _active_run(session, tool)
+ if run is None or _object_or_error(session, tool, object_name) is None:
+ return
+ user_id = user_id or tool.root.user_id or "ArtiaX"
+ session_id = session_id or _next_session_id(run, list(run.segmentations))
+ tool.new_segmentation(object_name, user_id, session_id)
+
+
+def _ids(text: str):
+ from copick_shared_ui.util.instances import parse_id_set
+
+ return sorted(parse_id_set(text))
+
+
+def copick_instance(session, action: str, ids: Optional[str] = None, into: Optional[int] = None):
+ """Instance editing and browsing actions on the edited / active instance segmentation."""
+ from chimerax.core.commands import run as run_command
+
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ ctl = tool.segmentations
+ if action in ("paint", "erase", "pick"):
+ run_command(session, f"ui mousemode right '{action} copick instance'", log=False)
+ return
+ if action in ("show", "hide", "isolate"):
+ seg = ctl.active_key
+ if seg is None:
+ session.logger.warning("No instance or panoptic segmentation is active.")
+ return
+ keys = {r.row_key for r in ctl.rows(seg)}
+ chosen = set(_ids(ids or ""))
+ visible = {"show": keys, "hide": keys - chosen, "isolate": chosen}[action]
+ if action == "show" and chosen:
+ visible = chosen | {k for k, s in ctl.models[seg].surfaces.items() if s.display}
+ ctl.set_visible(seg, visible)
+ return
+ if not ctl.editing:
+ session.logger.warning("No instance segmentation is being edited (copick instance edit ).")
+ return
+ if action == "new":
+ session.logger.info(f"Current instance ID {ctl.new_id()}")
+ elif action == "id":
+ ctl.set_current_id(int(ids))
+ elif action == "undo":
+ ctl.undo()
+ elif action == "delete":
+ ctl.relabel(_ids(ids or ""), 0)
+ elif action == "merge":
+ if into is None:
+ session.logger.error("copick instance merge IDS into ID")
+ return
+ ctl.relabel(_ids(ids or ""), int(into))
+
+
+def copick_instance_edit(session, uri: Optional[str] = None):
+ """Edit an instance segmentation of the active run (matching ``uri``; loads level 0)."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ run = _active_run(session, tool)
+ if run is None:
+ return
+ entities = [
+ e for e in _resolve_entities(session, tool, run, uri, "segmentation") if getattr(e, "is_instance", False)
+ ]
+ if len(entities) != 1:
+ session.logger.error(f"'{uri}' matches {len(entities)} instance segmentations; give a unique URI.")
+ return
+ seg = entities[0]
+ tool.segmentations.start_editing(seg, on_ready=lambda: tool._mw.set_entity_active(seg, True))
+
+
+def copick_instance_brush(session, radius: float):
+ tool = _get_running_tool(session)
+ if tool is not None:
+ tool.segmentations.paint_radius = float(radius)
+ tool.settings.paint_radius = float(radius)
+
+
+def copick_instance_save(session, user_id: Optional[str] = None, session_id: Optional[str] = None):
+ tool = _get_running_tool(session)
+ if tool is not None:
+ tool.segmentations.save(user_id=user_id, session_id=session_id)
+
+
+def copick_save(session):
+ """Save every edited annotation: picks, filament sets opened from an editable file, and the edited instance
+ segmentation (when it was opened from an editable store)."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ tool.store()
+ ctl = tool.segmentations
+ edit = ctl.edit
+ if edit is not None and edit.dirty:
+ if edit.read_only or edit.source is None:
+ session.logger.warning("copick: the edited instance segmentation is new or read-only; use 'Save…'.")
+ else:
+ ctl.save()
+ active = tool.filaments.active
+ if active is not None and active[0].dirty and (active[0].read_only or active[0].source is None):
+ session.logger.warning("copick: the active filament set is new or read-only; use 'Save…'.")
+ session.logger.info("copick: saved edited annotations.")
+
+
+def copick_annotate(session, state: str = "show", page: Optional[str] = None):
+ """Show, hide or toggle the Copick Annotate window (filament tracing, instance editing and browsing)."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ annotate = tool.annotate(create=state != "hide")
+ if annotate is None:
+ return
+ shown = annotate.tool_window.shown
+ if state == "hide" or (state == "toggle" and shown):
+ annotate.tool_window.shown = False
+ return
+ annotate.show_page(page or _current_page(annotate))
+
+
+def _current_page(annotate) -> str:
+ return {0: "filaments", 1: "instances", 2: "picks"}.get(annotate.tabs.currentIndex(), "filaments")
+
+
+def copick_color_picks(session, state: str = "on"):
+ """Colour every loaded particle list by instance ID (on) or by object (off)."""
+ tool = _get_running_tool(session)
+ if tool is None:
+ return
+ tool.settings.color_picks_by_instance = state == "on"
+ for picks in list(tool.picks_map):
+ tool.set_color_picks_by_instance(picks, state == "on")
+
+
def register_copick(logger):
"""Register all commands with ChimeraX, and specify expected arguments."""
from chimerax.core.commands import (
@@ -607,6 +909,7 @@ def register_copick(logger):
CmdDesc,
EnumOf,
FileNameArg,
+ Float3Arg,
FloatArg,
FloatsArg,
IntArg,
@@ -777,8 +1080,184 @@ def register_copick_spotlight():
)
register("copick spotlight", desc, copick_spotlight)
+ def register_annotation_commands():
+ from chimerax.core.commands import IntArg as _IntArg
+
+ entity_url = "help:user/commands/copick_filaments.html"
+ for verb in ("open", "show"):
+ register(
+ f"copick {verb} filaments",
+ CmdDesc(
+ optional=[("uri", StringArg)],
+ synopsis="Show filament sets matching a copick URI.",
+ url=entity_url,
+ ),
+ copick_open_filaments,
+ )
+ register(
+ "copick hide filaments",
+ CmdDesc(
+ optional=[("uri", StringArg)],
+ synopsis="Hide filament sets matching a copick URI.",
+ url=entity_url,
+ ),
+ copick_hide_filaments,
+ )
+ register(
+ "copick new filaments",
+ CmdDesc(
+ required=[("object_name", StringArg)],
+ keyword=[("user_id", StringArg), ("session_id", StringArg)],
+ synopsis="Start tracing a new set of filaments in the active run.",
+ url=entity_url,
+ ),
+ copick_new_filaments,
+ )
+ register(
+ "copick filament trace",
+ CmdDesc(
+ optional=[("state", EnumOf(["on", "off", "toggle"]))],
+ synopsis="Switch the filament trace mouse mode on or off.",
+ url=entity_url,
+ ),
+ copick_filament_trace,
+ )
+ register(
+ "copick filament",
+ CmdDesc(
+ required=[
+ (
+ "action",
+ EnumOf(["new", "select", "focus", "go", "next", "previous", "reverse", "delete", "convert"]),
+ ),
+ ],
+ optional=[("instance_id", _IntArg)],
+ synopsis="Filament tracing actions on the active filament set.",
+ url=entity_url,
+ ),
+ copick_filament,
+ )
+ register(
+ "copick filament join",
+ CmdDesc(
+ optional=[("ids", ListOf(IntArg))],
+ keyword=[("target", IntArg)],
+ synopsis="Join filaments end to end (default: those selected in the 3D view or the Annotate window).",
+ url=entity_url,
+ ),
+ copick_filament_join,
+ )
+ register(
+ "copick filament cut",
+ CmdDesc(
+ optional=[("state", EnumOf(["on", "off", "toggle"]))],
+ keyword=[("at", Float3Arg), ("instance_id", _IntArg)],
+ synopsis="Cut mode on or off, or cut a filament in two at a point.",
+ url=entity_url,
+ ),
+ copick_filament_cut,
+ )
+ register(
+ "copick filament save",
+ CmdDesc(
+ keyword=[("user_id", StringArg), ("session_id", StringArg), ("pick_spacing", FloatArg)],
+ synopsis="Save the active filament set, optionally with sampled picks.",
+ url=entity_url,
+ ),
+ copick_filament_save,
+ )
+ inst_url = "help:user/commands/copick_instance.html"
+ register(
+ "copick new segmentation",
+ CmdDesc(
+ required=[("object_name", StringArg)],
+ keyword=[("user_id", StringArg), ("session_id", StringArg)],
+ synopsis="Start a new instance segmentation in the active run.",
+ url=inst_url,
+ ),
+ copick_new_segmentation,
+ )
+ register(
+ "copick instance",
+ CmdDesc(
+ required=[
+ (
+ "action",
+ EnumOf(
+ [
+ "paint",
+ "erase",
+ "pick",
+ "new",
+ "id",
+ "undo",
+ "delete",
+ "merge",
+ "show",
+ "hide",
+ "isolate",
+ ],
+ ),
+ ),
+ ],
+ optional=[("ids", StringArg)],
+ keyword=[("into", _IntArg)],
+ synopsis="Instance editing and browsing actions.",
+ url=inst_url,
+ ),
+ copick_instance,
+ )
+ register(
+ "copick instance edit",
+ CmdDesc(
+ optional=[("uri", StringArg)],
+ synopsis="Edit an instance segmentation of the active run.",
+ url=inst_url,
+ ),
+ copick_instance_edit,
+ )
+ register(
+ "copick instance brush",
+ CmdDesc(required=[("radius", FloatArg)], synopsis="Set the instance paint brush radius (Å).", url=inst_url),
+ copick_instance_brush,
+ )
+ register(
+ "copick instance save",
+ CmdDesc(
+ keyword=[("user_id", StringArg), ("session_id", StringArg)],
+ synopsis="Save the edited instance segmentation.",
+ url=inst_url,
+ ),
+ copick_instance_save,
+ )
+ register(
+ "copick save",
+ CmdDesc(synopsis="Save all edited picks, filaments and instance segmentations.", url=inst_url),
+ copick_save,
+ )
+ register(
+ "copick annotate",
+ CmdDesc(
+ optional=[("state", EnumOf(["show", "hide", "toggle"]))],
+ keyword=[("page", EnumOf(["filaments", "instances", "picks"]))],
+ synopsis="Show or hide the Copick Annotate window.",
+ url=inst_url,
+ ),
+ copick_annotate,
+ )
+ register(
+ "copick colorpicks",
+ CmdDesc(
+ optional=[("state", EnumOf(["on", "off"]))],
+ synopsis="Colour particle lists by instance ID (on) or object (off).",
+ url=inst_url,
+ ),
+ copick_color_picks,
+ )
+
register_copick_start()
register_copick_keyboard_shortcuts()
+ register_annotation_commands()
register_copick_new()
register_copick_open_run()
register_tomogram_commands()
diff --git a/src/docs/user/commands/copick_filaments.html b/src/docs/user/commands/copick_filaments.html
new file mode 100644
index 0000000..1243274
--- /dev/null
+++ b/src/docs/user/commands/copick_filaments.html
@@ -0,0 +1,106 @@
+
+
+
+ Command: copick filaments
+
+
+
+
+
+
Usage:
+ copick (open | show | hide) filaments [ copick-uri ]
+ copick new filamentsobject-name [ userIdid ] [ sessionIdid ]
+ copick filament trace [ on | off | toggle ]
+ copick filament join [ id1,id2,… ] [ targetid ]
+ copick filament cut [ on | off | toggle ]
+ [ atx,y,z [ instanceIdfilament-id ] ]
+ copick filament (new | select | focus | go | next | previous |
+ reverse | delete | convert) [ filament-id ]
+ copick filament save [ userIdid ] [ sessionIdid ]
+ [ pickSpacingÅ ]
+
+
+ Filaments are traced centrelines (e.g. microtubules, actin) of objects declared as filaments
+ (Edit Object Types → Is Filament). Each filament set is drawn as one tube per filament,
+ coloured by its filament ID; picks sampled from a filament carry the same ID and colour.
+
+
+ Tracing: copick filament trace on binds the right mouse button to trace copick filament.
+ Click on the shown tomogram plane (single plane, orthoplanes or tilted slab) to add a control point to the
+ active filament; drag a control point to move it; shift-click a control point to remove it. The centreline is
+ a centripetal Catmull-Rom curve through the control points, regenerated by copick after every edit, so every
+ tool reproduces the same curve and the control points are kept in the file.
+ Filaments fitted as B-splines keep their knots: their control points can be moved but not added or removed
+ until they are converted with copick filament convert.
+
+
+ Cutting: copick filament cut on (the Cut button of the Copick Edit toolbar or the
+ Copick Annotate window, shortcut cf) binds the right mouse button to cut copick filament.
+ Click on a filament where it crosses the shown tomogram plane to split it in two there: the first piece keeps
+ the filament's ID, the second gets the next free ID, and both keep its polarity, score, radius and metadata.
+ Catmull-Rom and linear filaments are split between their control points, with the cut point added as the end
+ of one piece and the start of the other, so all other control points stay where they were; a click within
+ one step of a control point splits at that control point. B-spline fits are split along their centreline
+ and each piece gets Catmull-Rom control points derived from it. A cut too close to an end of the filament is
+ refused. copick filament cut atx,y,z cuts the filament passing closest to that point
+ (Angstrom), or filament instanceId, without the mouse.
+
+
+ Joining: copick filament join (the Join button of the Copick Edit toolbar or the filament
+ list's join button in the Copick Annotate window, shortcut jf) joins filaments end to end: starting from the
+ target (the current filament of the list, or targetid), 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 ID,
+ direction, polarity, score, radius and metadata; the others are removed. The joined curve is Catmull-Rom (linear
+ if all parts are linear) through all control points. Without IDs, the selected filaments are joined.
+
+
+ Selecting filaments: the 3D view and the Copick Annotate window's filament list share one selection.
+ Ctrl-click a filament's tube (Shift+Ctrl-click to add more) and its row is selected; select rows and their tubes
+ are selected. Selecting a single filament makes it the active one. select clear (or Ctrl-click on empty
+ space) clears both.
+
+
+ Undo: every filament edit (adding, moving, deleting a control point, a whole drag, new / cut /
+ join / reverse / delete / convert filament) is a step on ChimeraX's undo stack. Undo and redo it with Edit → Undo /
+ Redo, Cmd+Z / Shift+Cmd+Z (Ctrl+Z / Ctrl+Y on Windows and Linux) or the
+ undo / redo commands. Undoing a step shows the filament
+ set it belongs to. While tracing, ChimeraX keeps at least 100 undo steps; the steps of a set are dropped when it
+ is closed.
+
+
+
+
+
action
meaning
+
new
Start a new filament (the next free ID) and make it active.
+
selectid
Make filament id active (new control points go there).
+
goid
Make filament id active and focus the view on it (what the
+ filament stepper below the Filaments table does).
+
focus [id]
Move the view to the middle of a filament.
+
next / previous
Step through the filaments and focus them.
+
reverse [id]
Reverse the point order (polarity) of a filament.
+
delete [id]
Delete a filament.
+
convert [id]
Convert a B-spline fit to an editable Catmull-Rom curve.
+
save
Save the active set. With pickSpacing, picks are also sampled every
+ Å along each filament (+Z along the filament, instance ID = filament ID); they replace the whole
+ picks set of the same object, user and session. Session 0 (tool output) cannot be written.
+
+
+
+
Examples:
+
copick open filaments microtubule:tracer/1
+ copick new filaments microtubule
+ copick filament trace on
+ copick filament save sessionId manual-2 pickSpacing 82
+
+
The Filaments tab, the Copick Edit toolbar tab and the shortcuts
+ tf nf fn fp rv xf run the same commands.
+ Instance segmentations hold one integer ID per voxel (0 = background); the IDs match the instance IDs
+ of picks and filaments of the same object, and are drawn with the same colours. Panoptic segmentations
+ (object label + instance channels) are shown with one surface per segment and can be browsed, not edited.
+ Both appear in the Segmentations tab with their type.
+
+
+ Editing works on a full-resolution copy of the volume: copick instance edit loads an instance
+ segmentation, copick new segmentation starts an empty one at the shown tomogram's voxel spacing. The right
+ mouse button then paints the current ID as a sphere of the brush radius where the mouse meets the tomogram plane
+ (paint copick instance); erase copick instance clears voxels and pick copick instance
+ makes the clicked ID current. Surfaces of the IDs an edit touched are rebuilt when the mouse is released.
+ The undo stack keeps the last 20 edits. Limits: a sphere brush only, and large volumes need memory.
+
+
+ ids is a list such as 3,5-8. show / hide / isolate act on the active
+ instance or panoptic segmentation (segment indices for panoptic). copick colorpicks on colours every particle
+ list by instance ID (filament picks always are). copick save stores all edited picks, filament sets and the
+ edited instance segmentation that were opened from editable storage. copick annotate shows or hides the
+ Copick Annotate window (tracing, instance editing and browsing).
+
+
+
Examples:
+
copick instance edit microtubule:tracer/1@21.6?instance=true
+ copick instance new
+ copick instance brush 75
+ copick instance merge 2 into 1
+ copick instance save sessionId manual-3
+
+
The Segmentations tab, the Copick Edit toolbar tab and the shortcuts
+ pi ei ki ni iu run the same commands.
A Copick toolbar tab provides visualization (slab, volume, orthoplanes, surface),
- binning, marker/axis/surface visibility, viewing directions, and particle picking and
- manipulation mouse modes.
+ binning, marker/axis/surface visibility, viewing directions, and particle placement, manipulation and
+ deletion. A Copick Edit tab gathers the annotation tools: the Annotate window, new picks / filaments /
+ instance segmentations and Save All; the most used particle tools (place on the plane, select, move / rotate,
+ delete; the same as in the Copick tab); filament tracing (trace mode, cut mode, new, previous / next, reverse,
+ join, delete, save); instance editing (paint, erase, pick ID, new ID, undo, save); brush sizes; and colouring
+ particle lists by instance or object.
+
+
+
Filaments and instances
+
+ The Filaments tab lists the run's traced filament sets; double-click to show one. Below the table, a stepper (<<k of N>>, shortcuts fp / fn) steps through the filaments of the active set and focuses the view on each. The Segmentations
+ tab shows each segmentation's type (binary, multilabel, instance, panoptic); instance and panoptic segmentations
+ are drawn as one surface per instance or segment. Instance IDs share one colour per ID across picks, filaments and
+ segmentations.
+
+
+ Tracing, instance editing and instance browsing happen in a separate Copick Annotate window, so the copick
+ panel keeps its width and height. It opens when you start tracing or editing, from the Annotate button of
+ the Copick Edit toolbar, or with copick annotate. Its pages are Filaments (trace and cut modes, active
+ filament, insert mode, save, and a filament browser: step, focus, show / hide, reverse, delete), Instances
+ (edit, current ID, brush, background-only, save, and an instance / panoptic segment browser: step, focus,
+ show / hide / isolate, merge, delete) and Pick instances (the instance IDs of the current particle list).
+ The window is tabbed with the right-hand group (ArtiaX Options, Log, Models, ...) so it gets the full height.
+ ArtiaX raises its options panel whenever the current particle list or tomogram changes; if Copick Annotate was the
+ front tab at that moment, it raises itself back. Move it anywhere (ChimeraX remembers), float it, or use
+ copick dock.
+ See copick filaments and
+ copick instance.
Attribution
diff --git a/src/filaments/__init__.py b/src/filaments/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/filaments/controller.py b/src/filaments/controller.py
new file mode 100644
index 0000000..8626418
--- /dev/null
+++ b/src/filaments/controller.py
@@ -0,0 +1,546 @@
+"""Filament sets in a running copick tool: display, tracing (via the ``trace copick filament`` mouse mode), editing
+and saving. Commands, toolbar buttons, shortcuts and the Filaments tab all go through this controller.
+
+Undo: every edit step of a set's ``FilamentEditSession`` is mirrored as an action on ChimeraX's undo stack, so
+Edit > Undo / Redo, Cmd+Z / Ctrl+Z and the ``undo`` / ``redo`` commands step through filament edits in order with
+everything else ChimeraX can undo.
+"""
+
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+import numpy as np
+from copick_shared_ui.util.filament_session import FilamentEditSession
+from copick_shared_ui.util.filaments import nearest_point_to_line, point_at_arc_length
+from superqt.utils import thread_worker
+
+from .model import FilamentSetModel
+
+
+def _save_impl(session: FilamentEditSession, user_id: str, session_id: str, pick_spacing: Optional[float]):
+ return session.save(user_id=user_id, session_id=session_id, pick_spacing=pick_spacing)
+
+
+_save_worker = thread_worker(_save_impl)
+
+#: ChimeraX keeps 10 undo steps by default; tracing raises that to this many.
+UNDO_DEPTH = 100
+
+#: The filament mouse modes (bound to the right button).
+TRACE_MODE = "trace copick filament"
+CUT_MODE = "cut copick filament"
+FILAMENT_MODES = (TRACE_MODE, CUT_MODE)
+
+_undo_action_class = None
+
+
+def _filament_undo_action(controller: "FilamentController", edit: FilamentEditSession, label: str):
+ """An UndoAction that undoes / redoes one step of ``edit`` (class built lazily: chimerax.core is only importable
+ inside ChimeraX)."""
+ global _undo_action_class
+ if _undo_action_class is None:
+ from chimerax.core.undo import UndoAction
+
+ class FilamentUndoAction(UndoAction):
+ def __init__(self, controller, edit, label):
+ super().__init__(label, can_redo=True)
+ self.controller, self.edit = controller, edit
+
+ def undo(self):
+ self.controller.apply_history(self.edit, redo=False)
+
+ def redo(self):
+ self.controller.apply_history(self.edit, redo=True)
+
+ _undo_action_class = FilamentUndoAction
+ return _undo_action_class(controller, edit, label)
+
+
+class FilamentController:
+ """Owns the filament sessions and models of the current run."""
+
+ def __init__(self, tool: Any):
+ self.tool = tool
+ self.session = tool.session
+ # key: the CopickFilaments it was opened from, or the session itself for new, unsaved traces
+ self.entries: Dict[Any, Tuple[FilamentEditSession, FilamentSetModel]] = {}
+ self.active_key: Any = None
+ self.editing = False
+ self._prev_right_mode: Optional[str] = None
+ self.listeners: List[Callable[[], None]] = []
+ #: Called with the filament IDs selected in the 3D view when that selection changes.
+ self.selection_listeners: List[Callable[[List[int]], None]] = []
+ self._scene_selection: List[int] = []
+ self._workers = []
+ self._undo_actions: Dict[int, list] = {} # id(edit session) -> its actions on ChimeraX's undo stack
+ self._drag_edit: Optional[FilamentEditSession] = None
+
+ # -- state -----------------------------------------------------------------------------------------------------
+
+ def notify(self) -> None:
+ for listener in list(self.listeners):
+ try:
+ listener()
+ except Exception as e: # never let a UI listener break an edit
+ self.session.logger.warning(f"copick: filament listener failed: {e}")
+
+ @property
+ def active(self) -> Optional[Tuple[FilamentEditSession, FilamentSetModel]]:
+ entry = self.entries.get(self.active_key)
+ if entry is not None and entry[1].deleted:
+ self.entries.pop(self.active_key, None)
+ return None
+ return entry
+
+ @property
+ def edit_session(self) -> Optional[FilamentEditSession]:
+ return self.active[0] if self.active else None
+
+ def tube_scale(self) -> float:
+ return float(getattr(self.tool.settings, "filament_tube_scale", 0.5))
+
+ # -- open / close ----------------------------------------------------------------------------------------------
+
+ def show(self, copick_filaments: Any) -> FilamentSetModel:
+ """Load (if needed) and show a filament set; makes it the active set."""
+ entry = self.entries.get(copick_filaments)
+ if entry is None or entry[1].deleted:
+ step = copick_filaments.voxel_spacing or self._tomogram_voxel_size() or 10.0
+ edit = FilamentEditSession.from_filaments(copick_filaments, step=step)
+ self._track_history(edit)
+ model = FilamentSetModel(self.session, edit, tube_scale=self.tube_scale())
+ self.session.models.add([model])
+ model.redraw()
+ self.entries[copick_filaments] = (edit, model)
+ else:
+ entry[1].display = True
+ self.active_key = copick_filaments
+ self.notify()
+ return self.entries[copick_filaments][1]
+
+ def hide(self, copick_filaments: Any) -> None:
+ entry = self.entries.get(copick_filaments)
+ if entry is not None and not entry[1].deleted:
+ entry[1].display = False
+ self.notify()
+
+ def is_shown(self, copick_filaments: Any) -> bool:
+ entry = self.entries.get(copick_filaments)
+ return entry is not None and not entry[1].deleted and entry[1].display
+
+ def new(self, object_name: str, user_id: str, session_id: str) -> FilamentEditSession:
+ """Start a new (unsaved) filament set in the active run and switch to tracing."""
+ run = self.tool.active_run()
+ if run is None:
+ raise ValueError("Open a tomogram first.")
+ step = self._tomogram_voxel_size() or 10.0
+ edit = FilamentEditSession(run, object_name, user_id, session_id, step=step)
+ self._track_history(edit)
+ model = FilamentSetModel(self.session, edit, tube_scale=self.tube_scale())
+ self.session.models.add([model])
+ self.entries[edit] = (edit, model)
+ self.active_key = edit
+ self.start_tracing()
+ return edit
+
+ def close_all(self) -> None:
+ for edit, model in self.entries.values():
+ self._forget_history(edit)
+ if not model.deleted:
+ model.delete()
+ self.entries.clear()
+ self.active_key = None
+ self.stop_tracing()
+ self.notify()
+
+ def delete_entity(self, copick_filaments: Any) -> None:
+ entry = self.entries.pop(copick_filaments, None)
+ if entry is not None:
+ self._forget_history(entry[0])
+ if entry is not None and not entry[1].deleted:
+ entry[1].delete()
+ if self.active_key is copick_filaments:
+ self.active_key = None
+ self.notify()
+
+ def set_active(self, key: Any) -> None:
+ if key in self.entries:
+ self.active_key = key
+ if self.editing:
+ for _e, m in self.entries.values():
+ m.set_show_controls(m is self.entries[key][1])
+ self.notify()
+
+ def _tomogram_voxel_size(self) -> Optional[float]:
+ vol = self.tool.active_volume
+ if vol is None or vol.deleted or not hasattr(vol, "copick_tomo"):
+ return None
+ return float(vol.copick_tomo.voxel_spacing.voxel_size)
+
+ # -- tracing mode ----------------------------------------------------------------------------------------------
+
+ @property
+ def right_mode(self) -> Optional[str]:
+ """Name of the mouse mode on the right button (no modifiers)."""
+ bindings = self.session.ui.mouse_modes.bindings
+ names = [b.mode.name for b in bindings if b.button == "right" and not b.modifiers]
+ return names[0] if names else None
+
+ def start_tracing(self) -> None:
+ """Right mouse button: trace (add / move / remove control points)."""
+ self._start_mode(TRACE_MODE)
+
+ def start_cutting(self) -> None:
+ """Right mouse button: cut the filament under the click in two."""
+ self._start_mode(CUT_MODE)
+
+ def _start_mode(self, mode_name: str) -> None:
+ from chimerax.core.commands import run
+
+ if self.active is None:
+ self.session.logger.warning("copick: no filament set is active; open or create one first.")
+ return
+ right = self.right_mode
+ if right and right not in FILAMENT_MODES:
+ self._prev_right_mode = right
+ self._enter_editing()
+ run(self.session, f"ui mousemode right '{mode_name}'", log=False)
+ self.notify()
+
+ def _enter_editing(self) -> None:
+ if not self.editing:
+ self.editing = True
+ self.tool.annotate("filaments")
+ active = self.active
+ for _e, model in self.entries.values():
+ if not model.deleted:
+ model.set_show_controls(active is not None and model is active[1])
+
+ def on_mouse_mode_set(self, button: str, modifiers, mode) -> None:
+ """Another mode took the right button: leave filament editing (hide the handles, update the panel)."""
+ if button != "right" or modifiers or not self.editing:
+ return
+ if mode is not None and getattr(mode, "name", None) in FILAMENT_MODES:
+ return
+ self.editing = False
+ self._prev_right_mode = None
+ for _e, model in self.entries.values():
+ if not model.deleted:
+ model.set_show_controls(False)
+ self.notify()
+
+ def mode_enabled(self, mode_name: str) -> None:
+ """A filament mouse mode was bound (from the toolbar, a command or ``ui mousemode``): show the handles."""
+ if self.active is not None:
+ self._enter_editing()
+ self.notify()
+
+ def stop_tracing(self) -> None:
+ from chimerax.core.commands import run
+
+ if not self.editing:
+ return
+ self.editing = False
+ for _e, model in self.entries.values():
+ if not model.deleted:
+ model.set_show_controls(False)
+ if self._prev_right_mode:
+ run(self.session, f"ui mousemode right '{self._prev_right_mode}'", log=False)
+ self.notify()
+
+ # -- edits (each regenerates through copick core and redraws) ----------------------------------------------
+
+ def _redraw(self, ids=None) -> None:
+ edit, model = self.active
+ model.redraw(ids)
+ self.notify()
+
+ def add_point(self, xyz) -> Optional[int]:
+ if self.active is None:
+ return None
+ edit, _model = self.active
+ try:
+ index = edit.insert_point(np.asarray(xyz, dtype=float))
+ except ValueError as e:
+ self.session.logger.warning(str(e))
+ return None
+ self._redraw([edit.active_id])
+ return index
+
+ def move_point(self, instance_id: int, index: int, xyz) -> None:
+ edit, _model = self.active
+ edit.move_point(instance_id, index, np.asarray(xyz, dtype=float))
+ self._redraw([instance_id])
+
+ def remove_point(self, instance_id: int, index: int) -> None:
+ edit, _model = self.active
+ try:
+ edit.remove_point(instance_id, index)
+ except ValueError as e:
+ self.session.logger.warning(str(e))
+ return
+ self._redraw([instance_id])
+
+ def hit_control(self, xyz1, xyz2, tolerance: float) -> Optional[Tuple[int, int]]:
+ """The control point (``(instance_id, index)``) closest to the viewer along a pick ray, if any."""
+ if self.active is None:
+ return None
+ points = self.active[1].all_control_points()
+ if not points:
+ return None
+ hit = nearest_point_to_line(np.array([p for _i, _k, p in points]), xyz1, xyz2, tolerance)
+ if hit is None:
+ return None
+ instance_id, index, _p = points[hit]
+ return instance_id, index
+
+ def new_filament(self) -> Optional[int]:
+ if self.active is None:
+ return None
+ instance_id = self.active[0].new_filament()
+ self._redraw([])
+ return instance_id
+
+ def set_active_filament(self, instance_id: int) -> None:
+ if self.active is None:
+ return
+ self.active[0].active_id = int(instance_id)
+ self._redraw([])
+
+ def go_to_filament(self, instance_id: int) -> None:
+ """Make a filament active and focus the view on it (the dock stepper's jump)."""
+ if self.active is None or int(instance_id) not in self.active[0].ids():
+ return
+ self.set_active_filament(instance_id)
+ self.focus(int(instance_id))
+
+ def step_filament(self, delta: int) -> Optional[int]:
+ if self.active is None:
+ return None
+ edit = self.active[0]
+ ids = edit.ids()
+ if not ids:
+ return None
+ i = ids.index(edit.active_id) if edit.active_id in ids else -1
+ edit.active_id = ids[(i + delta) % len(ids)]
+ self._redraw([])
+ self.focus(edit.active_id)
+ return edit.active_id
+
+ def reverse(self, instance_id: Optional[int] = None) -> None:
+ if self.active is None:
+ return
+ edit = self.active[0]
+ instance_id = edit.active_id if instance_id is None else int(instance_id)
+ edit.reverse(instance_id)
+ self._redraw([instance_id])
+
+ def delete_filament(self, instance_id: Optional[int] = None) -> None:
+ if self.active is None:
+ return
+ edit = self.active[0]
+ instance_id = edit.active_id if instance_id is None else int(instance_id)
+ edit.delete_filament(instance_id)
+ self._redraw([instance_id])
+
+ def convert(self, instance_id: Optional[int] = None) -> None:
+ if self.active is None:
+ return
+ edit = self.active[0]
+ instance_id = edit.active_id if instance_id is None else int(instance_id)
+ edit.convert_to_catmull_rom(instance_id)
+ self._redraw([instance_id])
+
+ def focus(self, instance_id: int) -> None:
+ if self.active is None:
+ return
+ edit = self.active[0]
+ if instance_id in edit.filaments:
+ xyz = point_at_arc_length(np.asarray(edit.filaments[instance_id].points), 0.5)
+ else:
+ cps = edit.controls(instance_id)
+ if not len(cps):
+ return
+ xyz = cps.mean(axis=0)
+ self.tool.focus_xyz(xyz, radius=self.active[1].radius)
+ spotlight = getattr(self.tool, "spotlight", None)
+ if spotlight is not None and spotlight.enabled:
+ spotlight.on_active_particle(xyz)
+
+ # -- undo / redo -----------------------------------------------------------------------------------------------
+
+ def _track_history(self, edit: FilamentEditSession) -> None:
+ edit.on_history = lambda label, edit=edit: self._register_undo(edit, label)
+
+ def _register_undo(self, edit: FilamentEditSession, label: str) -> None:
+ undo = getattr(self.session, "undo", None)
+ if undo is None:
+ return
+ if 0 < undo.max_depth < UNDO_DEPTH:
+ undo.set_depth(UNDO_DEPTH)
+ actions = self._undo_actions.setdefault(id(edit), [])
+ actions.append(undo.register(_filament_undo_action(self, edit, label)))
+ del actions[: -UNDO_DEPTH * 2]
+
+ def _forget_history(self, edit: FilamentEditSession) -> None:
+ """Drop a closed set's steps from ChimeraX's undo stack (other steps stay undoable)."""
+ undo = getattr(self.session, "undo", None)
+ for action in self._undo_actions.pop(id(edit), []):
+ if undo is not None:
+ undo.deregister(action, delete_history=False)
+ edit.on_history = None
+
+ def apply_history(self, edit: FilamentEditSession, redo: bool) -> None:
+ """Undo or redo one step of a filament set (called by ChimeraX's undo stack); shows the set it changed."""
+ key = next((k for k, (e, _m) in self.entries.items() if e is edit), None)
+ if key is None or self.entries[key][1].deleted:
+ self.session.logger.status("copick: that filament set is closed")
+ return
+ label = edit.redo() if redo else edit.undo()
+ if label is None:
+ return
+ model = self.entries[key][1]
+ model.display = True
+ if key is not self.active_key:
+ self.set_active(key)
+ model.redraw()
+ self.notify()
+ self.session.logger.status(f"copick: {'redo' if redo else 'undo'} {label.lower()}")
+
+ def cut_at(self, xyz, instance_id: Optional[int] = None, tolerance: Optional[float] = None) -> Optional[Tuple[int, int]]:
+ """Cut the filament under ``xyz`` (or ``instance_id``) in two; one undo step. Returns the two piece IDs."""
+ if self.active is None:
+ return None
+ edit, model = self.active
+ if tolerance is None:
+ tolerance = max(4 * edit.step, 1.5 * (model.radius or 0.0))
+ try:
+ pieces = edit.cut(np.asarray(xyz, dtype=float), instance_id=instance_id, tolerance=tolerance)
+ except ValueError as e:
+ self.session.logger.status(f"copick: {e}", color="orange red")
+ return None
+ self._redraw(list(pieces))
+ self.session.logger.info(f"copick: cut filament {pieces[0]} into {pieces[0]} and {pieces[1]}")
+ return pieces
+
+ # -- selection in the 3D view (Ctrl-click a tube), mirrored in the filament list ------------------------------
+
+ def scene_selected_ids(self) -> List[int]:
+ """IDs of the active set's filaments whose tubes are selected in the 3D view."""
+ if self.active is None:
+ return []
+ tubes = self.active[1]._tubes
+ return sorted(i for i, surf in tubes.items() if not surf.deleted and surf.selected)
+
+ def select_in_scene(self, instance_ids) -> None:
+ """Select exactly these filaments' tubes in the 3D view (from the filament list)."""
+ if self.active is None:
+ return
+ keep = {int(i) for i in instance_ids}
+ for i, surf in self.active[1]._tubes.items():
+ if not surf.deleted and surf.selected != (i in keep):
+ surf.selected = i in keep
+ self._scene_selection = sorted(i for i in keep if i in self.active[1]._tubes)
+
+ def on_scene_selection_changed(self) -> None:
+ """ChimeraX's selection changed: tell the filament list if the selected filaments changed."""
+ ids = self.scene_selected_ids()
+ if ids == self._scene_selection:
+ return
+ self._scene_selection = ids
+ for listener in list(self.selection_listeners):
+ try:
+ listener(ids)
+ except Exception as e:
+ self.session.logger.warning(f"copick: filament selection listener failed: {e}")
+
+ def join(self, instance_ids, target: Optional[int] = None) -> Optional[int]:
+ """Join filaments end to end (nearest ends first) into ``target`` (default the first); one undo step."""
+ if self.active is None:
+ return None
+ edit = self.active[0]
+ try:
+ joined = edit.join([int(i) for i in instance_ids], target=target)
+ except ValueError as e:
+ self.session.logger.warning(f"copick: {e}")
+ return None
+ self._redraw()
+ others = [int(i) for i in instance_ids if int(i) != joined]
+ self.session.logger.info(f"copick: joined filaments {', '.join(map(str, others))} into {joined}")
+ return joined
+
+ def begin_drag(self) -> None:
+ """Group the moves of a control-point drag into one undo step (ended by ``end_drag``)."""
+ self.end_drag()
+ if self.active is not None:
+ self._drag_edit = self.active[0]
+ self._drag_edit.begin_step("Move filament point")
+
+ def end_drag(self) -> None:
+ edit, self._drag_edit = self._drag_edit, None
+ if edit is not None:
+ edit.end_step()
+
+ # -- saving ----------------------------------------------------------------------------------------------------
+
+ def save(
+ self,
+ user_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ pick_spacing: Optional[float] = None,
+ on_done: Optional[Callable[[dict], None]] = None,
+ blocking: bool = False,
+ ):
+ """Save the active set (in a thread unless ``blocking``). Sampled picks replace the whole picks set."""
+ if self.active is None:
+ self.session.logger.warning("copick: no active filament set to save.")
+ return None
+ edit, model = self.active
+ user_id = user_id or edit.user_id
+ session_id = session_id or edit.session_id
+ if str(session_id) == "0":
+ self.session.logger.error("copick: session 0 is reserved for tool output; save under another session.")
+ return None
+ if pick_spacing:
+ # An open particle list of that picks set would overwrite the sampled picks on the next store().
+ self.tool.store()
+ key = self.active_key
+
+ def finished(result: dict) -> None:
+ target = result["filaments"]
+ entry = self.entries.pop(key, None)
+ if entry is not None:
+ self.entries[target] = entry
+ model.name = f"{edit.object_name} filaments ({edit.user_id}/{edit.session_id})"
+ self.active_key = target
+ picks_note = f" and {result['n_picks']} sampled picks" if result["n_picks"] else ""
+ self.session.logger.info(
+ f"copick: saved {result['n_filaments']} filaments "
+ f"({edit.object_name}:{edit.user_id}/{edit.session_id}){picks_note}",
+ )
+ if result.get("picks") is not None:
+ self.tool.reload_picks_entity(result["picks"])
+ self.tool._mw.update_filaments_table()
+ self.notify()
+ if on_done is not None:
+ on_done(result)
+
+ if blocking:
+ result = _save_impl(edit, user_id, session_id, pick_spacing)
+ finished(result)
+ return result
+ worker = _save_worker(edit, user_id, session_id, pick_spacing)
+ worker.returned.connect(finished)
+ worker.errored.connect(lambda e: self.session.logger.error(f"copick: saving filaments failed: {e}"))
+ worker.finished.connect(lambda: self._workers.remove(worker) if worker in self._workers else None)
+ self._workers.append(worker)
+ worker.start()
+ return worker
+
+ def save_dirty(self) -> None:
+ """Store edited sets that were opened from a user (editable) filament file (used by ``tool.store()``)."""
+ for key, (edit, _model) in list(self.entries.items()):
+ if edit.dirty and not edit.read_only and edit.source is not None and key is edit.source:
+ try:
+ edit.save()
+ except Exception as e:
+ self.session.logger.error(f"copick: could not store filaments {edit.object_name}: {e}")
diff --git a/src/filaments/model.py b/src/filaments/model.py
new file mode 100644
index 0000000..7c3d11d
--- /dev/null
+++ b/src/filaments/model.py
@@ -0,0 +1,199 @@
+"""ChimeraX model of one traced filament set: a tube surface per filament (in its instance colour) with arrowheads
+showing its direction (point order: what "reverse" flips), and, while editing, the control points as instanced
+spheres.
+
+The curves live in a ``FilamentEditSession`` (copick-shared-ui); every edit is regenerated by copick core, and this
+model only redraws from ``session.polylines()`` / ``session.all_controls()``. Coordinates are Angstrom, the frame of
+the tomograms.
+
+Deliberately not a MarkerSet: ChimeraX's ``mark plane`` mode places markers into the first MarkerSet it finds, which
+would collide with ArtiaX particle lists.
+"""
+
+from typing import Dict, Iterable, Optional, Set
+
+import numpy as np
+from chimerax.core.models import Model, Surface
+from copick_shared_ui.util.filaments import polyline_tangents
+from copick_shared_ui.util.instances import instance_colors
+
+DEFAULT_RADIUS = 50.0
+CIRCLE_SUBDIVISIONS = 12
+#: Control points are drawn this much wider than the widest tube, so they stand out of it.
+HANDLE_OVER_TUBE = 2.0
+#: ... and at least this many tomogram steps (voxels) in radius.
+HANDLE_MIN_STEPS = 2.5
+#: The active filament's handles are drawn this much larger (and white).
+ACTIVE_HANDLE_SCALE = 1.3
+#: Direction arrowheads, relative to the tube radius; a second arrowhead mid-filament on filaments longer than
+#: ``ARROW_MID_MIN_LENGTH`` arrowhead lengths (so the direction shows when the end is outside the slab).
+ARROW_RADIUS = 2.4
+ARROW_LENGTH = 6.0
+ARROW_MID_MIN_LENGTH = 8.0
+
+
+class FilamentSetModel(Model):
+ """Tubes for the filaments of one ``FilamentEditSession``."""
+
+ def __init__(self, session, edit_session, tube_scale: float = 0.5):
+ name = f"{edit_session.object_name} filaments ({edit_session.user_id}/{edit_session.session_id})"
+ Model.__init__(self, name, session)
+ self.edit_session = edit_session
+ self.tube_scale = float(tube_scale)
+ self._tubes: Dict[int, Surface] = {}
+ self._controls: Optional[Surface] = None
+ self._handle_radius: Optional[float] = None
+ self._hidden: Set[int] = set()
+ self.show_controls = False
+
+ # ----------------------------------------------------------------------------------------------------------------
+ # Appearance
+ # ----------------------------------------------------------------------------------------------------------------
+
+ @property
+ def base_rgba(self) -> np.ndarray:
+ obj = self.edit_session.object
+ color = getattr(obj, "color", None) or (255, 255, 255, 255)
+ return np.asarray(color, dtype=np.uint8)[:4]
+
+ @property
+ def radius(self) -> float:
+ return float(self.edit_session.radius or DEFAULT_RADIUS)
+
+ def tube_radius(self, instance_id: int) -> float:
+ f = self.edit_session.filaments.get(instance_id)
+ r = getattr(f, "radius", None) if f is not None else None
+ return float(r or self.radius) * self.tube_scale
+
+ def handle_radius(self) -> float:
+ """Radius of the control-point spheres: wider than the tubes, so they are visible on them."""
+ tubes = [self.tube_radius(i) for i in self.edit_session.filaments] or [self.radius * self.tube_scale]
+ return max(HANDLE_OVER_TUBE * max(tubes), HANDLE_MIN_STEPS * self.edit_session.step)
+
+ def color_of(self, instance_id: int) -> np.ndarray:
+ return instance_colors([instance_id], self.base_rgba, dtype=np.uint8)[0]
+
+ # ----------------------------------------------------------------------------------------------------------------
+ # Drawing
+ # ----------------------------------------------------------------------------------------------------------------
+
+ def redraw(self, ids: Optional[Iterable[int]] = None) -> None:
+ """Rebuild the tubes of ``ids`` (default all), drop tubes of deleted filaments, redraw the controls."""
+ lines = self.edit_session.polylines()
+ for instance_id in list(self._tubes):
+ if instance_id not in lines:
+ self._tubes.pop(instance_id).delete()
+ for instance_id in lines if ids is None else [i for i in ids if i in lines]:
+ self._draw_tube(instance_id, lines[instance_id])
+ for instance_id in [i for i in (ids or []) if i not in lines and i in self._tubes]:
+ self._tubes.pop(instance_id).delete()
+ self._draw_controls()
+
+ def _draw_tube(self, instance_id: int, points: np.ndarray) -> None:
+ from chimerax.surface.tube import tube_through_points
+
+ pts = np.asarray(points, dtype=np.float32).reshape(-1, 3)
+ if len(pts) < 2:
+ return
+ tangents = polyline_tangents(pts).astype(np.float32)
+ radius = self.tube_radius(instance_id)
+ va, na, ta = tube_through_points(pts, tangents, radius, CIRCLE_SUBDIVISIONS)
+ va, na, ta = _with_arrowheads(va, na, ta, pts, radius)
+ surf = self._tubes.get(instance_id)
+ if surf is None or surf.deleted:
+ surf = Surface(f"filament {instance_id}", self.session)
+ self.add([surf])
+ self._tubes[instance_id] = surf
+ surf.set_geometry(va, na, ta)
+ surf.color = self.color_of(instance_id)
+ surf.display = instance_id not in self._hidden
+ surf.copick_filament_id = instance_id
+
+ def _draw_controls(self) -> None:
+ from chimerax.geometry import Place, Places
+ from chimerax.surface import sphere_geometry2
+
+ controls = {i: c for i, c in self.edit_session.all_controls().items() if len(c)} if self.show_controls else {}
+ controls = {i: c for i, c in controls.items() if i not in self._hidden}
+ if not controls:
+ if self._controls is not None and not self._controls.deleted:
+ self._controls.display = False
+ return
+ r = self.handle_radius()
+ if self._controls is None or self._controls.deleted:
+ self._controls = Surface("control points", self.session)
+ self.add([self._controls])
+ self._handle_radius = None
+ if self._handle_radius != r: # the radius follows the tubes (filament radii, tube scale)
+ va, na, ta = sphere_geometry2(200)
+ self._controls.set_geometry((va * r).astype(np.float32), na.astype(np.float32), ta)
+ self._handle_radius = r
+ ids = np.concatenate([np.full(len(c), i) for i, c in controls.items()])
+ xyz = np.concatenate(list(controls.values()))
+ active = self.edit_session.active_id
+ places = []
+ for i, p in zip(ids, xyz, strict=True):
+ k = ACTIVE_HANDLE_SCALE if i == active else 1.0
+ places.append(Place(axes=np.eye(3) * k, origin=p))
+ self._controls.positions = Places(places)
+ colors = instance_colors(ids, self.base_rgba, dtype=np.uint8)
+ colors[ids == active] = (255, 255, 255, 255) # the active filament's handles are white
+ self._controls.colors = colors
+ self._controls.display = True
+
+ # ----------------------------------------------------------------------------------------------------------------
+ # Visibility
+ # ----------------------------------------------------------------------------------------------------------------
+
+ def set_visible_ids(self, ids: Iterable[int]) -> None:
+ ids = {int(i) for i in ids}
+ self._hidden = set(self._tubes) - ids
+ for instance_id, surf in self._tubes.items():
+ surf.display = instance_id in ids
+ self._draw_controls()
+
+ def set_show_controls(self, show: bool) -> None:
+ self.show_controls = bool(show)
+ self._draw_controls()
+
+ def all_control_points(self):
+ """``[(instance_id, index, xyz)]`` of every control point (for picking)."""
+ out = []
+ for instance_id, cps in self.edit_session.all_controls().items():
+ for k, p in enumerate(cps):
+ out.append((instance_id, k, p))
+ return out
+
+
+def _arrowhead_places(points: np.ndarray, length: float) -> list:
+ """(base point, unit direction) of the arrowheads of a polyline: at its end and, if it is long, at its middle."""
+ seg = np.linalg.norm(np.diff(points, axis=0), axis=1)
+ total = float(seg.sum())
+ if total <= 0:
+ return []
+ end_dir = points[-1] - points[-2]
+ places = [(points[-1] - 0.25 * length * end_dir / np.linalg.norm(end_dir), end_dir)]
+ if total > ARROW_MID_MIN_LENGTH * length:
+ s = np.concatenate([[0.0], np.cumsum(seg)])
+ k = int(np.clip(np.searchsorted(s, total / 2), 1, len(points) - 1))
+ f = (total / 2 - s[k - 1]) / max(seg[k - 1], 1e-9)
+ places.append((points[k - 1] + f * (points[k] - points[k - 1]), points[k] - points[k - 1]))
+ return [(base, d / np.linalg.norm(d)) for base, d in places if np.linalg.norm(d) > 0]
+
+
+def _with_arrowheads(va, na, ta, points: np.ndarray, tube_radius: float):
+ """Add direction arrowheads (cones pointing along the filament) to a tube's geometry."""
+ from chimerax.geometry import translation, vector_rotation
+ from chimerax.surface import cone_geometry
+
+ length = ARROW_LENGTH * tube_radius
+ cva, cna, cta = cone_geometry(radius=ARROW_RADIUS * tube_radius, height=length, nc=CIRCLE_SUBDIVISIONS * 2)
+ parts_v, parts_n, parts_t, offset = [va], [na], [ta], len(va)
+ for base, direction in _arrowhead_places(np.asarray(points, dtype=float), length):
+ rot = vector_rotation((0, 0, 1), tuple(direction))
+ place = translation(tuple(base + 0.5 * length * direction)) * rot
+ parts_v.append(place.transform_points(cva).astype(np.float32))
+ parts_n.append(rot.transform_vectors(cna).astype(np.float32))
+ parts_t.append((cta + offset).astype(ta.dtype))
+ offset += len(cva)
+ return np.concatenate(parts_v), np.concatenate(parts_n), np.concatenate(parts_t)
diff --git a/src/icons/annotate.png b/src/icons/annotate.png
new file mode 100644
index 0000000..a64771d
Binary files /dev/null and b/src/icons/annotate.png differ
diff --git a/src/icons/annotate.svg b/src/icons/annotate.svg
new file mode 100644
index 0000000..b921d1a
--- /dev/null
+++ b/src/icons/annotate.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/brush_large.png b/src/icons/brush_large.png
new file mode 100644
index 0000000..28f87c3
Binary files /dev/null and b/src/icons/brush_large.png differ
diff --git a/src/icons/brush_large.svg b/src/icons/brush_large.svg
new file mode 100644
index 0000000..82225f1
--- /dev/null
+++ b/src/icons/brush_large.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/brush_medium.png b/src/icons/brush_medium.png
new file mode 100644
index 0000000..f9ea0e7
Binary files /dev/null and b/src/icons/brush_medium.png differ
diff --git a/src/icons/brush_medium.svg b/src/icons/brush_medium.svg
new file mode 100644
index 0000000..6610433
--- /dev/null
+++ b/src/icons/brush_medium.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/brush_small.png b/src/icons/brush_small.png
new file mode 100644
index 0000000..52b8135
Binary files /dev/null and b/src/icons/brush_small.png differ
diff --git a/src/icons/brush_small.svg b/src/icons/brush_small.svg
new file mode 100644
index 0000000..91f5a4f
--- /dev/null
+++ b/src/icons/brush_small.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/color_instances.png b/src/icons/color_instances.png
new file mode 100644
index 0000000..33d7d59
Binary files /dev/null and b/src/icons/color_instances.png differ
diff --git a/src/icons/color_instances.svg b/src/icons/color_instances.svg
new file mode 100644
index 0000000..b5d510e
--- /dev/null
+++ b/src/icons/color_instances.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/color_object.png b/src/icons/color_object.png
new file mode 100644
index 0000000..f2bfba5
Binary files /dev/null and b/src/icons/color_object.png differ
diff --git a/src/icons/color_object.svg b/src/icons/color_object.svg
new file mode 100644
index 0000000..b5a051f
--- /dev/null
+++ b/src/icons/color_object.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/cut_filament.png b/src/icons/cut_filament.png
new file mode 100644
index 0000000..fd31885
Binary files /dev/null and b/src/icons/cut_filament.png differ
diff --git a/src/icons/cut_filament.svg b/src/icons/cut_filament.svg
new file mode 100644
index 0000000..aff28c8
--- /dev/null
+++ b/src/icons/cut_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/delete_filament.png b/src/icons/delete_filament.png
new file mode 100644
index 0000000..676bc46
Binary files /dev/null and b/src/icons/delete_filament.png differ
diff --git a/src/icons/delete_filament.svg b/src/icons/delete_filament.svg
new file mode 100644
index 0000000..87638d1
--- /dev/null
+++ b/src/icons/delete_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/erase_instance.png b/src/icons/erase_instance.png
new file mode 100644
index 0000000..094f8d7
Binary files /dev/null and b/src/icons/erase_instance.png differ
diff --git a/src/icons/erase_instance.svg b/src/icons/erase_instance.svg
new file mode 100644
index 0000000..a4c49aa
--- /dev/null
+++ b/src/icons/erase_instance.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/join_filament.png b/src/icons/join_filament.png
new file mode 100644
index 0000000..f87a4c5
Binary files /dev/null and b/src/icons/join_filament.png differ
diff --git a/src/icons/join_filament.svg b/src/icons/join_filament.svg
new file mode 100644
index 0000000..d42275d
--- /dev/null
+++ b/src/icons/join_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/new_filament.png b/src/icons/new_filament.png
new file mode 100644
index 0000000..6ad8f2c
Binary files /dev/null and b/src/icons/new_filament.png differ
diff --git a/src/icons/new_filament.svg b/src/icons/new_filament.svg
new file mode 100644
index 0000000..1cdd458
--- /dev/null
+++ b/src/icons/new_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/new_filament_set.png b/src/icons/new_filament_set.png
new file mode 100644
index 0000000..94087dd
Binary files /dev/null and b/src/icons/new_filament_set.png differ
diff --git a/src/icons/new_filament_set.svg b/src/icons/new_filament_set.svg
new file mode 100644
index 0000000..d2d8119
--- /dev/null
+++ b/src/icons/new_filament_set.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/new_instance.png b/src/icons/new_instance.png
new file mode 100644
index 0000000..f53779d
Binary files /dev/null and b/src/icons/new_instance.png differ
diff --git a/src/icons/new_instance.svg b/src/icons/new_instance.svg
new file mode 100644
index 0000000..4926974
--- /dev/null
+++ b/src/icons/new_instance.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/new_instance_seg.png b/src/icons/new_instance_seg.png
new file mode 100644
index 0000000..96cfacf
Binary files /dev/null and b/src/icons/new_instance_seg.png differ
diff --git a/src/icons/new_instance_seg.svg b/src/icons/new_instance_seg.svg
new file mode 100644
index 0000000..6d37be1
--- /dev/null
+++ b/src/icons/new_instance_seg.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/new_picks.png b/src/icons/new_picks.png
new file mode 100644
index 0000000..97ad357
Binary files /dev/null and b/src/icons/new_picks.png differ
diff --git a/src/icons/new_picks.svg b/src/icons/new_picks.svg
new file mode 100644
index 0000000..9bf5d60
--- /dev/null
+++ b/src/icons/new_picks.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/next_filament.png b/src/icons/next_filament.png
new file mode 100644
index 0000000..03b6bde
Binary files /dev/null and b/src/icons/next_filament.png differ
diff --git a/src/icons/next_filament.svg b/src/icons/next_filament.svg
new file mode 100644
index 0000000..ffeb38f
--- /dev/null
+++ b/src/icons/next_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/paint_instance.png b/src/icons/paint_instance.png
new file mode 100644
index 0000000..30fde80
Binary files /dev/null and b/src/icons/paint_instance.png differ
diff --git a/src/icons/paint_instance.svg b/src/icons/paint_instance.svg
new file mode 100644
index 0000000..3218ab2
--- /dev/null
+++ b/src/icons/paint_instance.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/pick_instance.png b/src/icons/pick_instance.png
new file mode 100644
index 0000000..cf1980b
Binary files /dev/null and b/src/icons/pick_instance.png differ
diff --git a/src/icons/pick_instance.svg b/src/icons/pick_instance.svg
new file mode 100644
index 0000000..1572330
--- /dev/null
+++ b/src/icons/pick_instance.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/prev_filament.png b/src/icons/prev_filament.png
new file mode 100644
index 0000000..8f966cc
Binary files /dev/null and b/src/icons/prev_filament.png differ
diff --git a/src/icons/prev_filament.svg b/src/icons/prev_filament.svg
new file mode 100644
index 0000000..68d542d
--- /dev/null
+++ b/src/icons/prev_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/reverse_filament.png b/src/icons/reverse_filament.png
new file mode 100644
index 0000000..8f183b4
Binary files /dev/null and b/src/icons/reverse_filament.png differ
diff --git a/src/icons/reverse_filament.svg b/src/icons/reverse_filament.svg
new file mode 100644
index 0000000..6db3aac
--- /dev/null
+++ b/src/icons/reverse_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/save_all.png b/src/icons/save_all.png
new file mode 100644
index 0000000..c6c61d0
Binary files /dev/null and b/src/icons/save_all.png differ
diff --git a/src/icons/save_all.svg b/src/icons/save_all.svg
new file mode 100644
index 0000000..b9536da
--- /dev/null
+++ b/src/icons/save_all.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/save_filaments.png b/src/icons/save_filaments.png
new file mode 100644
index 0000000..f01aeaf
Binary files /dev/null and b/src/icons/save_filaments.png differ
diff --git a/src/icons/save_filaments.svg b/src/icons/save_filaments.svg
new file mode 100644
index 0000000..596b941
--- /dev/null
+++ b/src/icons/save_filaments.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/save_instances.png b/src/icons/save_instances.png
new file mode 100644
index 0000000..7d331bf
Binary files /dev/null and b/src/icons/save_instances.png differ
diff --git a/src/icons/save_instances.svg b/src/icons/save_instances.svg
new file mode 100644
index 0000000..fe1a6b5
--- /dev/null
+++ b/src/icons/save_instances.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/trace_filament.png b/src/icons/trace_filament.png
new file mode 100644
index 0000000..947913c
Binary files /dev/null and b/src/icons/trace_filament.png differ
diff --git a/src/icons/trace_filament.svg b/src/icons/trace_filament.svg
new file mode 100644
index 0000000..23ba476
--- /dev/null
+++ b/src/icons/trace_filament.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/icons/undo_instance.png b/src/icons/undo_instance.png
new file mode 100644
index 0000000..eb616fa
Binary files /dev/null and b/src/icons/undo_instance.png differ
diff --git a/src/icons/undo_instance.svg b/src/icons/undo_instance.svg
new file mode 100644
index 0000000..017e6e4
--- /dev/null
+++ b/src/icons/undo_instance.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/misc/settings.py b/src/misc/settings.py
index 5c3e197..c4babbc 100644
--- a/src/misc/settings.py
+++ b/src/misc/settings.py
@@ -12,4 +12,8 @@ class CoPickSettings(Settings):
"spotlight_mode": "volume", # Render mode: surface, mesh, volume, mip
"spotlight_features": "dark", # Feature polarity in the data: dark or light
"spotlight_particles": False, # Also hide particles outside the spotlight sphere
+ "filament_tube_scale": 0.5, # Filament tube radius as a fraction of the object radius
+ "filament_pick_spacing": 0.0, # Default spacing of picks sampled along filaments (0 = object radius)
+ "paint_radius": 50.0, # Instance paint brush radius (Å)
+ "color_picks_by_instance": False, # Colour every particle list by instance ID (filaments always are)
}
diff --git a/src/misc/spotlight.py b/src/misc/spotlight.py
index d94db5b..e32b44a 100644
--- a/src/misc/spotlight.py
+++ b/src/misc/spotlight.py
@@ -728,14 +728,14 @@ def _update_particle_masks(self, center):
dy = p["pos_y"] - cy
dz = p["pos_z"] - cz
mask[idx] = dx * dx + dy * dy + dz * dz <= r2max
- pl.displayed_particles = mask
+ pl.displayed_particles = mask & self.tool.instance_mask(pl)
def _particles_show_all(self):
- """Show all particles of all loaded lists (the spotlight-mode default)."""
+ """Show all particles of all loaded lists (the spotlight-mode default), except instances hidden by ID."""
for pl in self.tool.picks_map.values():
if pl is None or pl.deleted:
continue
- pl.displayed_particles = True
+ pl.displayed_particles = self.tool.instance_mask(pl)
# ---------------------------------------------------------------------- helpers
diff --git a/src/mouse/mousemodes.py b/src/mouse/mousemodes.py
index 9e0140d..140e972 100644
--- a/src/mouse/mousemodes.py
+++ b/src/mouse/mousemodes.py
@@ -35,3 +35,171 @@ def wheel(self, event):
new_pos = vol.slab_position + event.wheel_value() * vol.pixelsize[0]
new_pos = min(max(new_pos, vmin), vmax)
vol.slab_position = new_pos
+
+
+class _CopickPlaneMode(MouseMode):
+ """Base for modes that act on the point where the mouse ray meets the shown tomogram plane (single plane,
+ orthoplanes or tilted slab)."""
+
+ def _tool(self):
+ return getattr(self.session, "copick", None)
+
+ def _ray(self, event):
+ x, y = event.position()
+ return self.session.main_view.clip_plane_points(x, y)
+
+ def _plane_point(self, event):
+ from chimerax.markers.mouse import volume_plane_intercept
+
+ tool = self._tool()
+ if tool is None or tool.active_volume is None or tool.active_volume.deleted:
+ return None
+ xyz1, xyz2 = self._ray(event)
+ if xyz1 is None:
+ return None
+ xyz, _v = volume_plane_intercept(xyz1, xyz2, [tool.active_volume])
+ if xyz is None:
+ self.session.logger.status("copick: click on the shown tomogram plane (slab, orthoplanes or tilted slab)")
+ return xyz
+
+ def _pixel_tolerance(self, xyz, pixels: float = 8.0) -> float:
+ return self.session.main_view.pixel_size(xyz) * pixels
+
+
+class TraceFilamentMode(_CopickPlaneMode):
+ """Click on the tomogram plane to add a control point to the active filament; drag a control point to move it;
+ shift-click a control point to remove it."""
+
+ name = "trace copick filament"
+
+ def __init__(self, session):
+ _CopickPlaneMode.__init__(self, session)
+ self._drag = None
+
+ def _controller(self):
+ tool = self._tool()
+ return getattr(tool, "filaments", None) if tool is not None else None
+
+ def enable(self):
+ ctl = self._controller()
+ if ctl is not None:
+ ctl.mode_enabled(self.name)
+
+ def mouse_down(self, event):
+ MouseMode.mouse_down(self, event)
+ ctl = self._controller()
+ if ctl is None or ctl.active is None:
+ self.session.logger.status("copick: open or create a filament set first")
+ return
+ xyz1, xyz2 = self._ray(event)
+ if xyz1 is not None:
+ p = self._plane_point(event)
+ tol = self._pixel_tolerance(p if p is not None else xyz1)
+ hit = ctl.hit_control(xyz1, xyz2, tol)
+ if hit is not None:
+ if event.shift_down():
+ ctl.remove_point(*hit)
+ else:
+ ctl.set_active_filament(hit[0])
+ ctl.begin_drag()
+ self._drag = hit
+ return
+ p = self._plane_point(event)
+ if p is not None:
+ ctl.add_point(p)
+
+ def mouse_drag(self, event):
+ if self._drag is None:
+ return
+ p = self._plane_point(event)
+ if p is not None:
+ self._controller().move_point(self._drag[0], self._drag[1], p)
+
+ def mouse_up(self, event):
+ if self._drag is not None:
+ ctl = self._controller()
+ if ctl is not None:
+ ctl.end_drag()
+ self._drag = None
+ MouseMode.mouse_up(self, event)
+
+
+class CutFilamentMode(TraceFilamentMode):
+ """Click on a filament (on the tomogram plane) to cut it in two there."""
+
+ name = "cut copick filament"
+
+ def mouse_down(self, event):
+ MouseMode.mouse_down(self, event)
+ ctl = self._controller()
+ if ctl is None or ctl.active is None:
+ self.session.logger.status("copick: open or create a filament set first")
+ return
+ p = self._plane_point(event)
+ if p is not None:
+ ctl.cut_at(p)
+
+ def mouse_drag(self, event):
+ pass
+
+ def mouse_up(self, event):
+ MouseMode.mouse_up(self, event)
+
+
+class PaintInstanceMode(_CopickPlaneMode):
+ """Paint the current instance ID (a sphere of the brush radius) where the mouse meets the tomogram plane."""
+
+ name = "paint copick instance"
+ value = None # None = the current ID
+
+ def _controller(self):
+ tool = self._tool()
+ return getattr(tool, "segmentations", None) if tool is not None else None
+
+ def _apply(self, event):
+ ctl = self._controller()
+ if ctl is None or not ctl.editing:
+ self.session.logger.status("copick: start editing an instance segmentation first")
+ return
+ p = self._plane_point(event)
+ if p is not None:
+ ctl.paint(p, value=self.value)
+
+ def mouse_down(self, event):
+ MouseMode.mouse_down(self, event)
+ self._apply(event)
+
+ def mouse_drag(self, event):
+ self._apply(event)
+
+ def mouse_up(self, event):
+ ctl = self._controller()
+ if ctl is not None:
+ ctl.flush()
+ MouseMode.mouse_up(self, event)
+
+
+class EraseInstanceMode(PaintInstanceMode):
+ """Erase instance voxels (set them to background)."""
+
+ name = "erase copick instance"
+ value = 0
+
+
+class PickInstanceMode(_CopickPlaneMode):
+ """Make the instance ID under the mouse (on the tomogram plane) the current ID."""
+
+ name = "pick copick instance"
+
+ def mouse_down(self, event):
+ MouseMode.mouse_down(self, event)
+ tool = self._tool()
+ ctl = getattr(tool, "segmentations", None) if tool is not None else None
+ if ctl is None or not ctl.editing:
+ return
+ p = self._plane_point(event)
+ if p is not None:
+ ctl.pick(p)
+
+
+COPICK_EDIT_MODES = (TraceFilamentMode, CutFilamentMode, PaintInstanceMode, EraseInstanceMode, PickInstanceMode)
diff --git a/src/segmentation/__init__.py b/src/segmentation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/segmentation/controller.py b/src/segmentation/controller.py
new file mode 100644
index 0000000..2bd137c
--- /dev/null
+++ b/src/segmentation/controller.py
@@ -0,0 +1,476 @@
+"""Instance and panoptic segmentations in a running copick tool: surface display (computed off the UI thread) and
+instance editing (``paint`` / ``erase`` / ``pick copick instance`` mouse modes, merge / delete / undo, save).
+
+Binary and multilabel segmentations keep the existing volume-surface path in ``tool.show_volume_from_segmentation``.
+"""
+
+from typing import Any, Callable, Dict, Iterable, List, Optional, Set
+
+import numpy as np
+from copick_shared_ui.core.types import segmentation_type_of
+from copick_shared_ui.util.instances import (
+ InstanceRow,
+ instance_colors,
+ label_bboxes,
+ label_counts,
+ panoptic_segments,
+ rows_from_counts,
+)
+from superqt.utils import thread_worker
+
+from .instance_edit import InstanceEditSession
+from .label_surfaces import LabelSurfaceModel, compute_label_surfaces, read_label_level
+
+MAX_PANOPTIC_SEGMENTS = 500
+
+
+def _instance_colors(ids: Iterable[int]) -> Dict[int, np.ndarray]:
+ ids = list(ids)
+ colors = instance_colors(ids, (180, 180, 180, 255), dtype=np.uint8)
+ return {int(i): c for i, c in zip(ids, colors, strict=True)}
+
+
+def _load_impl(seg: Any, level: int, objects: Dict[int, tuple]):
+ """Read a level and compute the surfaces (worker thread)."""
+ kind = segmentation_type_of(seg)
+ data, voxel_size, level = read_label_level(seg, level)
+ result = {"segmentation": seg, "kind": kind, "voxel_size": voxel_size, "level": level}
+ if kind == "panoptic":
+ segments, rows = panoptic_segments(data[0], data[1], objects)
+ result["rows"] = rows
+ if len(rows) > MAX_PANOPTIC_SEGMENTS:
+ # Too many segments for one surface each: show the label channel (one surface per object) instead.
+ labels = data[0]
+ result["fallback"] = True
+ result["geometry"] = compute_label_surfaces(labels, voxel_size)
+ result["bboxes"] = label_bboxes(labels)
+ else:
+ result["geometry"] = compute_label_surfaces(segments, voxel_size)
+ result["bboxes"] = label_bboxes(segments)
+ else:
+ result["volume"] = data
+ result["counts"] = label_counts(data)
+ result["bboxes"] = label_bboxes(data)
+ result["geometry"] = compute_label_surfaces(data, voxel_size)
+ return result
+
+
+_load_worker = thread_worker(_load_impl)
+
+
+def _surfaces_impl(volume: np.ndarray, voxel_size: float, ids: Optional[List[int]]):
+ return compute_label_surfaces(volume, voxel_size, ids)
+
+
+_surfaces_worker = thread_worker(_surfaces_impl)
+
+
+def _save_impl(edit: InstanceEditSession, user_id: str, session_id: str, target_shape):
+ return edit.save(user_id=user_id, session_id=session_id, target_shape=target_shape)
+
+
+_save_worker = thread_worker(_save_impl)
+
+
+class SegmentationController:
+ """Owns the label-surface models of instance and panoptic segmentations, and the instance editing session."""
+
+ def __init__(self, tool: Any):
+ self.tool = tool
+ self.session = tool.session
+ self.models: Dict[Any, LabelSurfaceModel] = {} # CopickSegmentation -> model
+ self.info: Dict[Any, dict] = {} # CopickSegmentation -> {"kind", "rows", "bboxes", "voxel_size", ...}
+ self.edit: Optional[InstanceEditSession] = None
+ self.edit_model: Optional[LabelSurfaceModel] = None
+ self.edit_key: Any = None
+ self.active_key: Any = None
+ self.paint_radius = float(getattr(tool.settings, "paint_radius", 50.0))
+ self.background_only = False
+ self.listeners: List[Callable[[], None]] = []
+ self._workers = []
+ self._pending_touched: Set[int] = set()
+ self._prev_right_mode: Optional[str] = None
+
+ def notify(self) -> None:
+ for listener in list(self.listeners):
+ try:
+ listener()
+ except Exception as e:
+ self.session.logger.warning(f"copick: segmentation listener failed: {e}")
+
+ def _keep(self, worker) -> None:
+ self._workers.append(worker)
+ worker.finished.connect(lambda: self._workers.remove(worker) if worker in self._workers else None)
+
+ def _objects(self, root: Any) -> Dict[int, tuple]:
+ return {
+ o.label: (o.name, tuple(np.asarray(o.color or (128, 128, 128, 255), dtype=float) / 255.0))
+ for o in root.config.pickable_objects
+ }
+
+ # -- display -----------------------------------------------------------------------------------------------
+
+ def show(
+ self,
+ seg: Any,
+ level: Optional[int] = None,
+ on_done: Optional[Callable[[Any], None]] = None,
+ blocking: bool = False,
+ ) -> None:
+ """Load and show an instance or panoptic segmentation as surfaces."""
+ level = self.tool.settings.zarr_level if level is None else level
+ if seg in self.models and not self.models[seg].deleted:
+ self.models[seg].display = True
+ self.active_key = seg
+ self.notify()
+ if on_done:
+ on_done(self.models[seg])
+ return
+ objects = self._objects(seg.run.root)
+
+ def finished(result: dict) -> None:
+ model = self._build_model(result)
+ if on_done:
+ on_done(model)
+
+ if blocking:
+ finished(_load_impl(seg, level, objects))
+ return
+ self.session.logger.status(f"copick: loading {segmentation_type_of(seg)} segmentation {seg.name}...")
+ worker = _load_worker(seg, level, objects)
+ worker.returned.connect(finished)
+ worker.errored.connect(lambda e: self.session.logger.error(f"copick: loading {seg.name} failed: {e}"))
+ self._keep(worker)
+ worker.start()
+
+ def _build_model(self, result: dict) -> LabelSurfaceModel:
+ seg = result["segmentation"]
+ kind = result["kind"]
+ model = LabelSurfaceModel(f"{seg.name} {kind} ({seg.user_id}/{seg.session_id})", self.session)
+ self.session.models.add([model])
+ if kind == "panoptic":
+ rows: List[InstanceRow] = result["rows"]
+ if result.get("fallback"):
+ objects = self._objects(seg.run.root)
+ colors = {k: (np.asarray(v[1]) * 255).astype(np.uint8) for k, v in objects.items()}
+ names = {k: v[0] for k, v in objects.items()}
+ self.session.logger.warning(
+ f"copick: {len(rows)} panoptic segments; showing one surface per object instead.",
+ )
+ else:
+ colors = {r.row_key: (np.asarray(r.color) * 255).astype(np.uint8) for r in rows}
+ names = {r.row_key: f"{r.label} #{r.instance_id}" if r.instance_id else r.label for r in rows}
+ model.set_surfaces(result["geometry"], colors, names)
+ else:
+ counts = result["counts"]
+ rows = rows_from_counts(counts, label=seg.name)
+ model.set_surfaces(result["geometry"], _instance_colors(counts), {i: f"{seg.name} #{i}" for i in counts})
+ self.models[seg] = model
+ self.info[seg] = {
+ "kind": kind,
+ "rows": rows,
+ "bboxes": result["bboxes"],
+ "voxel_size": result["voxel_size"],
+ "level": result["level"],
+ "fallback": result.get("fallback", False),
+ }
+ model.copick_segmentation = seg
+ self.active_key = seg
+ self.session.logger.status("")
+ self.notify()
+ return model
+
+ def hide(self, seg: Any) -> None:
+ model = self.models.get(seg)
+ if model is not None and not model.deleted:
+ model.display = False
+ self.notify()
+
+ def close_all(self) -> None:
+ self.stop_editing()
+ for model in self.models.values():
+ if not model.deleted:
+ model.delete()
+ self.models.clear()
+ self.info.clear()
+ self.edit = None
+ self.edit_model = None
+ self.edit_key = None
+ self.active_key = None
+ self.notify()
+
+ def forget(self, seg: Any) -> None:
+ model = self.models.pop(seg, None)
+ self.info.pop(seg, None)
+ if model is not None and not model.deleted:
+ model.delete()
+ if self.edit_key is seg:
+ self.edit = None
+ self.edit_model = None
+ self.edit_key = None
+ self.notify()
+
+ def rows(self, seg: Any) -> List[InstanceRow]:
+ if self.edit is not None and self.edit_key is seg:
+ return rows_from_counts(self.edit.counts(), label=self.edit.object_name)
+ return list(self.info.get(seg, {}).get("rows", []))
+
+ def focus(self, seg: Any, key: int) -> None:
+ if self.edit is not None and self.edit_key is seg:
+ xyz = self.edit.center_of(key)
+ else:
+ info = self.info.get(seg)
+ box = info["bboxes"].get(int(key)) if info else None
+ xyz = (
+ None
+ if box is None
+ else np.array([(s.start + s.stop - 1) / 2.0 for s in box])[::-1] * info["voxel_size"]
+ )
+ if xyz is not None:
+ self.tool.focus_xyz(xyz, radius=4 * (self.edit.voxel_size if self.edit else 20.0))
+
+ def set_visible(self, seg: Any, keys: Iterable[int]) -> None:
+ model = self.models.get(seg)
+ if model is not None and not model.deleted:
+ model.set_visible_keys(keys)
+
+ # -- editing ---------------------------------------------------------------------------------------------------
+
+ def start_editing(
+ self,
+ seg: Any = None,
+ object_name: Optional[str] = None,
+ user_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ blocking: bool = False,
+ on_ready: Optional[Callable[[], None]] = None,
+ ) -> None:
+ """Edit an instance segmentation (loaded at level 0), or a new empty one for ``object_name`` at the active
+ tomogram's voxel spacing."""
+ if seg is not None:
+
+ def ready(result: dict) -> None:
+ existing = self.models.get(seg)
+ if existing is not None and not existing.deleted and self.info.get(seg, {}).get("level", 0) != 0:
+ existing.delete()
+ self.models.pop(seg, None)
+ model = self.models.get(seg)
+ if model is None or model.deleted:
+ model = self._build_model(result)
+ self.edit = InstanceEditSession(
+ seg.run,
+ seg.name,
+ seg.user_id,
+ seg.session_id,
+ result["volume"],
+ result["voxel_size"],
+ seg.voxel_size,
+ level=result["level"],
+ source=seg,
+ )
+ self.edit._counts = result["counts"]
+ self.edit._bboxes = result["bboxes"]
+ self.edit_model, self.edit_key = model, seg
+ self._enter_mode()
+ if on_ready:
+ on_ready()
+
+ if blocking:
+ ready(_load_impl(seg, 0, {}))
+ else:
+ worker = _load_worker(seg, 0, {})
+ worker.returned.connect(ready)
+ worker.errored.connect(lambda e: self.session.logger.error(f"copick: loading {seg.name} failed: {e}"))
+ self._keep(worker)
+ worker.start()
+ return
+ vol = self.tool.active_volume
+ if vol is None or vol.deleted:
+ raise ValueError("Open a tomogram first.")
+ import zarr
+
+ tomo = vol.copick_tomo
+ shape = zarr.open(tomo.zarr(), mode="r")["0"].shape
+ vs = float(tomo.voxel_spacing.voxel_size)
+ run = tomo.voxel_spacing.run
+ root = run.root
+ user_id = user_id or root.user_id or "ArtiaX"
+ self.edit = InstanceEditSession(
+ run,
+ object_name,
+ user_id,
+ session_id or "manual",
+ np.zeros(shape, dtype=np.uint16),
+ vs,
+ vs,
+ level=0,
+ )
+ model = LabelSurfaceModel(f"{object_name} instance ({user_id}/{self.edit.session_id}) (new)", self.session)
+ self.session.models.add([model])
+ self.edit_model, self.edit_key = model, self.edit
+ self.models[self.edit] = model
+ self.info[self.edit] = {"kind": "instance", "rows": [], "bboxes": {}, "voxel_size": vs, "level": 0}
+ self._enter_mode()
+ if on_ready:
+ on_ready()
+
+ def _enter_mode(self) -> None:
+ from chimerax.core.commands import run
+
+ bindings = self.session.ui.mouse_modes.bindings
+ right = [b.mode.name for b in bindings if b.button == "right" and not b.modifiers]
+ if right and "copick instance" not in right[0]:
+ self._prev_right_mode = right[0]
+ self.active_key = self.edit_key
+ run(self.session, "ui mousemode right 'paint copick instance'", log=False)
+ self.tool.annotate("instances")
+ self.session.logger.info(
+ f"copick: editing instances of {self.edit.object_name} (current ID {self.edit.current_id}, "
+ f"brush {self.paint_radius:g} Å)",
+ )
+ self.notify()
+
+ def stop_editing(self) -> None:
+ from chimerax.core.commands import run
+
+ if self.edit is None:
+ return
+ if self._prev_right_mode:
+ run(self.session, f"ui mousemode right '{self._prev_right_mode}'", log=False)
+ self._prev_right_mode = None
+ self.notify()
+
+ @property
+ def editing(self) -> bool:
+ return self.edit is not None
+
+ def paint(self, xyz, value: Optional[int] = None) -> Set[int]:
+ if self.edit is None:
+ return set()
+ touched = self.edit.paint(xyz, self.paint_radius, value=value, background_only=self.background_only)
+ self._pending_touched |= touched
+ return touched
+
+ def pick(self, xyz) -> int:
+ if self.edit is None:
+ return 0
+ value = self.edit.pick(xyz)
+ if value:
+ self.edit.current_id = value
+ self.session.logger.status(f"copick: current instance ID {value}")
+ self.notify()
+ return value
+
+ def new_id(self) -> int:
+ if self.edit is None:
+ return 0
+ self.edit.current_id = self.edit.next_id()
+ self.notify()
+ return self.edit.current_id
+
+ def set_current_id(self, value: int) -> None:
+ if self.edit is not None:
+ self.edit.current_id = int(value)
+ self.notify()
+
+ def relabel(self, ids: Iterable[int], value: int) -> None:
+ if self.edit is None:
+ return
+ self._pending_touched |= self.edit.relabel(ids, value)
+ self.flush()
+
+ def undo(self) -> None:
+ if self.edit is None:
+ return
+ self._pending_touched |= self.edit.undo()
+ self.flush()
+
+ def flush(self, blocking: bool = False) -> None:
+ """Rebuild the surfaces of the IDs touched since the last flush."""
+ if self.edit is None or not self._pending_touched:
+ return
+ touched = sorted(self._pending_touched)
+ self._pending_touched = set()
+ volume, voxel_size = self.edit.volume, self.edit.voxel_size
+ ids = touched if len(touched) <= 8 else None
+
+ def apply(geometry: dict) -> None:
+ model = self.edit_model
+ if model is None or model.deleted:
+ return
+ present = set(self.edit.counts())
+ gone = [i for i in touched if i not in present]
+ model.remove_keys(gone)
+ name = self.edit.object_name
+ model.set_surfaces(
+ geometry,
+ _instance_colors(geometry),
+ {i: f"{name} #{i}" for i in geometry},
+ replace=ids is None,
+ )
+ self.info.setdefault(self.edit_key, {})["bboxes"] = self.edit.bboxes()
+ self.notify()
+
+ if blocking:
+ apply(_surfaces_impl(volume.copy(), voxel_size, ids))
+ return
+ worker = _surfaces_worker(volume.copy(), voxel_size, ids)
+ worker.returned.connect(apply)
+ worker.errored.connect(lambda e: self.session.logger.error(f"copick: surface update failed: {e}"))
+ self._keep(worker)
+ worker.start()
+
+ def save(
+ self,
+ user_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ on_done: Optional[Callable[[Any], None]] = None,
+ blocking: bool = False,
+ ):
+ if self.edit is None:
+ self.session.logger.warning("copick: no instance segmentation is being edited.")
+ return None
+ edit = self.edit
+ user_id = user_id or edit.user_id
+ session_id = session_id or edit.session_id
+ if str(session_id) == "0":
+ self.session.logger.error("copick: session 0 is reserved for tool output; save under another session.")
+ return None
+ target_shape = None
+ vol = self.tool.active_volume
+ if edit.level != 0 and vol is not None and not vol.deleted:
+ import zarr
+
+ target_shape = zarr.open(vol.copick_tomo.zarr(), mode="r")["0"].shape
+ old_key = self.edit_key
+
+ def finished(seg: Any) -> None:
+ model = self.models.pop(old_key, None)
+ info = self.info.pop(old_key, None)
+ if model is not None:
+ self.models[seg] = model
+ model.name = f"{seg.name} instance ({seg.user_id}/{seg.session_id})"
+ model.copick_segmentation = seg
+ if info is not None:
+ self.info[seg] = info
+ self.edit_key = seg
+ self.active_key = seg
+ self.session.logger.info(
+ f"copick: saved instance segmentation {seg.name}:{seg.user_id}/{seg.session_id}@{seg.voxel_size} "
+ f"({len(edit.counts())} instances)",
+ )
+ self.tool._mw.update_segmentations_table()
+ self.notify()
+ if on_done:
+ on_done(seg)
+
+ if blocking:
+ seg = _save_impl(edit, user_id, session_id, target_shape)
+ finished(seg)
+ return seg
+ worker = _save_worker(edit, user_id, session_id, target_shape)
+ worker.returned.connect(finished)
+ worker.errored.connect(lambda e: self.session.logger.error(f"copick: saving instances failed: {e}"))
+ self._keep(worker)
+ worker.start()
+ return worker
diff --git a/src/segmentation/instance_edit.py b/src/segmentation/instance_edit.py
new file mode 100644
index 0000000..7cf25ba
--- /dev/null
+++ b/src/segmentation/instance_edit.py
@@ -0,0 +1,219 @@
+"""Editing state for an instance segmentation in ChimeraX: an in-memory label volume with a sphere brush, picking,
+merge / delete, a bounding-box undo stack, and saving through copick (``new_segmentation(is_instance=True)``).
+
+ChimeraX's own segmentation tool writes only 0/1 masks, so multi-ID editing works on this numpy array; the viewer
+rebuilds the surfaces of the IDs each edit touches. Qt-free.
+"""
+
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
+
+import numpy as np
+from copick_shared_ui.util.instances import delete_labels, label_bboxes, label_counts, merge_labels, next_instance_id
+
+MAX_UNDO = 20
+
+
+class InstanceEditSession:
+ """An instance segmentation being edited.
+
+ Args:
+ run: The copick run.
+ object_name: Pickable object the instances belong to.
+ user_id, session_id: Identity used when saving.
+ volume: ``(Z, Y, X)`` instance IDs.
+ voxel_size: Voxel size of ``volume`` in Angstrom (the loaded pyramid level).
+ base_voxel_size: Voxel spacing the segmentation is stored at (level 0).
+ level: Pyramid level of ``volume`` (0 = full resolution).
+ source: The ``CopickSegmentation`` it was opened from, if any.
+ """
+
+ def __init__(
+ self,
+ run: Any,
+ object_name: str,
+ user_id: str,
+ session_id: str,
+ volume: np.ndarray,
+ voxel_size: float,
+ base_voxel_size: float,
+ level: int = 0,
+ source: Any = None,
+ ):
+ self.run = run
+ self.object_name = object_name
+ self.user_id = user_id
+ self.session_id = session_id
+ self.volume = np.ascontiguousarray(volume)
+ if self.volume.dtype.kind not in "ui" or self.volume.dtype.itemsize < 2:
+ self.volume = self.volume.astype(np.uint16)
+ self.voxel_size = float(voxel_size)
+ self.base_voxel_size = float(base_voxel_size)
+ self.level = int(level)
+ self.source = source
+ self.current_id = 1
+ self.dirty = False
+ self._undo: List[Tuple[Tuple[slice, slice, slice], np.ndarray]] = []
+ self._counts: Optional[Dict[int, int]] = None
+ self._bboxes: Optional[Dict[int, Tuple[slice, slice, slice]]] = None
+ self.current_id = self.next_id()
+
+ # -- queries ---------------------------------------------------------------------------------------------------
+
+ @property
+ def read_only(self) -> bool:
+ return str(self.session_id) == "0" or bool(getattr(self.source, "read_only", False))
+
+ def counts(self) -> Dict[int, int]:
+ if self._counts is None:
+ self._counts = label_counts(self.volume)
+ return self._counts
+
+ def bboxes(self) -> Dict[int, Tuple[slice, slice, slice]]:
+ if self._bboxes is None:
+ self._bboxes = label_bboxes(self.volume)
+ return self._bboxes
+
+ def ids(self) -> List[int]:
+ return sorted(self.counts())
+
+ def next_id(self) -> int:
+ return next_instance_id(self.counts().keys())
+
+ def center_of(self, instance_id: int) -> Optional[np.ndarray]:
+ """Bounding-box centre of an ID, ``[x, y, z]`` Angstrom."""
+ box = self.bboxes().get(int(instance_id))
+ if box is None:
+ return None
+ zyx = np.array([(s.start + s.stop - 1) / 2.0 for s in box])
+ return zyx[::-1] * self.voxel_size
+
+ def index_of(self, xyz: Sequence[float]) -> Tuple[int, int, int]:
+ """``(z, y, x)`` voxel index of a point in Angstrom."""
+ ijk = np.round(np.asarray(xyz, dtype=float) / self.voxel_size).astype(int)
+ return int(ijk[2]), int(ijk[1]), int(ijk[0])
+
+ def pick(self, xyz: Sequence[float]) -> int:
+ """The ID at a point (0 outside the volume or on background)."""
+ z, y, x = self.index_of(xyz)
+ if not (0 <= z < self.volume.shape[0] and 0 <= y < self.volume.shape[1] and 0 <= x < self.volume.shape[2]):
+ return 0
+ return int(self.volume[z, y, x])
+
+ # -- edits -----------------------------------------------------------------------------------------------------
+
+ def _invalidate(self) -> None:
+ self._counts = None
+ self._bboxes = None
+ self.dirty = True
+
+ def _push_undo(self, region: Tuple[slice, slice, slice]) -> None:
+ self._undo.append((region, self.volume[region].copy()))
+ if len(self._undo) > MAX_UNDO:
+ self._undo.pop(0)
+
+ def _ensure_capacity(self, value: int) -> None:
+ if value > np.iinfo(self.volume.dtype).max:
+ self.volume = self.volume.astype(np.uint32)
+
+ def paint(
+ self,
+ xyz: Sequence[float],
+ radius: float,
+ value: Optional[int] = None,
+ background_only: bool = False,
+ ) -> Set[int]:
+ """Write ``value`` (default the current ID; 0 erases) into a sphere of ``radius`` Angstrom around ``xyz``.
+
+ Returns:
+ The IDs whose voxels changed (including ``value`` unless it is 0).
+ """
+ value = self.current_id if value is None else int(value)
+ self._ensure_capacity(value)
+ center = np.asarray(self.index_of(xyz), dtype=float)
+ r = max(float(radius) / self.voxel_size, 0.5)
+ lo = np.maximum(np.floor(center - r).astype(int), 0)
+ hi = np.minimum(np.ceil(center + r).astype(int) + 1, self.volume.shape)
+ if np.any(hi <= lo):
+ return set()
+ region = tuple(slice(int(a), int(b)) for a, b in zip(lo, hi, strict=True))
+ zz, yy, xx = np.ogrid[region[0], region[1], region[2]]
+ mask = (zz - center[0]) ** 2 + (yy - center[1]) ** 2 + (xx - center[2]) ** 2 <= r * r
+ block = self.volume[region]
+ if background_only and value != 0:
+ mask &= block == 0
+ if not mask.any():
+ return set()
+ self._push_undo(region)
+ touched = {int(i) for i in np.unique(block[mask]) if i != 0}
+ block[mask] = value
+ if value:
+ touched.add(value)
+ self._invalidate()
+ return touched
+
+ def relabel(self, ids: Iterable[int], value: int) -> Set[int]:
+ """Set every voxel of ``ids`` to ``value`` (0 deletes). The whole volume is one undo step."""
+ ids = [int(i) for i in ids if int(i) != int(value)]
+ if not ids:
+ return set()
+ region = (slice(None), slice(None), slice(None))
+ self._push_undo(region)
+ if value:
+ self._ensure_capacity(value)
+ merge_labels(self.volume, ids, value)
+ else:
+ delete_labels(self.volume, ids)
+ self._invalidate()
+ return set(ids) | ({int(value)} if value else set())
+
+ def undo(self) -> Set[int]:
+ """Undo the last edit; returns the IDs present before or after in the restored region."""
+ if not self._undo:
+ return set()
+ region, old = self._undo.pop()
+ now = self.volume[region]
+ touched = {int(i) for i in np.unique(now) if i != 0} | {int(i) for i in np.unique(old) if i != 0}
+ self.volume[region] = old
+ self._invalidate()
+ return touched
+
+ # -- saving ----------------------------------------------------------------------------------------------------
+
+ def full_resolution(self, target_shape: Optional[Sequence[int]] = None) -> np.ndarray:
+ """The volume at level 0 (nearest-neighbour upsampling when edited at a coarser level)."""
+ if self.level == 0 and (target_shape is None or tuple(target_shape) == self.volume.shape):
+ return self.volume
+ from scipy.ndimage import zoom
+
+ if target_shape is None:
+ factor = self.voxel_size / self.base_voxel_size
+ target_shape = tuple(int(round(s * factor)) for s in self.volume.shape)
+ factors = [t / s for t, s in zip(target_shape, self.volume.shape, strict=True)]
+ up = zoom(self.volume, factors, order=0)
+ out = np.zeros(target_shape, dtype=self.volume.dtype)
+ sl = tuple(slice(0, min(a, b)) for a, b in zip(up.shape, target_shape, strict=True))
+ out[sl] = up[sl]
+ return out
+
+ def save(
+ self,
+ user_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ exist_ok: bool = True,
+ target_shape: Optional[Sequence[int]] = None,
+ ) -> Any:
+ """Store as an instance segmentation (copick picks the dtype, uint16 or wider)."""
+ user_id = user_id or self.user_id
+ session_id = session_id or self.session_id
+ seg = self.run.new_segmentation(
+ self.base_voxel_size,
+ self.object_name,
+ session_id,
+ user_id=user_id,
+ is_instance=True,
+ exist_ok=exist_ok,
+ )
+ seg.from_numpy(self.full_resolution(target_shape), levels=3)
+ self.user_id, self.session_id, self.source = user_id, session_id, seg
+ self.dirty = False
+ return seg
diff --git a/src/segmentation/label_surfaces.py b/src/segmentation/label_surfaces.py
new file mode 100644
index 0000000..221b1f4
--- /dev/null
+++ b/src/segmentation/label_surfaces.py
@@ -0,0 +1,126 @@
+"""Surfaces for label volumes (instance and panoptic segmentations): one ChimeraX ``Surface`` per label, grouped under
+a parent model, coloured per instance (copick-shared-ui colours, so an ID looks the same as its picks and filaments).
+
+The geometry is computed with ChimeraX's C routine ``chimerax.segment.segmentation_surfaces`` (uint8 to uint32 label
+maps), off the UI thread; surfaces are created on the main thread.
+"""
+
+from typing import Dict, Iterable, Optional, Tuple
+
+import numpy as np
+import zarr
+from chimerax.core.models import Model, Surface
+
+Geometry = Tuple[np.ndarray, np.ndarray, np.ndarray] # vertices, normals, triangles
+
+
+def level_keys(group) -> list:
+ return sorted((k for k in group if k.isdigit()), key=int)
+
+
+def level_voxel_size(group, path: str, base_voxel_size: float) -> float:
+ """Spatial voxel size of a pyramid level from the OME-NGFF metadata (scale only; copick writes no translation)."""
+ try:
+ for ds in group.attrs["multiscales"][0]["datasets"]:
+ if ds["path"] == path:
+ for t in ds["coordinateTransformations"]:
+ if t["type"] == "scale":
+ return float(t["scale"][-1])
+ except (KeyError, IndexError, TypeError):
+ pass
+ return base_voxel_size * 2 ** int(path)
+
+
+def read_label_level(seg, level: int) -> Tuple[np.ndarray, float, int]:
+ """Read one pyramid level of a segmentation: ``(array, voxel size of that level, level used)``. Panoptic arrays
+ come back as ``(2, Z, Y, X)``."""
+ group = zarr.open(seg.zarr(), mode="r")
+ keys = level_keys(group)
+ if not keys:
+ raise ValueError(f"No pyramid levels in segmentation {seg.name}")
+ level = max(0, min(int(level), len(keys) - 1))
+ path = keys[level]
+ return np.asarray(group[path]), level_voxel_size(group, path, seg.voxel_size), level
+
+
+def compute_label_surfaces(
+ volume: np.ndarray,
+ voxel_size: float,
+ ids: Optional[Iterable[int]] = None,
+) -> Dict[int, Geometry]:
+ """Surface geometry per label of a ``(Z, Y, X)`` label volume, in Angstrom. ``ids`` restricts the labels."""
+ from chimerax.segment import segmentation_surfaces
+ from chimerax.surface import calculate_vertex_normals
+
+ vol = np.ascontiguousarray(volume)
+ if vol.dtype.kind not in "ui" or vol.dtype.itemsize > 4:
+ vol = vol.astype(np.uint32)
+ wanted = None if ids is None else {int(i) for i in ids}
+ out: Dict[int, Geometry] = {}
+ if wanted is not None and len(wanted) <= 8:
+ # A few labels: compute each on its bounding box (cheap partial rebuilds after edits).
+ for label in wanted:
+ idx = np.nonzero(vol == label)
+ if len(idx[0]) == 0:
+ continue
+ lo = [max(0, int(a.min()) - 1) for a in idx]
+ hi = [int(a.max()) + 2 for a in idx]
+ sub = np.ascontiguousarray((vol[lo[0] : hi[0], lo[1] : hi[1], lo[2] : hi[2]] == label).astype(np.uint8))
+ for _rid, va, ta in segmentation_surfaces(sub):
+ va = (va + np.array([lo[2], lo[1], lo[0]], dtype=np.float32)) * voxel_size
+ out[label] = (va.astype(np.float32), calculate_vertex_normals(va, ta), ta)
+ return out
+ for label, va, ta in segmentation_surfaces(vol):
+ label = int(label)
+ if label == 0 or (wanted is not None and label not in wanted):
+ continue
+ va = (va * voxel_size).astype(np.float32)
+ out[label] = (va, calculate_vertex_normals(va, ta), ta)
+ return out
+
+
+class LabelSurfaceModel(Model):
+ """A parent model holding one surface per label key."""
+
+ def __init__(self, name: str, session):
+ Model.__init__(self, name, session)
+ self.surfaces: Dict[int, Surface] = {}
+ self._hidden = set()
+
+ def set_surfaces(
+ self,
+ geometry: Dict[int, Geometry],
+ colors: Dict[int, Iterable[int]],
+ names: Optional[Dict[int, str]] = None,
+ replace: bool = True,
+ ) -> None:
+ """Create or update the surfaces of ``geometry``; with ``replace``, drop surfaces of keys not in it."""
+ if replace:
+ for key in [k for k in self.surfaces if k not in geometry]:
+ self.surfaces.pop(key).delete()
+ for key, (va, na, ta) in geometry.items():
+ surf = self.surfaces.get(key)
+ if surf is None or surf.deleted:
+ surf = Surface((names or {}).get(key, f"label {key}"), self.session)
+ surf.clip_cap = True
+ self.add([surf])
+ self.surfaces[key] = surf
+ surf.set_geometry(va, na, ta)
+ surf.color = np.asarray(colors.get(key, (180, 180, 180, 255)), dtype=np.uint8)
+ surf.display = key not in self._hidden
+ surf.copick_label = key
+
+ def remove_keys(self, keys: Iterable[int]) -> None:
+ for key in keys:
+ surf = self.surfaces.pop(int(key), None)
+ if surf is not None and not surf.deleted:
+ surf.delete()
+
+ def set_visible_keys(self, keys: Iterable[int]) -> None:
+ keys = {int(k) for k in keys}
+ self._hidden = set(self.surfaces) - keys
+ for key, surf in self.surfaces.items():
+ surf.display = key in keys
+
+ def key_for_surface(self, surface) -> Optional[int]:
+ return getattr(surface, "copick_label", None)
diff --git a/src/shortcuts/shortcuts.py b/src/shortcuts/shortcuts.py
index 1b31e29..53ba018 100644
--- a/src/shortcuts/shortcuts.py
+++ b/src/shortcuts/shortcuts.py
@@ -64,6 +64,20 @@ def copick_shortcuts() -> Tuple[List[Tuple[Any, ...]], Tuple[Any, ...]]:
"Copick",
),
("..", "artiax invert", "Invert tomogram contrast", "Visualization", {}, "Copick"),
+ # Filaments & Instances
+ ("tf", "copick filament trace toggle", "Filament trace mode on/off.", "Filaments & Instances", {}, "Copick"),
+ ("cf", "copick filament cut toggle", "Filament cut mode on/off.", "Filaments & Instances", {}, "Copick"),
+ ("nf", "copick filament new", "Start a new filament.", "Filaments & Instances", {}, "Copick"),
+ ("fn", "copick filament next", "Next filament.", "Filaments & Instances", {}, "Copick"),
+ ("fp", "copick filament previous", "Previous filament.", "Filaments & Instances", {}, "Copick"),
+ ("rv", "copick filament reverse", "Reverse the active filament.", "Filaments & Instances", {}, "Copick"),
+ ("jf", "copick filament join", "Join the selected filaments.", "Filaments & Instances", {}, "Copick"),
+ ("xf", "copick filament delete", "Delete the active filament.", "Filaments & Instances", {}, "Copick"),
+ ("pi", "copick instance paint", "Paint instance mode.", "Filaments & Instances", {}, "Copick"),
+ ("ei", "copick instance erase", "Erase instance mode.", "Filaments & Instances", {}, "Copick"),
+ ("ki", "copick instance pick", "Pick instance ID mode.", "Filaments & Instances", {}, "Copick"),
+ ("ni", "copick instance new", "New instance ID.", "Filaments & Instances", {}, "Copick"),
+ ("iu", "copick instance undo", "Undo the last instance edit.", "Filaments & Instances", {}, "Copick"),
# Info
("il", toggle_info_label, "Toggle Info Label.", "Info", {}, "Copick"),
("?", show_help, "Show Shortcuts in Log.", "Info", {}, "Copick"),
@@ -74,6 +88,7 @@ def copick_shortcuts() -> Tuple[List[Tuple[Any, ...]], Tuple[Any, ...]]:
"Particles",
"Picking",
"Visualization",
+ "Filaments & Instances",
"Info",
),
)
@@ -81,10 +96,31 @@ def copick_shortcuts() -> Tuple[List[Tuple[Any, ...]], Tuple[Any, ...]]:
return csc, catcols
+class CopickKeyboardShortcuts(Keyboard_Shortcuts):
+ """ChimeraX keyboard shortcuts that keep the platform Undo / Redo keys (Cmd+Z / Shift+Cmd+Z, Ctrl+Z / Ctrl+Y).
+
+ While shortcuts are on, keys typed in the graphics window come here instead of the command line, and the base
+ class would read Cmd+Z as an unknown shortcut. Like the command line, they undo / redo on ChimeraX's undo stack
+ (filament edits and anything else ChimeraX can undo).
+ """
+
+ def key_pressed(self, event):
+ from Qt.QtGui import QKeySequence
+
+ if event.matches(QKeySequence.StandardKey.Undo):
+ self.keys = ""
+ self.session.undo.undo()
+ elif event.matches(QKeySequence.StandardKey.Redo):
+ self.keys = ""
+ self.session.undo.redo()
+ else:
+ super().key_pressed(event)
+
+
def copick_keyboard_shortcuts(session: Session) -> Keyboard_Shortcuts:
ks = getattr(session, "copick_shortcuts", None)
if ks is None:
- session.copick_shortcuts = ks = Keyboard_Shortcuts(session)
+ session.copick_shortcuts = ks = CopickKeyboardShortcuts(session)
return ks
diff --git a/src/tool.py b/src/tool.py
index 87228bd..0c15a6a 100644
--- a/src/tool.py
+++ b/src/tool.py
@@ -28,17 +28,19 @@
from copick.models import CopickMesh, CopickPicks, CopickSegmentation
from copick.util.uri import serialize_copick_uri
from copick_shared_ui.core.thumbnail_cache import set_global_cache_config, set_global_cache_image_interface
+from copick_shared_ui.core.types import segmentation_type_flags, segmentation_type_of
+from copick_shared_ui.util.instances import instance_colors
# Qt
from Qt.QtCore import QModelIndex
from Qt.QtGui import QFont
from Qt.QtWidgets import QVBoxLayout
+from .filaments.controller import FilamentController
from .misc.colorops import palette_from_root
from .misc.meshops import ensure_mesh
from .misc.pickops import (
append_no_duplicates,
- instance_id_colors,
is_filament,
point_from_pose,
point_identity,
@@ -49,6 +51,7 @@
from .misc.spotlight import SpotlightManager
from .misc.tomostate import apply_view_state, capture_view_state
from .misc.volops import set_step
+from .segmentation.controller import SegmentationController
from .storage import density_map_store
# from .ui.pickstable import TablePicks
@@ -132,6 +135,10 @@ def __init__(self, session, tool_name):
self.settings = CoPickSettings(session, "copick", version="1")
"""Settings."""
+ # Filaments (tracing) and instance/panoptic segmentations (surfaces, instance editing); the UI binds to them.
+ self.filaments = FilamentController(self)
+ self.segmentations = SegmentationController(self)
+
# UI
self.tool_window = MainToolWindow(self, close_destroys=False)
self._build_ui()
@@ -149,19 +156,28 @@ def __init__(self, session, tool_name):
"""Map segmentations to volumes."""
self.mesh_map = {}
"""Map meshes to surface objects."""
+ self.instance_masks = {}
+ """Per particle list, the particles shown by the instance browser (hide/isolate by instance ID)."""
# Mouse Modes
- from .mouse.mousemodes import WheelMovePlanesMode
+ from .mouse.mousemodes import COPICK_EDIT_MODES, WheelMovePlanesMode
self.wheel_move_planes_mode = WheelMovePlanesMode(self.session)
self.session.ui.mouse_modes.add_mode(self.wheel_move_planes_mode)
run(self.session, "ui mousemode shift wheel 'move copick planes'")
+ for mode_class in COPICK_EDIT_MODES:
+ self.session.ui.mouse_modes.add_mode(mode_class(self.session))
# Keep trigger handler refs so they can be removed in delete(); otherwise the
# session-level handlers keep firing into a deleted tool after close session.
self._trigger_handlers = [
self.session.triggers.add_handler("app quit", self._store),
self.session.triggers.add_handler("set mouse mode", self._update_mouse_info_label),
+ self.session.triggers.add_handler("set mouse mode", self._on_mouse_mode_set),
+ self.session.triggers.add_handler("selection changed", self._on_selection_changed),
+ # Fired by models themselves (``select``, Ctrl-click, ``select clear``); the general "selection changed"
+ # trigger above only fires while atomic structures (ArtiaX particle lists) exist.
+ self.session.triggers.add_handler("model selection changed", self._on_model_selection_changed),
self.session.triggers.add_handler(MODEL_DISPLAY_CHANGED, self._on_model_display_changed),
self.session.triggers.add_handler(REMOVE_MODELS, self._on_models_removed),
]
@@ -268,6 +284,10 @@ def close_all(self):
surf.delete()
self.mesh_map = {}
+ self.instance_masks = {}
+ self.filaments.close_all()
+ self.segmentations.close_all()
+
def _store(self, *args, **kwargs):
self.store()
@@ -291,6 +311,9 @@ def store(self):
pick.points = points
pick.store()
+ # Edited filament sets opened from an editable file are stored too.
+ self.filaments.save_dirty()
+
def close_all_tomograms(self):
"""Close every loaded tomogram (run switch, project reload)."""
# Spotlight is bound to the active volume; disable before deleting it.
@@ -569,6 +592,10 @@ def _on_model_display_changed(self, _trigger_name, model):
self.show_tomogram(key)
def _on_models_removed(self, _trigger_name, models):
+ # A filament set closed outside copick: the dock stepper and the Annotate window follow.
+ filaments = getattr(self, "filaments", None)
+ if filaments is not None and any(getattr(m, "edit_session", None) is not None for m in models):
+ self.session.ui.thread_safe(filaments.notify)
# Tomograms closed outside copick (e.g. ``close #1.1.2``) leave the registry.
if self._tomo_closing:
return
@@ -636,9 +663,7 @@ def open_tomogram(self, tomo: CopickTomogramFSSpec, zarr_level: int = None):
self.close_all_tomograms()
self.store()
self.close_all()
- self._mw._picks_table.set_view(tomo.voxel_spacing.run)
- self._mw._meshes_table.set_view(tomo.voxel_spacing.run)
- self._mw._segmentations_table.set_view(tomo.voxel_spacing.run)
+ self._mw.set_tables_run(tomo.voxel_spacing.run)
key = _tomo_key(tomo)
vol = self._loaded_tomos.get(key)
@@ -754,10 +779,12 @@ def show_particles_from_picks(self, picks: CopickPicks):
partlist.radius = pick_obj.radius
# Filaments: one colour per filament, so neighbouring filaments can be told apart.
- if pick_obj is not None and is_filament(pick_obj) and partlist.size > 0:
- partlist.particle_colors = instance_id_colors(
+ by_instance = getattr(getattr(self, "settings", None), "color_picks_by_instance", False)
+ if pick_obj is not None and (is_filament(pick_obj) or by_instance) and partlist.size > 0:
+ partlist.particle_colors = instance_colors(
[p["instance_id"] for _id, p in partlist.data],
pick_obj.color,
+ dtype=np.uint8,
)
if volume is not None:
@@ -999,7 +1026,15 @@ def focus_particle(self):
return
part = pl.data[ap]
- r = pl.radius
+ self.focus_xyz((part["pos_x"], part["pos_y"], part["pos_z"]), radius=pl.radius)
+
+ if self.spotlight.enabled:
+ self.spotlight.on_active_particle((part["pos_x"], part["pos_y"], part["pos_z"]))
+
+ def focus_xyz(self, xyz, radius: float = 50.0):
+ """Move the slab / orthoplanes to a point (Angstrom) and centre the camera on it."""
+ part = {"pos_x": float(xyz[0]), "pos_y": float(xyz[1]), "pos_z": float(xyz[2])}
+ r = float(radius)
vol = self.active_volume
# While spotlight is on the source volume is hidden; repositioning its
@@ -1032,9 +1067,6 @@ def focus_particle(self):
)
run(self.session, f"cofr {part['pos_x']},{part['pos_y']},{part['pos_z']}", log=False)
- if self.spotlight.enabled:
- self.spotlight.on_active_particle((part["pos_x"], part["pos_y"], part["pos_z"]))
-
def remove_particle(self):
self._sync_stepper()
pl = self._stepper_pl
@@ -1201,9 +1233,14 @@ def duplicate_segmentation(self, index: QModelIndex, user_id: str = "", session_
pass
def new_segmentation(self, object_name: str, user_id: str, session_id: str):
- """Placeholder for new segmentation creation"""
- # TODO: Implement new segmentation creation logic
- pass
+ """Start a new (empty) instance segmentation of ``object_name`` at the active tomogram's voxel spacing and
+ switch to the paint mouse mode."""
+ if not object_name:
+ return
+ try:
+ self.segmentations.start_editing(object_name=object_name, user_id=user_id, session_id=session_id)
+ except ValueError as e:
+ self.session.logger.error(f"copick: {e}")
######################
# Delete actions #
@@ -1298,12 +1335,14 @@ def delete_segmentation(self, index: QModelIndex):
# Get the run that contains this segmentation entity
run = entity.run
- # Delete using the copick API
+ # Delete using the copick API, restricted to this segmentation's type: a binary and an instance
+ # segmentation can share name, user, session and voxel size.
run.delete_segmentations(
user_id=entity.user_id,
session_id=entity.session_id,
name=entity.name,
voxel_size=entity.voxel_size,
+ **segmentation_type_flags(segmentation_type_of(entity)),
)
# Remove from local tracking if it exists
@@ -1311,6 +1350,7 @@ def delete_segmentation(self, index: QModelIndex):
volume = self.seg_map[entity]
volume.delete()
del self.seg_map[entity]
+ self.segmentations.forget(entity)
# Update the UI
self._mw._segmentations_table.update()
@@ -1333,13 +1373,23 @@ def show_segmentation(self, index: QModelIndex):
if not isinstance(entity, CopickSegmentation):
return
- will_show = not self.seg_map[entity].display if entity in self.seg_map else True
+ will_show = not self.segmentation_shown(entity)
verb = "copick open segmentation" if will_show else "copick hide segmentation"
log_equivalent_command(self.session, build_command(verb, serialize_copick_uri(entity)))
(self._show_segmentation_entity if will_show else self._hide_segmentation_entity)(entity)
+ def segmentation_shown(self, seg: CopickSegmentation) -> bool:
+ if segmentation_type_of(seg) in ("instance", "panoptic"):
+ model = self.segmentations.models.get(seg)
+ return model is not None and not model.deleted and model.display
+ return seg in self.seg_map and self.seg_map[seg].display
+
def _show_segmentation_entity(self, seg: CopickSegmentation):
"""Load (if needed) and show a segmentation. Shared by UI + ``copick open segmentation``."""
+ if segmentation_type_of(seg) in ("instance", "panoptic"):
+ self.segmentations.show(seg)
+ self._mw.set_entity_active(seg, True)
+ return
if seg in self.seg_map:
self.seg_map[seg].display = True
self._mw.set_entity_active(seg, True)
@@ -1349,11 +1399,20 @@ def _show_segmentation_entity(self, seg: CopickSegmentation):
def _hide_segmentation_entity(self, seg: CopickSegmentation):
"""Hide a loaded segmentation (no-op if it is not currently loaded)."""
+ if segmentation_type_of(seg) in ("instance", "panoptic"):
+ self.segmentations.hide(seg)
+ self._mw.set_entity_active(seg, False)
+ return
if seg in self.seg_map:
self.seg_map[seg].display = False
self._mw.set_entity_active(seg, False)
def show_volume_from_segmentation(self, seg: CopickSegmentation):
+ # Instance and panoptic segmentations are label maps: one surface per instance / segment.
+ if segmentation_type_of(seg) in ("instance", "panoptic"):
+ self.segmentations.show(seg)
+ return
+
root = seg.run.root
name = seg.name
@@ -1390,6 +1449,133 @@ def show_volume_from_segmentation(self, seg: CopickSegmentation):
#seg_vol.color = np.array(pick_obj.color)
#print(seg_vol.child_models())
+ ####################
+ # Annotate window #
+ ####################
+ def annotate(self, page: Optional[str] = None, create: bool = True):
+ """The Copick Annotate window (filament tracing, instance editing, instance browsing); shown on ``page``
+ (``"filaments"``, ``"instances"`` or ``"picks"``) if given."""
+ from .ui.annotate_tool import get_annotate_tool
+
+ tool = get_annotate_tool(self.session, create=create)
+ if tool is not None and page is not None:
+ tool.show_page(page)
+ return tool
+
+ ####################
+ # Filament actions #
+ ####################
+ def active_run(self):
+ """The run of the shown tomogram, or None."""
+ vol = self.active_volume
+ if vol is None or vol.deleted or not hasattr(vol, "copick_tomo"):
+ return None
+ return vol.copick_tomo.voxel_spacing.run
+
+ def show_filaments(self, index: QModelIndex):
+ if not index.isValid():
+ return
+ entity = index.model().get_entity(index)
+ if entity is None:
+ return
+ will_show = not self.filaments.is_shown(entity)
+ verb = "copick open filaments" if will_show else "copick hide filaments"
+ log_equivalent_command(self.session, build_command(verb, serialize_copick_uri(entity)))
+ (self._show_filaments_entity if will_show else self._hide_filaments_entity)(entity)
+
+ def activate_filaments(self, index: QModelIndex):
+ if not index.isValid():
+ return
+ entity = index.model().get_entity(index)
+ if entity is not None and entity in self.filaments.entries:
+ self.filaments.set_active(entity)
+
+ def _show_filaments_entity(self, filaments):
+ self.filaments.show(filaments)
+ self._mw.set_entity_active(filaments, True)
+
+ def _hide_filaments_entity(self, filaments):
+ self.filaments.hide(filaments)
+ self._mw.set_entity_active(filaments, False)
+
+ def new_filaments(self, object_name: str, user_id: str, session_id: str):
+ """Start a new filament set in the active run and switch to the trace mouse mode."""
+ if not object_name:
+ return
+ try:
+ self.filaments.new(object_name, user_id, session_id)
+ except ValueError as e:
+ self.session.logger.error(f"copick: {e}")
+
+ def delete_filaments(self, index: QModelIndex):
+ if not index.isValid():
+ return
+ entity = index.model().get_entity(index)
+ if entity is None:
+ return
+ try:
+ self.filaments.delete_entity(entity)
+ entity.delete()
+ self._mw.update_filaments_table()
+ except Exception as e:
+ self.session.logger.error(f"Failed to delete filaments: {e}")
+
+ def reload_picks_entity(self, picks: CopickPicks):
+ """Redraw a picks set from storage (after it was rewritten, e.g. picks sampled from filaments)."""
+ shown = False
+ for p in list(self.picks_map):
+ if (
+ p.pickable_object_name == picks.pickable_object_name
+ and p.user_id == picks.user_id
+ and (p.session_id == picks.session_id)
+ ):
+ shown = self.picks_map[p].display
+ self.picks_map.pop(p).delete()
+ self._mw.update_picks_table()
+ if shown:
+ self.show_particles_from_picks(picks)
+ self._mw.set_entity_active(picks, True)
+
+ ##########################
+ # Instances (browsing) #
+ ##########################
+ def instance_mask(self, pl) -> Any:
+ """Particles of a list shown by the instance browser (True = all)."""
+ mask = self.instance_masks.get(pl)
+ if mask is None or len(mask) != pl.size:
+ return True if pl.size == 0 else np.ones(pl.size, dtype=bool)
+ return mask
+
+ def set_pick_instances_visible(self, picks: CopickPicks, ids) -> None:
+ """Show only the particles of ``picks`` whose instance ID is in ``ids``."""
+ pl = self.picks_map.get(picks)
+ if pl is None or pl.deleted or pl.size == 0:
+ return
+ ids = {int(i) for i in ids}
+ mask = np.array([int(p["instance_id"]) in ids for _id, p in pl.data], dtype=bool)
+ self.instance_masks[pl] = mask
+ # With the spotlight hiding particles it combines this mask with its sphere on the next update.
+ if not (self.spotlight.enabled and self.spotlight.hide_particles):
+ pl.displayed_particles = mask
+
+ def focus_pick_instance(self, picks: CopickPicks, instance_id: int) -> None:
+ pl = self.picks_map.get(picks)
+ if pl is None or pl.deleted:
+ return
+ for pid, p in pl.data:
+ if int(p["instance_id"]) == int(instance_id):
+ self.go_to_particle(pl, pid)
+ return
+
+ def set_color_picks_by_instance(self, picks: CopickPicks, on: bool) -> None:
+ pl = self.picks_map.get(picks)
+ if pl is None or pl.deleted or pl.size == 0:
+ return
+ obj = self.root.get_object(picks.pickable_object_name)
+ base = np.asarray(obj.color if obj is not None and obj.color else (255, 255, 255, 255), dtype=np.uint8)
+ ids = [p["instance_id"] for _id, p in pl.data]
+ pl.particle_colors = instance_colors(ids, base, dtype=np.uint8) if on else np.tile(base, (len(ids), 1))
+
################
# Mesh actions #
################
@@ -1443,12 +1629,42 @@ def show_surf_from_mesh(self, mesh: CopickMesh):
col = np.array(pick_obj.color)
surf.color = col
+ def _on_selection_changed(self, *_args) -> None:
+ filaments = getattr(self, "filaments", None)
+ if filaments is not None:
+ filaments.on_scene_selection_changed()
+
+ def _on_model_selection_changed(self, _trigger_name, model) -> None:
+ # Many models can change at once (select clear): check the filament selection once, after they are done.
+ if getattr(model, "copick_filament_id", None) is None or getattr(self, "_selection_check_pending", False):
+ return
+ self._selection_check_pending = True
+ from Qt.QtCore import QTimer
+
+ def check():
+ self._selection_check_pending = False
+ self._on_selection_changed()
+
+ QTimer.singleShot(0, check)
+
+ def _on_mouse_mode_set(self, _trigger_name, data) -> None:
+ filaments = getattr(self, "filaments", None)
+ if filaments is not None:
+ filaments.on_mouse_mode_set(*data)
+
def delete(self):
self.store()
if getattr(self, "spotlight", None):
self.spotlight.shutdown()
+ annotate = getattr(self, "_annotate", None)
+ if annotate is not None:
+ annotate.delete()
+ filaments, mw = getattr(self, "filaments", None), getattr(self, "_mw", None)
+ if filaments is not None and mw is not None and mw.refresh_filament_stepper in filaments.listeners:
+ filaments.listeners.remove(mw.refresh_filament_stepper)
+
# Remove trigger handlers so they don't fire into this (deleted) tool, e.g. during
# close session or after a close/reopen cycle (the Session object persists).
for h in getattr(self, "_trigger_handlers", []):
@@ -1694,9 +1910,7 @@ def _reinitialize_ui(self):
if updated_run:
# Update all table views with the refreshed run data
- self._mw._picks_table.set_view(updated_run)
- self._mw._meshes_table.set_view(updated_run)
- self._mw._segmentations_table.set_view(updated_run)
+ self._mw.set_tables_run(updated_run)
# Reload the previously active tomogram if we have the necessary info
if current_voxel_size is not None and current_tomo_type is not None:
diff --git a/src/toolbar/toolbar.py b/src/toolbar/toolbar.py
index 673aa7c..98932e4 100644
--- a/src/toolbar/toolbar.py
+++ b/src/toolbar/toolbar.py
@@ -19,6 +19,10 @@
"Translate Picked Particle": 'ui mousemode right "translate picked particle"',
"Rotate Picked Particle": 'ui mousemode right "rotate picked particle"',
"Delete Selected Particles": delete_selected_particles,
+ "Color By Object": "copick colorpicks off",
+ "Color By Instance": "copick colorpicks on",
+ "Edit Delete Selected": delete_selected_particles,
+ "Edit New Picks": lambda session: session.copick._mw._picks_table._on_new_clicked(),
"Delete Picked Particle": 'ui mousemode right "delete picked particle"',
"Show Markers": "artiax show markers",
"Hide Markers": "artiax hide markers",
@@ -34,6 +38,24 @@
"1x": partial(set_step, (1, 1, 1)),
"2x": partial(set_step, (2, 2, 2)),
"4x": partial(set_step, (4, 4, 4)),
+ # Copick Edit tab
+ "Edit New Filaments": lambda session: session.copick._mw._filaments_table._on_new_clicked(),
+ "Edit New Instances": lambda session: session.copick._mw._segmentations_table._on_new_clicked(),
+ "Edit Save All": "copick save",
+ "Filament New": "copick filament new",
+ "Filament Previous": "copick filament previous",
+ "Filament Next": "copick filament next",
+ "Filament Reverse": "copick filament reverse",
+ "Filament Join": "copick filament join",
+ "Filament Delete": "copick filament delete",
+ "Filament Save": lambda session: session.copick.annotate("filaments").filament_panel._save(),
+ "Instance New ID": "copick instance new",
+ "Instance Undo": "copick instance undo",
+ "Instance Save": lambda session: session.copick.annotate("instances").instance_panel._save(),
+ "Edit Annotate": "copick annotate toggle",
+ "Brush Small": "copick instance brush 25",
+ "Brush Medium": "copick instance brush 75",
+ "Brush Large": "copick instance brush 200",
}
diff --git a/src/ui/EntityTable.py b/src/ui/EntityTable.py
index fc1bd48..3cb59bf 100644
--- a/src/ui/EntityTable.py
+++ b/src/ui/EntityTable.py
@@ -1,6 +1,11 @@
-from typing import Callable, Tuple, Type, Union
+from typing import Callable, Optional, Tuple, Type, Union
+import copick.models
from copick.models import CopickMesh, CopickPicks, CopickRun, CopickSegmentation
+from copick_shared_ui.core.types import SEGMENTATION_TYPE_LABELS, segmentation_type_of
+
+# copick versions without the Filaments entity have no CopickFilaments
+CopickFilaments = getattr(copick.models, "CopickFilaments", None)
class TableEntity:
@@ -25,15 +30,33 @@ def data(self, column: int) -> str:
if column == 0:
return self.entity.user_id
elif column == 1:
- if isinstance(self.entity, (CopickPicks, CopickMesh)):
- return self.entity.pickable_object_name
- elif isinstance(self.entity, CopickSegmentation):
+ if isinstance(self.entity, CopickSegmentation):
return self.entity.name
+ return self.entity.pickable_object_name
elif column == 2:
return self.entity.session_id
+ elif column == 3 and isinstance(self.entity, CopickSegmentation):
+ return SEGMENTATION_TYPE_LABELS[segmentation_type_of(self.entity)]
+ return None
+
+ def kind(self) -> Optional[str]:
+ """The segmentation type (binary, multilabel, instance, panoptic), or None for other entities."""
+ if isinstance(self.entity, CopickSegmentation):
+ return segmentation_type_of(self.entity)
+ return None
def color(self) -> Tuple[int, ...]:
- return tuple(self.entity.color)
+ color = getattr(self.entity, "color", None)
+ if color is None: # filaments (and instance/panoptic stores) take their object's colour
+ name = getattr(self.entity, "pickable_object_name", None) or getattr(self.entity, "name", "")
+ obj = self.entity.run.root.get_object(name)
+ color = obj.color if obj is not None and obj.color else (128, 128, 128, 255)
+ return tuple(color)
+
+ @property
+ def locked(self) -> bool:
+ """Tool output or read-only storage (filaments have no ``read_only`` on every backend)."""
+ return bool(getattr(self.entity, "from_tool", False) or getattr(self.entity, "read_only", False))
def columnCount(self) -> int:
return 3
@@ -50,6 +73,13 @@ class TableMesh(TableEntity):
class TableSegmentation(TableEntity):
CopickClass = CopickSegmentation
+ def columnCount(self) -> int:
+ return 4
+
+
+class TableFilaments(TableEntity):
+ CopickClass = CopickFilaments
+
class EntityTableRoot:
def __init__(self, run: CopickRun, get_entity: Callable, entity_clz: Type[TableEntity]):
diff --git a/src/ui/NewEntityDialogs.py b/src/ui/NewEntityDialogs.py
new file mode 100644
index 0000000..2aefc57
--- /dev/null
+++ b/src/ui/NewEntityDialogs.py
@@ -0,0 +1,98 @@
+from typing import Iterable, Optional, Tuple
+
+from copick.models import CopickRun
+from copick_shared_ui.core.types import is_filament_object
+
+from .BaseEntityDialog import BaseEntityDialog, ColoredComboBox
+
+
+def next_manual_session_id(entities: Iterable) -> str:
+ """The next free ``manual-X`` session ID among ``entities``."""
+ existing = {str(e.session_id).lower() for e in entities if getattr(e, "session_id", None)}
+ counter = 1
+ while f"manual-{counter}" in existing:
+ counter += 1
+ return f"manual-{counter}"
+
+
+class _NewEntityDialog(BaseEntityDialog):
+ """Object + user + session for a new entity; subclasses choose the objects and the existing entities."""
+
+ title = "Create New"
+ info = ""
+ ok_text = "Create"
+
+ def __init__(self, run: CopickRun, parent=None, preset_user_id: str = None):
+ self._run = run
+ default_user_id = run.root.user_id if run and run.root and run.root.user_id else "ArtiaX"
+ super().__init__(parent, preset_user_id, default_user_id)
+
+ def _objects(self):
+ return list(self._run.root.pickable_objects) if self._run and self._run.root else []
+
+ def _existing(self):
+ return []
+
+ def _setup_specific_ui(self):
+ self.setWindowTitle(self.title)
+ self._object_combo = ColoredComboBox()
+ self._object_combo.setToolTip("Select the pickable object")
+ self._form_layout.insertRow(0, "Object:", self._object_combo)
+ self._info_label.setText(self.info)
+ self._object_combo.currentTextChanged.connect(self._update_ok_button)
+
+ def _populate_initial_data(self):
+ for obj in self._objects():
+ self._object_combo.addColoredItem(obj.name, obj.color if obj.color else (128, 128, 128, 255))
+ self._session_edit.setText(next_manual_session_id(self._existing()))
+
+ def get_selection(self) -> Optional[Tuple[str, str, str]]:
+ if self.result() != self.Accepted:
+ return None
+ object_name = self._object_combo.currentText()
+ user_id = self.get_user_id()
+ session_id = self.get_session_id()
+ if not object_name or not user_id or not session_id:
+ return None
+ return object_name, user_id, session_id
+
+ def _validate_additional_fields(self) -> bool:
+ return bool(self._object_combo.currentText())
+
+ def _get_ok_button_text(self) -> str:
+ return self.ok_text
+
+ def _use_dialog_button_box(self) -> bool:
+ return True
+
+
+class NewFilamentsDialog(_NewEntityDialog):
+ """A new set of traced filaments (filament objects only)."""
+
+ title = "Trace New Filaments"
+ info = (
+ "Start a new set of traced filaments. Click on the tomogram plane to place control points; the "
+ "centreline is a Catmull-Rom curve through them. Only objects declared as filaments are listed "
+ "(Edit Object Types → Is Filament)."
+ )
+ ok_text = "Start tracing"
+
+ def _objects(self):
+ return [o for o in super()._objects() if is_filament_object(o)]
+
+ def _existing(self):
+ return list(getattr(self._run, "filaments", []))
+
+
+class NewInstanceSegmentationDialog(_NewEntityDialog):
+ """A new, empty instance segmentation to paint."""
+
+ title = "New Instance Segmentation"
+ info = (
+ "Start a new instance segmentation at the shown tomogram's voxel spacing. Paint instance IDs on the "
+ "tomogram plane (right mouse: paint copick instance); IDs match picks and filaments of the same object."
+ )
+ ok_text = "Start painting"
+
+ def _existing(self):
+ return list(self._run.segmentations)
diff --git a/src/ui/QUnifiedTable.py b/src/ui/QUnifiedTable.py
index c65642d..1a3aca4 100644
--- a/src/ui/QUnifiedTable.py
+++ b/src/ui/QUnifiedTable.py
@@ -2,6 +2,7 @@
from copick.models import CopickMesh, CopickPicks, CopickRun, CopickSegmentation
from copick_shared_ui.util.validation import generate_smart_copy_name
+from copick_shared_ui.widgets.compact_delegate import SortMenu, make_compact
from Qt.QtCore import QEvent, QModelIndex, QSortFilterProxyModel, Qt, Signal
from Qt.QtWidgets import (
QHBoxLayout,
@@ -17,31 +18,32 @@
from .DuplicateDialog import DuplicateDialog
from .emoji_font import apply_emoji_font
from .NewPickDialog import NewPickDialog
+from .QUnifiedTableModel import HEADERS, ORDER_ROLE, SEARCH_ROLE, SESSION_ROLE, TYPE_ROLE, USER_ROLE, QUnifiedTableModel
from .SettingsOverlay import SettingsOverlay
-from .QUnifiedTableModel import QUnifiedTableModel
+
+#: Sort keys of the header menu: (key, menu text, model role).
+SORT_KEYS = {
+ "default": ("Default (tool sets first)", ORDER_ROLE),
+ "name": ("Name", Qt.DisplayRole),
+ "user": ("User", USER_ROLE),
+ "session": ("Session", SESSION_ROLE),
+ "type": ("Type", TYPE_ROLE),
+}
class TableFilterProxyModel(QSortFilterProxyModel):
- """Custom proxy model for table search across user, object, and session columns"""
+ """Search across name, user, session and (segmentations) type."""
def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool:
- """Override to search across user, object, and session columns"""
if not self.filterRegularExpression().pattern():
return True
source_model = self.sourceModel()
if not source_model:
return False
-
- # Check all three columns (User/Tool, Object, Session)
- for column in range(3):
- index = source_model.index(source_row, column, source_parent)
- if index.isValid():
- data = source_model.data(index, Qt.ItemDataRole.DisplayRole)
- if data and self.filterRegularExpression().match(str(data)).hasMatch():
- return True
-
- return False
+ index = source_model.index(source_row, 0, source_parent)
+ text = source_model.data(index, SEARCH_ROLE) if index.isValid() else None
+ return bool(text) and self.filterRegularExpression().match(str(text)).hasMatch()
class QUnifiedTable(QWidget):
@@ -54,7 +56,7 @@ class QUnifiedTable(QWidget):
def __init__(
self,
- item_type: Union[Literal["picks"], Literal["meshes"], Literal["segmentations"]],
+ item_type: Union[Literal["picks"], Literal["filaments"], Literal["meshes"], Literal["segmentations"]],
parent=None,
):
super().__init__(parent)
@@ -83,6 +85,15 @@ def _setup_ui(self):
self._table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self._table.setSelectionMode(QTableView.SelectionMode.SingleSelection)
self._table.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding))
+ # One compact two-line column (swatch, name, type chip / "user · session"); the header is a sort menu.
+ make_compact(self._table)
+ keys = ["default", "name", "user", "session"] + (["type"] if self.item_type == "segmentations" else [])
+ self._sort_menu = SortMenu(
+ self._table.horizontalHeader(),
+ HEADERS[self.item_type],
+ [(k, SORT_KEYS[k][0]) for k in keys],
+ lambda _key, _descending: self._apply_sort(),
+ )
# Create overlay search widget (floating at bottom-left)
self._search_overlay = QWidget(self._table)
@@ -281,9 +292,12 @@ def set_view(self, run: CopickRun):
self._filter_model = TableFilterProxyModel()
self._filter_model.setSourceModel(self._source_model)
self._filter_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
- self._filter_model.setFilterRole(Qt.DisplayRole)
+ self._filter_model.setSortCaseSensitivity(Qt.CaseInsensitive)
+ self._filter_model.setDynamicSortFilter(True)
self._table.setModel(self._filter_model)
+ self._sort_menu.refresh()
+ self._apply_sort()
# Connect selection model after model is set
self._table.selectionModel().selectionChanged.connect(self._on_selection_changed)
@@ -296,8 +310,14 @@ def set_view(self, run: CopickRun):
if old_source_model is not None:
old_source_model.deleteLater()
- # Resize columns to content
- self._table.resizeColumnsToContents()
+ def _apply_sort(self) -> None:
+ """Sort the rows by the header menu's choice (the proxy keeps the order through model refreshes)."""
+ if self._filter_model is None:
+ return
+ _text, role = SORT_KEYS.get(self._sort_menu.key, SORT_KEYS["default"])
+ self._filter_model.setSortRole(role)
+ order = Qt.DescendingOrder if self._sort_menu.descending else Qt.AscendingOrder
+ self._filter_model.sort(0, order)
def set_entity_active(self, entity: Union[CopickMesh, CopickPicks, CopickSegmentation], active: bool):
"""Update the active state of an entity"""
@@ -308,7 +328,6 @@ def update(self):
"""Refresh the table data"""
if self._source_model:
self._source_model.update_all()
- self._table.resizeColumnsToContents()
def _on_selection_changed(self, selected, deselected):
"""Handle table selection changes"""
@@ -413,8 +432,7 @@ def _get_existing_session_ids(self) -> list:
session_ids = []
for row in range(self._source_model.rowCount()):
- index = self._source_model.index(row, 2) # Session ID column
- session_id = self._source_model.data(index, Qt.ItemDataRole.DisplayRole)
+ session_id = self._source_model.data(self._source_model.index(row, 0), SESSION_ROLE)
if session_id:
session_ids.append(str(session_id))
return session_ids
@@ -425,6 +443,17 @@ def _on_new_clicked(self):
return
# Show dialog based on item type
+ if self.item_type in ("filaments", "segmentations"):
+ from .NewEntityDialogs import NewFilamentsDialog, NewInstanceSegmentationDialog
+
+ preset_user_id = self._run.root.user_id if self._run.root else None
+ dialog_class = NewFilamentsDialog if self.item_type == "filaments" else NewInstanceSegmentationDialog
+ dialog = dialog_class(self._run, self, preset_user_id)
+ if dialog.exec_() == dialog_class.Accepted:
+ selection = dialog.get_selection()
+ if selection:
+ self.newClicked.emit(*selection)
+ return
if self.item_type == "picks":
# Get preset user ID from root if available
preset_user_id = None
@@ -459,7 +488,16 @@ def _on_delete_clicked(self):
# Show confirmation dialog if enabled
if self._delete_confirmation:
- entity_type = self.item_type.rstrip("s") # Remove 's' from 'picks', 'meshes', 'segmentations'
+ entity_type = {
+ "picks": "picks",
+ "filaments": "filaments",
+ "meshes": "mesh",
+ "segmentations": "segmentation",
+ }[self.item_type]
+ if self.item_type == "segmentations":
+ from copick_shared_ui.core.types import SEGMENTATION_TYPE_LABELS, segmentation_type_of
+
+ entity_type = f"{SEGMENTATION_TYPE_LABELS[segmentation_type_of(entity)].lower()} segmentation"
# Get object type information
object_type = ""
diff --git a/src/ui/QUnifiedTableModel.py b/src/ui/QUnifiedTableModel.py
index a5e8d32..d183db4 100644
--- a/src/ui/QUnifiedTableModel.py
+++ b/src/ui/QUnifiedTableModel.py
@@ -2,17 +2,46 @@
from typing import Literal, Union
from copick.models import CopickMesh, CopickPicks, CopickRun, CopickSegmentation
+from copick_shared_ui.core.types import SEGMENTATION_TYPE_LABELS, SEGMENTATION_TYPE_TAGS
+from copick_shared_ui.widgets.compact_delegate import (
+ CAPTION_ROLE,
+ CHIP_COLOR_ROLE,
+ CHIP_ROLE,
+ MULTI_SWATCH,
+ SWATCH_ROLE,
+)
from Qt.QtCore import QAbstractTableModel, QModelIndex, Qt
from Qt.QtGui import QBrush, QColor, QIcon
-from .EntityTable import EntityTableRoot, TableEntity, TableMesh, TablePicks, TableSegmentation
+from .EntityTable import EntityTableRoot, TableEntity, TableFilaments, TableMesh, TablePicks, TableSegmentation
+
+# One compact column (CompactItemDelegate): line 1 swatch, name and type chip; line 2 "user · session".
+HEADERS = {
+ "picks": "Object",
+ "filaments": "Object",
+ "meshes": "Object",
+ "segmentations": "Name",
+}
+
+# Roles for sorting and searching (user, session and type are no longer columns).
+USER_ROLE = Qt.UserRole + 1
+SESSION_ROLE = Qt.UserRole + 2
+TYPE_ROLE = Qt.UserRole + 3 # segmentation type label (Binary, Multilabel, Instance, Panoptic)
+SEARCH_ROLE = Qt.UserRole + 4 # "name user session type", what the search box matches
+ORDER_ROLE = Qt.UserRole + 5 # position in the default order (tool entities first, then user, then name)
+
+CHIP_COLORS = {
+ "multilabel": QColor(38, 166, 154),
+ "instance": QColor(66, 165, 245),
+ "panoptic": QColor(171, 71, 188),
+}
class QUnifiedTableModel(QAbstractTableModel):
def __init__(
self,
run: CopickRun,
- item_type: Union[Literal["picks"], Literal["meshes"], Literal["segmentations"]],
+ item_type: Union[Literal["picks"], Literal["filaments"], Literal["meshes"], Literal["segmentations"]],
parent=None,
):
super().__init__(parent)
@@ -22,6 +51,7 @@ def __init__(
self._entities = []
self._eye_open_icon = None
self._eye_closed_icon = None
+ self._header_text = None # set by the sort menu
self._load_icons()
self._build_model()
@@ -44,6 +74,7 @@ def _build_model(self):
# Map item types to their corresponding getter functions and entity classes
type_mapping = {
"picks": (lambda: self._run.picks, TablePicks),
+ "filaments": (lambda: list(getattr(self._run, "filaments", [])), TableFilaments),
"meshes": (lambda: self._run.meshes, TableMesh),
"segmentations": (lambda: self._run.segmentations, TableSegmentation),
}
@@ -57,7 +88,7 @@ def _build_model(self):
self._entities.append(child)
# Sort entities: tool entities first, then user entities
- self._entities.sort(key=lambda x: (not x.entity.from_tool, x.entity.user_id, self._get_object_name(x)))
+ self._entities.sort(key=lambda x: (not x.locked, x.entity.user_id, self._get_object_name(x)))
def _get_object_name(self, table_entity: TableEntity) -> str:
"""Get object name from table entity"""
@@ -67,53 +98,63 @@ def rowCount(self, parent=QModelIndex()):
return len(self._entities)
def columnCount(self, parent=QModelIndex()):
- return 3 # User/Tool (with lock/unlock icon), Object, Session
+ return 1
def headerData(self, section, orientation, role=Qt.DisplayRole):
- if orientation == Qt.Horizontal and role == Qt.DisplayRole:
- headers = ["User/Tool", "Object", "Session"]
- return headers[section]
+ if orientation == Qt.Horizontal and role == Qt.DisplayRole and section == 0:
+ return self._header_text or HEADERS[self._item_type]
return None
+ def setHeaderData(self, section, orientation, value, role=Qt.DisplayRole): # noqa: N802 (Qt API)
+ if orientation == Qt.Horizontal and role == Qt.DisplayRole and section == 0:
+ self._header_text = value
+ self.headerDataChanged.emit(orientation, 0, 0)
+ return True
+ return False
+
def data(self, index, role=Qt.DisplayRole):
- if not index.isValid() or index.row() >= len(self._entities):
+ if not index.isValid() or index.row() >= len(self._entities) or index.column() != 0:
return None
entity = self._entities[index.row()]
- column = index.column()
+ name = entity.data(1) or ""
+ user, session = entity.data(0) or "", entity.data(2) or ""
+ kind = entity.kind()
if role == Qt.DisplayRole:
- if column == 0:
- # Show user/tool name with lock/unlock indicator
- access_indicator = "🔒" if (entity.entity.from_tool or entity.entity.read_only) else "✏️"
- return f"{access_indicator} {entity.data(0)}"
- elif column == 1:
- return entity.data(1)
- elif column == 2:
- return entity.data(2)
-
- elif role == Qt.BackgroundRole:
+ return name
+ if role == CAPTION_ROLE:
+ return f"{'🔒 ' if entity.locked else ''}{user} · {session}"
+ if role == CHIP_ROLE:
+ return SEGMENTATION_TYPE_TAGS.get(kind) if kind else None
+ if role == CHIP_COLOR_ROLE:
+ return CHIP_COLORS.get(kind)
+ if role == SWATCH_ROLE:
+ return MULTI_SWATCH if kind and kind != "binary" else QColor(*entity.color())
+ if role == Qt.BackgroundRole:
color = QColor(*entity.color())
color.setAlpha(50) # Semi-transparent background
return QBrush(color)
-
- elif role == Qt.DecorationRole:
- if column == 1: # Eye icon for active state in object column
- if entity.is_active:
- return self._eye_open_icon if self._eye_open_icon else QIcon()
- else:
- return self._eye_closed_icon if self._eye_closed_icon else QIcon()
-
- elif role == Qt.ToolTipRole:
- if column == 0:
- if entity.entity.from_tool or entity.entity.read_only:
- return f"Tool-generated entity (read-only): {entity.data(0)}"
- else:
- return f"User-generated entity (editable): {entity.data(0)}"
- elif column == 1:
- status = "Visible" if entity.is_active else "Hidden"
- return f"{entity.data(1)} - {status}"
-
+ if role == Qt.DecorationRole: # eye: shown or hidden
+ icon = self._eye_open_icon if entity.is_active else self._eye_closed_icon
+ return icon if icon else QIcon()
+ if role == Qt.ToolTipRole:
+ lines = [f"{name}", f"user: {user}", f"session: {session}"]
+ if kind:
+ lines.append(f"type: {SEGMENTATION_TYPE_LABELS[kind]}")
+ lines.append("tool output (read-only)" if entity.locked else "editable")
+ lines.append("shown — double-click to hide" if entity.is_active else "hidden — double-click to show")
+ return " ".join(lines)
+ if role == USER_ROLE:
+ return user
+ if role == SESSION_ROLE:
+ return session
+ if role == TYPE_ROLE:
+ return SEGMENTATION_TYPE_LABELS[kind] if kind else None
+ if role == SEARCH_ROLE:
+ return " ".join(t for t in (name, user, session, SEGMENTATION_TYPE_LABELS.get(kind or "", "")) if t)
+ if role == ORDER_ROLE:
+ return index.row()
return None
def get_entity(self, index: QModelIndex) -> Union[CopickMesh, CopickPicks, CopickSegmentation, None]:
@@ -133,9 +174,8 @@ def set_entity_active(self, entity: Union[CopickMesh, CopickPicks, CopickSegment
for i, table_entity in enumerate(self._entities):
if table_entity.entity == entity:
table_entity.is_active = active
- # Emit data changed for the object column (where the eye icon is)
- index = self.index(i, 1)
- self.dataChanged.emit(index, index, [Qt.DecorationRole])
+ index = self.index(i, 0)
+ self.dataChanged.emit(index, index, [Qt.DecorationRole, Qt.ToolTipRole])
break
def update_all(self):
diff --git a/src/ui/annotate_tool.py b/src/ui/annotate_tool.py
new file mode 100644
index 0000000..c97e04a
--- /dev/null
+++ b/src/ui/annotate_tool.py
@@ -0,0 +1,168 @@
+"""The "Copick Annotate" tool window: filament tracing, instance editing and instance browsing, kept out of the narrow
+copick dock (tables + tree stay there at their old width).
+
+Placement: tabbed into the right-hand group (with ArtiaX Options, Log, Models, Volume Viewer and Marker Placement) so
+it gets the full height. ArtiaX runs ``ui tool show "ArtiaX Options"`` whenever the current particle list, tomogram or
+geometric model changes, which raises its panel over every tab of the group. So this window watches the same ArtiaX
+triggers: if it was the front tab when ArtiaX raised its panel, it raises itself back right after. If the user had
+switched to another tab themselves, nothing happens. ChimeraX remembers where the user put the window.
+"""
+
+import contextlib
+import time
+from typing import Any, Optional
+
+from chimerax.core.tools import ToolInstance
+from chimerax.ui import MainToolWindow
+from Qt.QtCore import QTimer
+from Qt.QtWidgets import QTabWidget, QVBoxLayout
+
+from .annotation_panels import FilamentPanel, InstancePanel, PicksInstancePanel
+from .emoji_font import apply_emoji_font
+
+TOOL_NAME = "Copick Annotate"
+ARTIAX_OPTIONS = "ArtiaX Options"
+# Tool windows to tab with, in order of preference (the right-hand group ArtiaX builds).
+TAB_WITH = (ARTIAX_OPTIONS, "Log", "Models", "Volume Viewer")
+# How recently the tab must have lost the front for an ArtiaX trigger to count as having covered it (seconds).
+COVERED_WINDOW = 0.2
+
+
+class CopickAnnotateTool(ToolInstance):
+ SESSION_ENDURING = False
+ SESSION_SAVE = False
+ help = "help:user/tools/copick.html"
+
+ def __init__(self, session, tool_name: str = TOOL_NAME):
+ super().__init__(session, tool_name)
+ self.display_name = TOOL_NAME
+ self.copick = session.copick
+
+ self.tool_window = MainToolWindow(self, close_destroys=False)
+ layout = QVBoxLayout()
+ layout.setContentsMargins(2, 2, 2, 2)
+ self.tabs = QTabWidget()
+ apply_emoji_font(self.tabs)
+ self.filament_panel = FilamentPanel(self.copick)
+ self.instance_panel = InstancePanel(self.copick)
+ self.picks_panel = PicksInstancePanel(self.copick)
+ self.tabs.addTab(self.filament_panel, "Filaments")
+ self.tabs.addTab(self.instance_panel, "Instances")
+ self.tabs.addTab(self.picks_panel, "Pick instances")
+ layout.addWidget(self.tabs)
+ self.tool_window.ui_area.setLayout(layout)
+
+ # Tabbed with the right-hand group for the full height (ChimeraX restores a position the user chose instead).
+ target = self._tab_target()
+ self.tool_window.manage(target if target is not None else "right")
+
+ # Track whether this tab is in front, to undo ArtiaX raising its panel over it.
+ self._front = False
+ self._lost_front_at = float("-inf")
+ dock = self._dock()
+ if dock is not None:
+ dock.visibilityChanged.connect(self._on_visibility_changed)
+
+ self._handlers = []
+ artiax = getattr(session, "ArtiaX", None)
+ if artiax is not None:
+ from chimerax.artiax.ArtiaX import OPTIONS_PARTLIST_CHANGED
+
+ names = [OPTIONS_PARTLIST_CHANGED]
+ try:
+ from chimerax.artiax.ArtiaX import OPTIONS_GEOMODEL_CHANGED, OPTIONS_TOMO_CHANGED
+
+ names += [OPTIONS_TOMO_CHANGED, OPTIONS_GEOMODEL_CHANGED]
+ except ImportError:
+ pass
+ self._handlers = [
+ (artiax.triggers, artiax.triggers.add_handler(n, self._after_artiax_raise)) for n in names
+ ]
+
+ def _tab_target(self):
+ """The tool window to tab with: the first docked one of TAB_WITH that is shown."""
+ by_name = {getattr(t, "display_name", None): t for t in self.session.tools.list()}
+ for name in TAB_WITH:
+ tool = by_name.get(name)
+ tw = getattr(tool, "tool_window", None)
+ dock = getattr(tw, "_dock_widget", None) if tw is not None else None
+ if tw is not None and tw.shown and dock is not None and not dock.isFloating():
+ return tw
+ return None
+
+ # -- pages -----------------------------------------------------------------------------------------------------
+
+ def show_page(self, page: str, raise_window: bool = True) -> None:
+ """Show the window on ``"filaments"``, ``"instances"`` or ``"picks"``."""
+ widget = {"filaments": self.filament_panel, "instances": self.instance_panel, "picks": self.picks_panel}[page]
+ self.tabs.setCurrentWidget(widget)
+ if not self.tool_window.shown:
+ self.tool_window.shown = True
+ if raise_window:
+ self._raise()
+
+ # -- living next to ArtiaX -------------------------------------------------------------------------------------
+
+ def _dock(self):
+ return getattr(self.tool_window, "_dock_widget", None)
+
+ def _artiax_dock(self):
+ for t in self.session.tools.list():
+ if getattr(t, "display_name", None) == ARTIAX_OPTIONS and getattr(t, "tool_window", None) is not None:
+ return getattr(t.tool_window, "_dock_widget", None)
+ return None
+
+ def tabbed_with_artiax(self) -> bool:
+ dock, other = self._dock(), self._artiax_dock()
+ if dock is None or other is None:
+ return False
+ return other in self.session.ui.main_window.tabifiedDockWidgets(dock)
+
+ def _raise(self) -> None:
+ dock = self._dock()
+ if dock is not None:
+ dock.raise_()
+
+ def _on_visibility_changed(self, visible: bool) -> None:
+ if not visible and self._front:
+ self._lost_front_at = time.monotonic()
+ self._front = bool(visible)
+
+ def _after_artiax_raise(self, *_args) -> None:
+ """ArtiaX just raised its options panel. If this tab was in front a moment ago (Qt may report the switch
+ before or after this handler runs), raise it again once ArtiaX is done."""
+ if not self.tool_window.shown or not self.tabbed_with_artiax():
+ return
+ covered_now = time.monotonic() - self._lost_front_at < COVERED_WINDOW
+ if self._front or covered_now:
+ QTimer.singleShot(0, self._raise)
+
+ def delete(self) -> None:
+ for triggers, handler in self._handlers:
+ with contextlib.suppress(Exception): # the ArtiaX model may already be gone
+ triggers.remove_handler(handler)
+ self._handlers = []
+ for panel in (self.filament_panel, self.instance_panel):
+ listeners = panel.ctl.listeners
+ if panel.refresh in listeners:
+ listeners.remove(panel.refresh)
+ selection_listeners = self.filament_panel.ctl.selection_listeners
+ if self.filament_panel._on_scene_selection in selection_listeners:
+ selection_listeners.remove(self.filament_panel._on_scene_selection)
+ if getattr(self.copick, "_annotate", None) is self:
+ self.copick._annotate = None
+ super().delete()
+
+
+def get_annotate_tool(session, create: bool = True) -> Optional[CopickAnnotateTool]:
+ """The Copick Annotate window of the running copick tool (created on demand)."""
+ tool: Any = getattr(session, "copick", None)
+ if tool is None:
+ return None
+ current = getattr(tool, "_annotate", None)
+ if current is not None and not getattr(current, "deleted", False):
+ return current
+ if not create:
+ return None
+ tool._annotate = CopickAnnotateTool(session)
+ return tool._annotate
diff --git a/src/ui/annotation_panels.py b/src/ui/annotation_panels.py
new file mode 100644
index 0000000..b084ecf
--- /dev/null
+++ b/src/ui/annotation_panels.py
@@ -0,0 +1,439 @@
+"""Panels of the Copick Annotate window: the shared instance browser plus the few controls the Copick Edit toolbar does
+not already have. Rows wrap (FlowLayout), so the window never forces a wide dock. Every control runs a ``copick ...``
+command, so actions are logged and scriptable."""
+
+from typing import Any
+
+import numpy as np
+from chimerax.core.commands import run
+from copick_shared_ui.util.filament_session import INSERT_MODE_LABELS, INSERT_MODES
+from copick_shared_ui.util.instances import rows_from_points
+from copick_shared_ui.widgets.flow_layout import FlowLayout
+from copick_shared_ui.widgets.instances import InstanceBrowserWidget
+from Qt.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+from .emoji_font import apply_emoji_font
+
+
+def _labelled(label: str, widget: QWidget) -> QWidget:
+ """``label`` and ``widget`` kept together on one line of a flow row."""
+ box = QWidget()
+ lay = QHBoxLayout(box)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(3)
+ lay.addWidget(QLabel(label))
+ lay.addWidget(widget)
+ return box
+
+
+def _flow_row(*widgets: QWidget) -> QWidget:
+ row = QWidget()
+ lay = FlowLayout(row, h_spacing=6, v_spacing=3)
+ for w in widgets:
+ lay.addWidget(w)
+ return row
+
+
+def _button(text: str, tip: str, slot, checkable: bool = False) -> QPushButton:
+ """A push button like the copick dock's (native, with the emoji font fallback)."""
+ b = QPushButton(text)
+ b.setToolTip(tip)
+ b.setCheckable(checkable)
+ b.clicked.connect(slot)
+ apply_emoji_font(b)
+ return b
+
+
+def _browser(title: str) -> InstanceBrowserWidget:
+ """An instance browser whose action buttons look like the rest of copick's."""
+ return InstanceBrowserWidget(title=title, button_factory=lambda text, tip, slot: _button(text, tip, slot))
+
+
+class SaveAnnotationDialog(QDialog):
+ """User / session (and optionally pick sampling) for saving a filament set or instance segmentation."""
+
+ def __init__(self, parent, title: str, user_id: str, session_id: str, picks: bool = False, spacing: float = 100.0):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ form = QFormLayout(self)
+ self.user_edit = QLineEdit(user_id)
+ self.session_edit = QLineEdit(session_id if str(session_id) != "0" else "manual-1")
+ form.addRow("User ID:", self.user_edit)
+ form.addRow("Session ID:", self.session_edit)
+ self.picks_cb = None
+ if picks:
+ self.picks_cb = QCheckBox("Also write picks sampled along the filaments")
+ self.spacing = QDoubleSpinBox()
+ self.spacing.setRange(1.0, 100000.0)
+ self.spacing.setSuffix(" Å")
+ self.spacing.setValue(spacing)
+ self.spacing.setEnabled(False)
+ note = QLabel("Sampled picks replace the whole picks set of this object / user / session.")
+ note.setWordWrap(True)
+ note.setStyleSheet("color: #d08000;")
+ note.setVisible(False)
+ self.picks_cb.toggled.connect(self.spacing.setEnabled)
+ self.picks_cb.toggled.connect(note.setVisible)
+ form.addRow(self.picks_cb)
+ form.addRow("Pick spacing:", self.spacing)
+ form.addRow(note)
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ form.addRow(buttons)
+
+ def values(self):
+ spacing = self.spacing.value() if self.picks_cb is not None and self.picks_cb.isChecked() else None
+ return self.user_edit.text().strip(), self.session_edit.text().strip(), spacing
+
+
+class FilamentPanel(QWidget):
+ """Tracing controls and the filament browser of the active filament set."""
+
+ def __init__(self, tool: Any, parent=None):
+ super().__init__(parent)
+ self.tool = tool
+ self.ctl = tool.filaments
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(3)
+
+ self.info = QLabel("No filament set active. Double-click a row to show it, or + to trace new filaments.")
+ self.info.setWordWrap(True)
+ layout.addWidget(self.info)
+
+ self.trace_btn = _button(
+ "✏️ Trace",
+ "Trace mode: right-click the tomogram plane to add control points, "
+ "drag them to move, shift-click to remove",
+ self._toggle_trace,
+ checkable=True,
+ )
+ self.cut_btn = _button(
+ "✂️ Cut",
+ "Cut mode: right-click a filament on the tomogram plane to split it in two there (Cmd/Ctrl+Z undoes)",
+ self._toggle_cut,
+ checkable=True,
+ )
+ self.active_spin = QSpinBox()
+ self.active_spin.setRange(1, 10_000_000)
+ self.active_spin.valueChanged.connect(lambda v: self._cmd(f"copick filament select {v}"))
+ self.mode_combo = QComboBox()
+ for mode in INSERT_MODES:
+ self.mode_combo.addItem(INSERT_MODE_LABELS[mode], mode)
+ self.mode_combo.setToolTip(
+ "Where a new control point goes: after the last, before the first, or into the nearest segment",
+ )
+ self.mode_combo.currentIndexChanged.connect(lambda _i: self._set_mode(self.mode_combo.currentData()))
+ self.convert_btn = _button(
+ "→ Catmull-Rom",
+ "This filament is a B-spline fit: its control points can be moved but not added or removed. "
+ "Convert it to an editable Catmull-Rom curve.",
+ lambda: self._cmd("copick filament convert"),
+ )
+ self.save_btn = _button("💾", "Save the filament set…", self._save)
+ layout.addWidget(
+ _flow_row(
+ self.trace_btn,
+ self.cut_btn,
+ _labelled("Filament", self.active_spin),
+ _labelled("New points", self.mode_combo),
+ self.convert_btn,
+ self.save_btn,
+ ),
+ )
+
+ self.browser = _browser("Filaments")
+ self.browser.set_capabilities(
+ new=True,
+ delete=True,
+ reverse=True,
+ merge=True,
+ merge_tip="Join the selected filaments end to end (into the current one)",
+ )
+ self.browser.current_changed.connect(lambda k: self._cmd(f"copick filament select {k}"))
+ self.browser.focus_requested.connect(lambda k: self._cmd(f"copick filament focus {k}"))
+ self.browser.visibility_changed.connect(self._on_visibility)
+ self.browser.new_requested.connect(lambda: self._cmd("copick filament new"))
+ self.browser.delete_requested.connect(lambda ks: [self._cmd(f"copick filament delete {k}") for k in ks])
+ self.browser.reverse_requested.connect(lambda k: self._cmd(f"copick filament reverse {k}"))
+ self.browser.merge_requested.connect(
+ lambda sources, target: self._cmd(
+ f"copick filament join {','.join(str(k) for k in [target, *sources])} target {target}",
+ ),
+ )
+ layout.addWidget(self.browser, 1)
+
+ # The list and the 3D view share one filament selection (Ctrl-click tubes, or select rows).
+ self.browser.selection_changed.connect(self.ctl.select_in_scene)
+ self.ctl.selection_listeners.append(self._on_scene_selection)
+
+ self.ctl.listeners.append(self.refresh)
+ self.refresh()
+
+ def _cmd(self, command: str) -> None:
+ run(self.tool.session, command)
+
+ def _on_scene_selection(self, ids) -> None:
+ """Filaments selected in the 3D view: select their rows; a single one becomes the active filament."""
+ self.browser.set_selected_keys(ids)
+ edit = self.ctl.edit_session
+ if len(ids) == 1 and edit is not None and edit.active_id != ids[0]:
+ self._cmd(f"copick filament select {ids[0]}")
+
+ def _toggle_trace(self, on: bool) -> None:
+ self._cmd(f"copick filament trace {'on' if on else 'off'}")
+
+ def _toggle_cut(self, on: bool) -> None:
+ self._cmd(f"copick filament cut {'on' if on else 'off'}")
+
+ def _set_mode(self, mode: str) -> None:
+ edit = self.ctl.edit_session
+ if edit is not None:
+ edit.insert_mode = mode
+
+ def _on_visibility(self, keys) -> None:
+ if self.ctl.active is not None:
+ self.ctl.active[1].set_visible_ids(keys)
+
+ def _save(self) -> None:
+ edit = self.ctl.edit_session
+ if edit is None:
+ return
+ obj = edit.object
+ spacing = float(getattr(obj, "radius", None) or 100.0)
+ root = edit.run.root
+ user = edit.user_id if not edit.read_only else (root.user_id or "ArtiaX")
+ dlg = SaveAnnotationDialog(self, "Save filaments", user, edit.session_id, picks=True, spacing=spacing)
+ if dlg.exec_() != QDialog.Accepted:
+ return
+ user, session, spacing = dlg.values()
+ cmd = f"copick filament save userId {user} sessionId {session}"
+ if spacing:
+ cmd += f" pickSpacing {spacing:g}"
+ self._cmd(cmd)
+
+ def refresh(self) -> None:
+ edit = self.ctl.edit_session
+ from ..filaments.controller import CUT_MODE, TRACE_MODE
+
+ right = self.ctl.right_mode if self.ctl.editing else None
+ for btn, mode in ((self.trace_btn, TRACE_MODE), (self.cut_btn, CUT_MODE)):
+ btn.blockSignals(True)
+ btn.setChecked(right == mode)
+ btn.blockSignals(False)
+ if edit is None:
+ self.info.setText(
+ "No filament set active. Double-click a filament set in the Filaments table, or start one "
+ "with New Filaments.",
+ )
+ self.convert_btn.setVisible(False)
+ self.browser.clear()
+ return
+ state = " · unsaved changes" if edit.dirty else ""
+ ro = " · read-only source: save under a user session" if edit.read_only else ""
+ self.info.setText(
+ f"{edit.object_name} ({edit.user_id}/{edit.session_id}) · {len(edit.to_list())} filaments · "
+ f"step {edit.step:g} Å{state}{ro}",
+ )
+ for w, v in ((self.active_spin, edit.active_id),):
+ w.blockSignals(True)
+ w.setValue(int(v))
+ w.blockSignals(False)
+ self.mode_combo.blockSignals(True)
+ self.mode_combo.setCurrentIndex(max(0, self.mode_combo.findData(edit.insert_mode)))
+ self.mode_combo.blockSignals(False)
+ self.convert_btn.setVisible(edit.kind(edit.active_id) == "bspline")
+ from copick_shared_ui.util.instances import InstanceRow, rows_from_filaments
+
+ rows = rows_from_filaments(edit.to_list())
+ known = {r.instance_id for r in rows}
+ rows += [InstanceRow(i, count=len(c), label="pending") for i, c in edit.pending.items() if i not in known]
+ self.browser.set_rows(sorted(rows, key=lambda r: r.instance_id), kind="filaments")
+ self.browser.set_current(edit.active_id, emit=False)
+ self.browser.set_selected_keys(self.ctl.scene_selected_ids() or self.browser.selected_keys())
+
+
+class InstancePanel(QWidget):
+ """Instance browser of the active instance / panoptic segmentation, and instance editing controls."""
+
+ def __init__(self, tool: Any, parent=None):
+ super().__init__(parent)
+ self.tool = tool
+ self.ctl = tool.segmentations
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(3)
+ self.info = QLabel("Double-click an instance or panoptic segmentation to browse its instances.")
+ self.info.setWordWrap(True)
+ layout.addWidget(self.info)
+
+ self.edit_btn = _button(
+ "✏️ Edit",
+ "Edit the selected instance segmentation (loads full resolution)",
+ self._edit,
+ )
+ self.id_spin = QSpinBox()
+ self.id_spin.setRange(0, 2**31 - 1)
+ self.id_spin.valueChanged.connect(lambda v: self._cmd(f"copick instance id {v}"))
+ self.radius_spin = QDoubleSpinBox()
+ self.radius_spin.setRange(1.0, 5000.0)
+ self.radius_spin.setSuffix(" Å")
+ self.radius_spin.setValue(self.ctl.paint_radius)
+ self.radius_spin.valueChanged.connect(lambda v: self._cmd(f"copick instance brush {v:g}"))
+ self.bg_cb = QCheckBox("background only")
+ self.bg_cb.setToolTip("Paint only background voxels (keep other instances)")
+ self.bg_cb.toggled.connect(lambda on: setattr(self.ctl, "background_only", bool(on)))
+ self.save_btn = _button("💾", "Save the instance segmentation…", self._save)
+ self.edit_widgets = [
+ _labelled("ID", self.id_spin),
+ _labelled("Brush", self.radius_spin),
+ self.bg_cb,
+ self.save_btn,
+ ]
+ layout.addWidget(_flow_row(self.edit_btn, *self.edit_widgets))
+
+ self.browser = _browser("Instances")
+ self.browser.current_changed.connect(self._on_current)
+ self.browser.focus_requested.connect(self._on_focus)
+ self.browser.visibility_changed.connect(self._on_visibility)
+ self.browser.new_requested.connect(lambda: self._cmd("copick instance new"))
+ self.browser.delete_requested.connect(self._on_delete)
+ self.browser.merge_requested.connect(self._on_merge)
+ layout.addWidget(self.browser, 1)
+ self.selected_segmentation = None
+ self.ctl.listeners.append(self.refresh)
+ self.refresh()
+
+ def _cmd(self, command: str) -> None:
+ run(self.tool.session, command)
+
+ def _current_seg(self):
+ return self.ctl.active_key
+
+ def _edit(self) -> None:
+ seg = self.selected_segmentation or self.ctl.active_key
+ if seg is None:
+ self.tool.session.logger.warning("copick: select an instance segmentation first.")
+ return
+ from copick.util.uri import serialize_copick_uri
+
+ self._cmd(f"copick instance edit {serialize_copick_uri(seg)}")
+
+ def _save(self) -> None:
+ edit = self.ctl.edit
+ if edit is None:
+ return
+ root = edit.run.root
+ user = edit.user_id if not edit.read_only else (root.user_id or "ArtiaX")
+ dlg = SaveAnnotationDialog(self, "Save instance segmentation", user, edit.session_id)
+ if dlg.exec_() != QDialog.Accepted:
+ return
+ user, session, _ = dlg.values()
+ self._cmd(f"copick instance save userId {user} sessionId {session}")
+
+ def _on_current(self, key: int) -> None:
+ if self.ctl.editing and self.ctl.edit_key is self._current_seg():
+ self._cmd(f"copick instance id {key}")
+
+ def _on_focus(self, key: int) -> None:
+ seg = self._current_seg()
+ if seg is not None:
+ self.ctl.focus(seg, key)
+
+ def _on_visibility(self, keys) -> None:
+ seg = self._current_seg()
+ if seg is not None:
+ self.ctl.set_visible(seg, keys)
+
+ def _on_delete(self, keys) -> None:
+ if self.ctl.editing:
+ self._cmd(f"copick instance delete {','.join(str(k) for k in keys)}")
+
+ def _on_merge(self, sources, target: int) -> None:
+ if self.ctl.editing:
+ self._cmd(f"copick instance merge {','.join(str(k) for k in sources)} into {target}")
+
+ def refresh(self) -> None:
+ seg = self._current_seg()
+ editing = self.ctl.editing and self.ctl.edit_key is seg
+ for w in self.edit_widgets:
+ w.setVisible(editing)
+ self.edit_btn.setVisible(not editing)
+ if seg is None:
+ self.info.setText("Double-click an instance or panoptic segmentation to browse its instances.")
+ self.browser.clear()
+ return
+ info = self.ctl.info.get(seg, {})
+ kind = info.get("kind", "instance")
+ self.browser.set_title("Panoptic segments" if kind == "panoptic" else "Instances")
+ self.browser.set_capabilities(new=editing, delete=editing, merge=editing)
+ name = getattr(seg, "name", None) or (self.ctl.edit.object_name if self.ctl.edit else "")
+ level = info.get("level", 0)
+ state = ""
+ if editing:
+ edit = self.ctl.edit
+ state = f" · editing, current ID {edit.current_id}" + (" · unsaved changes" if edit.dirty else "")
+ self.id_spin.blockSignals(True)
+ self.id_spin.setValue(edit.current_id)
+ self.id_spin.blockSignals(False)
+ self.info.setText(f"{name} · {kind} · level {level}{state}")
+ self.browser.set_rows(self.ctl.rows(seg), kind=kind)
+
+
+class PicksInstancePanel(QWidget):
+ """Instance browser for the current particle list (instance IDs of picks, e.g. filament IDs)."""
+
+ def __init__(self, tool: Any, parent=None):
+ super().__init__(parent)
+ self.tool = tool
+ self.picks = None
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.browser = _browser("Pick instances")
+ self.browser.set_capabilities(color_toggle=True)
+ self.browser.focus_requested.connect(self._on_focus)
+ self.browser.visibility_changed.connect(self._on_visibility)
+ self.browser.color_by_instance_toggled.connect(self._on_color)
+ layout.addWidget(self.browser)
+
+ def set_picks(self, picks) -> None:
+ self.picks = picks
+ self.refresh()
+
+ def refresh(self) -> None:
+ pl = self.tool.picks_map.get(self.picks) if self.picks is not None else None
+ if pl is None or pl.deleted:
+ self.browser.clear()
+ return
+ ids = [int(p["instance_id"]) for _id, p in pl.data] if pl.size else []
+ scores = [float(p["score"]) for _id, p in pl.data] if pl.size else []
+ base = tuple(np.asarray(pl.color, dtype=float)[:4] / 255.0) if hasattr(pl, "color") else (1, 1, 1, 1)
+ self.browser.set_title(f"Pick instances · {self.picks.pickable_object_name}")
+ self.browser.set_rows(rows_from_points(ids, scores, base), kind="picks")
+
+ def _on_focus(self, key: int) -> None:
+ if self.picks is not None:
+ self.tool.focus_pick_instance(self.picks, key)
+
+ def _on_visibility(self, keys) -> None:
+ if self.picks is not None:
+ self.tool.set_pick_instances_visible(self.picks, keys)
+
+ def _on_color(self, on: bool) -> None:
+ if self.picks is not None:
+ self.tool.set_color_picks_by_instance(self.picks, on)
diff --git a/src/ui/main_widget.py b/src/ui/main_widget.py
index 83126a3..900ca15 100644
--- a/src/ui/main_widget.py
+++ b/src/ui/main_widget.py
@@ -4,6 +4,7 @@
import copick_shared_ui.platform.chimerax_integration as chimerax_integration_module
from copick.impl.filesystem import CopickRootFSSpec
from copick.models import CopickMesh, CopickPicks, CopickSegmentation
+from copick_shared_ui.core.types import copick_object_type
from Qt.QtCore import QEvent, QModelIndex, QObject, QSortFilterProxyModel, Qt
from Qt.QtWidgets import (
QHBoxLayout,
@@ -149,6 +150,32 @@ def _build(self):
picks_layout.addWidget(stepper_container)
picks_widget.setLayout(picks_layout)
+ # Filaments widget: the table and a filament stepper (like the picks page); tracing and the filament
+ # browser live in the Copick Annotate window
+ filaments_widget = QWidget()
+ filaments_layout = QVBoxLayout(filaments_widget)
+ filaments_layout.setContentsMargins(1, 1, 1, 1)
+ filaments_layout.setSpacing(2)
+ self._filaments_table = QUnifiedTable("filaments")
+ self._filament_stepper = StepWidget(noun="filament", prev_key="fp", next_key="fn")
+ self._filament_stepper.setMaximumHeight(45)
+ self._filament_stepper_name = ElidedLabel("No filament set active")
+ self._filament_stepper_name.setEnabled(False)
+ filament_stepper_row = QHBoxLayout()
+ filament_stepper_row.addStretch()
+ filament_stepper_row.addWidget(self._filament_stepper)
+ filament_stepper_row.addStretch()
+ filament_stepper_row.setContentsMargins(0, 0, 0, 0)
+ filament_stepper_layout = QVBoxLayout()
+ filament_stepper_layout.setContentsMargins(0, 0, 0, 0)
+ filament_stepper_layout.setSpacing(0)
+ filament_stepper_layout.addWidget(self._filament_stepper_name)
+ filament_stepper_layout.addLayout(filament_stepper_row)
+ filament_stepper_container = QWidget()
+ filament_stepper_container.setLayout(filament_stepper_layout)
+ filaments_layout.addWidget(self._filaments_table)
+ filaments_layout.addWidget(filament_stepper_container)
+
# Mesh widget with tight layout
meshes_layout = QVBoxLayout()
meshes_layout.setContentsMargins(1, 1, 1, 1) # Minimal margins
@@ -170,8 +197,16 @@ def _build(self):
# Create tabbed widget for tables
self._object_tabs = QTabWidget()
self._object_tabs.addTab(picks_widget, "Picks")
+ self._object_tabs.addTab(filaments_widget, "Filaments")
self._object_tabs.addTab(meshes_widget, "Meshes")
self._object_tabs.addTab(segmentations_widget, "Segmentations")
+ # Tab page -> table (used by the shared settings button; no hard-coded indices)
+ self._tab_tables = {
+ picks_widget: self._picks_table,
+ filaments_widget: self._filaments_table,
+ meshes_widget: self._meshes_table,
+ segmentations_widget: self._segmentations_table,
+ }
# Set up tables container with tight layout
tables_layout = QVBoxLayout()
@@ -526,6 +561,16 @@ def _connect(self):
self._picks_table.newClicked.connect(self._on_new_picks)
self._picks_table.deleteClicked.connect(self._copick.delete_particles)
+ # Filaments actions
+ self._filaments_table.get_table_view().doubleClicked.connect(
+ lambda i: self._copick.show_filaments(self._map_index(self._filaments_table, i)),
+ )
+ self._filaments_table.get_table_view().clicked.connect(
+ lambda i: self._copick.activate_filaments(self._map_index(self._filaments_table, i)),
+ )
+ self._filaments_table.newClicked.connect(self._on_new_filaments)
+ self._filaments_table.deleteClicked.connect(self._copick.delete_filaments)
+
# Meshes actions - use wrapper methods to handle proxy model mapping
self._meshes_table.get_table_view().doubleClicked.connect(self._on_meshes_double_click)
self._meshes_table.duplicateClicked.connect(self._copick.duplicate_mesh)
@@ -535,21 +580,34 @@ def _connect(self):
# Segmentations actions - use wrapper methods to handle proxy model mapping
self._segmentations_table.get_table_view().doubleClicked.connect(self._on_segmentations_double_click)
self._segmentations_table.duplicateClicked.connect(self._copick.duplicate_segmentation)
- self._segmentations_table.newClicked.connect(self._copick.new_segmentation)
+ self._segmentations_table.newClicked.connect(self._on_new_segmentation)
self._segmentations_table.deleteClicked.connect(self._copick.delete_segmentation)
+ self._segmentations_table.get_table_view().clicked.connect(self._on_segmentations_click)
- # Zarr level settings - connect all table overlays to persistent settings
- self._picks_table._settings_overlay.zarrLevelChanged.connect(self._on_zarr_level_changed)
- self._meshes_table._settings_overlay.zarrLevelChanged.connect(self._on_zarr_level_changed)
- self._segmentations_table._settings_overlay.zarrLevelChanged.connect(self._on_zarr_level_changed)
-
- # Loaded-tomogram cap - same pattern, persisted in settings
- for table in (self._picks_table, self._meshes_table, self._segmentations_table):
+ # Zarr level settings and the loaded-tomogram cap - all table overlays write the persistent settings
+ for table in self._all_tables():
+ table._settings_overlay.zarrLevelChanged.connect(self._on_zarr_level_changed)
table._settings_overlay.maxLoadedTomogramsChanged.connect(self._on_max_loaded_tomograms_changed)
self._picks_stepper.stepRequested.connect(self._on_stepper_step)
self._picks_stepper.jumpRequested.connect(self._copick.go_to)
+ self._filament_stepper.stepRequested.connect(self._on_filament_step)
+ self._filament_stepper.jumpRequested.connect(self._on_filament_jump)
+ self._copick.filaments.listeners.append(self.refresh_filament_stepper)
+ self.refresh_filament_stepper()
+
+ def _all_tables(self):
+ return (self._picks_table, self._filaments_table, self._meshes_table, self._segmentations_table)
+
+ def set_tables_run(self, run):
+ """Show a run's entities in all tables."""
+ for table in self._all_tables():
+ table.set_view(run)
+ annotate = self._copick.annotate(create=False)
+ if annotate is not None:
+ annotate.picks_panel.set_picks(None)
+
def set_entity_active(self, picks: Union[CopickMesh, CopickPicks, CopickSegmentation], active: bool):
if isinstance(picks, CopickPicks):
self._picks_table.set_entity_active(picks, active)
@@ -557,13 +615,63 @@ def set_entity_active(self, picks: Union[CopickMesh, CopickPicks, CopickSegmenta
self._meshes_table.set_entity_active(picks, active)
elif isinstance(picks, CopickSegmentation):
self._segmentations_table.set_entity_active(picks, active)
+ elif copick_object_type(picks) == "filaments":
+ self._filaments_table.set_entity_active(picks, active)
def update_picks_table(self):
self._picks_table.update()
+ def update_filaments_table(self):
+ self._filaments_table.update()
+ # The rebuilt model starts with every eye closed; re-mark the shown filament sets.
+ ctl = self._copick.filaments
+ for key in list(ctl.entries):
+ if not isinstance(key, type(ctl.entries[key][0])) and ctl.is_shown(key):
+ self._filaments_table.set_entity_active(key, True)
+
+ def update_segmentations_table(self):
+ self._segmentations_table.update()
+ for seg in list(self._copick.seg_map) + list(self._copick.segmentations.models):
+ if hasattr(seg, "run") and self._copick.segmentation_shown(seg):
+ self._segmentations_table.set_entity_active(seg, True)
+
+ @staticmethod
+ def _map_index(table, proxy_index: QModelIndex) -> QModelIndex:
+ if proxy_index.isValid() and table._filter_model:
+ return table._filter_model.mapToSource(proxy_index)
+ return proxy_index
+
+ def _on_segmentations_click(self, proxy_index: QModelIndex) -> None:
+ index = self._map_index(self._segmentations_table, proxy_index)
+ if not index.isValid():
+ return
+ seg = index.model().get_entity(index)
+ annotate = self._copick.annotate(create=False)
+ if annotate is not None:
+ annotate.instance_panel.selected_segmentation = seg
+ ctl = self._copick.segmentations
+ if seg in ctl.models:
+ ctl.active_key = seg
+ ctl.notify()
+
+ def _on_new_filaments(self, object_name: str, user_id: str, session_id: str):
+ from chimerax.core.commands import run
+
+ if object_name:
+ run(self._copick.session, f"copick new filaments {object_name} userId {user_id} sessionId {session_id}")
+
+ def _on_new_segmentation(self, object_name: str, user_id: str, session_id: str):
+ from chimerax.core.commands import run
+
+ if object_name:
+ run(
+ self._copick.session,
+ f"copick new segmentation {object_name} userId {user_id} sessionId {session_id}",
+ )
+
def clear_all_tables(self):
"""Clear all table models, deleting them so they don't leak."""
- for table in (self._picks_table, self._meshes_table, self._segmentations_table):
+ for table in self._all_tables():
# Disconnect and delete the selection model created for the current model.
selection_model = table._table.selectionModel()
if selection_model is not None:
@@ -592,6 +700,39 @@ def set_stepper(self, name: Optional[str], total: int, index: Optional[int] = No
self._picks_stepper_name.setFullText(name if name else "No particle list selected")
self._picks_stepper_name.setEnabled(bool(name) and enabled)
+ def refresh_filament_stepper(self) -> None:
+ """Show the active filament set and filament (``k of N`` in ID order, the order of ``fn`` / ``fp``)."""
+ ctl = self._copick.filaments
+ active = ctl.active
+ if active is None:
+ self._filament_stepper.set_state(0, None, enabled=False)
+ self._filament_stepper_name.setFullText("No filament set active")
+ self._filament_stepper_name.setEnabled(False)
+ return
+ edit, model = active
+ ids = edit.ids()
+ index = ids.index(edit.active_id) if edit.active_id in ids else None
+ current = f"filament #{edit.active_id}" if index is not None else f"new filament #{edit.active_id}"
+ name = f"{edit.object_name} · {edit.user_id}/{edit.session_id} — {current}"
+ if not model.display:
+ name += " (hidden)"
+ self._filament_stepper.set_state(len(ids), index, enabled=model.display and bool(ids))
+ self._filament_stepper_name.setFullText(name)
+ self._filament_stepper_name.setEnabled(model.display)
+
+ def _on_filament_step(self, delta: int) -> None:
+ from chimerax.core.commands import run
+
+ run(self._copick.session, "copick filament next" if delta > 0 else "copick filament previous")
+
+ def _on_filament_jump(self, index: int) -> None:
+ from chimerax.core.commands import run
+
+ edit = self._copick.filaments.edit_session
+ ids = edit.ids() if edit is not None else []
+ if 0 <= index < len(ids):
+ run(self._copick.session, f"copick filament go {ids[index]}")
+
def _on_stepper_step(self, delta: int):
if delta < 0:
self._copick.prev_particle()
@@ -837,11 +978,11 @@ def _on_picks_click(self, proxy_index: QModelIndex):
return
# Map proxy index to source index
- if self._picks_table._filter_model:
- source_index = self._picks_table._filter_model.mapToSource(proxy_index)
- self._copick.activate_particles(source_index)
- else:
- self._copick.activate_particles(proxy_index)
+ source_index = self._map_index(self._picks_table, proxy_index)
+ self._copick.activate_particles(source_index)
+ annotate = self._copick.annotate(create=False)
+ if annotate is not None:
+ annotate.picks_panel.set_picks(source_index.model().get_entity(source_index))
def _on_meshes_double_click(self, proxy_index: QModelIndex):
"""Handle double-click on meshes table by mapping proxy index to source index"""
@@ -892,16 +1033,7 @@ def _on_reload(self):
def _on_shared_settings_clicked(self):
"""Handle shared settings button click - show settings for current tab"""
- current_tab_index = self._object_tabs.currentIndex()
-
- # Get the current table's settings overlay
- current_table = None
- if current_tab_index == 0: # Picks tab
- current_table = self._picks_table
- elif current_tab_index == 1: # Meshes tab
- current_table = self._meshes_table
- elif current_tab_index == 2: # Segmentations tab
- current_table = self._segmentations_table
+ current_table = self._tab_tables.get(self._object_tabs.currentWidget())
if current_table:
# Initialize zarr level from persistent settings before showing
diff --git a/src/ui/step_widget.py b/src/ui/step_widget.py
index d9d6232..0bd38d1 100644
--- a/src/ui/step_widget.py
+++ b/src/ui/step_widget.py
@@ -17,7 +17,7 @@
class StepWidget(QWidget):
- """Display-only particle stepper (``<< [k] of N >>``).
+ """Display-only stepper (``<< [k] of N >>``) for particles or filaments.
The widget holds no stepping state of its own: the owning tool is the single authority
and pushes the current position via :meth:`set_state`. User input is forwarded as
@@ -30,8 +30,9 @@ class StepWidget(QWidget):
jumpRequested = Signal(int)
"""Absolute jump request, as a 0-based index (converted from the typed 1-based number)."""
- def __init__(self, parent=None):
+ def __init__(self, parent=None, noun: str = "particle", prev_key: str = "aa", next_key: str = "dd"):
super().__init__(parent=parent)
+ self._noun, self._prev_key, self._next_key = noun, prev_key, next_key
self._total = 0
self._index: Optional[int] = None
self._build()
@@ -43,8 +44,8 @@ def _build(self):
self._bck_button = QPushButton("<<")
self._fwd_button = QPushButton(">>")
- self._bck_button.setToolTip("Previous particle (aa)")
- self._fwd_button.setToolTip("Next particle (dd)")
+ self._bck_button.setToolTip(f"Previous {self._noun} ({self._prev_key})")
+ self._fwd_button.setToolTip(f"Next {self._noun} ({self._next_key})")
self._text = QLineEdit(IDLE_TEXT)
self._text.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum))
@@ -52,7 +53,7 @@ def _build(self):
# Digits only. Range checking happens in _txt(): a ranged QIntValidator would treat
# out-of-range input as "intermediate" and swallow editingFinished, leaving it stuck.
self._text.setValidator(QRegularExpressionValidator(QRegularExpression(r"\d*"), self._text))
- self._text.setToolTip("Type a particle number and press Enter to jump to it")
+ self._text.setToolTip(f"Type a {self._noun} number and press Enter to jump to it")
self._label = QLabel("of 0")
self._label.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum))
diff --git a/tests/conftest.py b/tests/conftest.py
index 7fe2fd4..d6786e8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -60,6 +60,7 @@ def tool_module(monkeypatch):
"copick.util",
"copick_shared_ui",
"copick_shared_ui.core",
+ "copick_shared_ui.util",
"Qt",
):
_module(monkeypatch, package)
@@ -112,6 +113,21 @@ def tool_module(monkeypatch):
set_global_cache_config=lambda *_args, **_kwargs: None,
set_global_cache_image_interface=lambda *_args, **_kwargs: None,
)
+ _module(
+ monkeypatch,
+ "copick_shared_ui.core.types",
+ segmentation_type_of=lambda seg: getattr(
+ seg,
+ "segmentation_type",
+ "multilabel" if getattr(seg, "is_multilabel", False) else "binary",
+ ),
+ segmentation_type_flags=lambda kind, explicit=None: {"is_multilabel": kind == "multilabel"},
+ )
+ _module(
+ monkeypatch,
+ "copick_shared_ui.util.instances",
+ instance_colors=lambda ids, base, dtype=None: [tuple(base)] * len(list(ids)),
+ )
_module(monkeypatch, "Qt.QtCore", QModelIndex=Placeholder)
_module(monkeypatch, "Qt.QtGui", QFont=Placeholder)
_module(monkeypatch, "Qt.QtWidgets", QVBoxLayout=Placeholder)
@@ -124,13 +140,14 @@ def tool_module(monkeypatch):
monkeypatch,
"portable_bundle.misc.pickops",
append_no_duplicates=lambda left, _right, **_kwargs: left,
- instance_id_colors=lambda ids, base: [tuple(base)] * len(list(ids)),
is_filament=lambda _obj: False,
point_from_pose=lambda **kwargs: kwargs,
point_identity=lambda point: (0, 1.0),
point_pose=lambda point: (point.location, (0.0, 0.0, 0.0), None),
with_new_particle_defaults=lambda data: data,
)
+ _module(monkeypatch, "portable_bundle.filaments.controller", FilamentController=Placeholder)
+ _module(monkeypatch, "portable_bundle.segmentation.controller", SegmentationController=Placeholder)
_module(monkeypatch, "portable_bundle.misc.settings", CoPickSettings=Placeholder)
_module(monkeypatch, "portable_bundle.misc.spotlight", SpotlightManager=SpotlightPlaceholder)
_module(
diff --git a/tests/test_segmentation_handoff.py b/tests/test_segmentation_handoff.py
index e8da5e6..124be79 100644
--- a/tests/test_segmentation_handoff.py
+++ b/tests/test_segmentation_handoff.py
@@ -83,3 +83,26 @@ def open_store(session, received_store, name):
np.testing.assert_array_equal(imported_volume.color, [100, 110, 120, 255])
np.testing.assert_array_equal(child.color, [100, 110, 120, 255])
assert snapshot(path) == before
+
+
+@pytest.mark.parametrize("kind", ["instance", "panoptic"])
+def test_instance_and_panoptic_segmentations_go_to_the_label_surface_controller(tool_module, monkeypatch, kind):
+ """Label maps (instance / panoptic) are drawn as one surface per label, not as a thresholded volume."""
+ segmentation = Segmentation(name="ribosome", segmentation_type=kind, is_multilabel=False, zarr_calls=0)
+ shown = []
+ commands = []
+ tool = tool_module.CopickTool.__new__(tool_module.CopickTool)
+ tool.seg_map = {}
+ tool.segmentations = SimpleNamespace(show=shown.append)
+ monkeypatch.setattr(tool_module, "run", lambda _session, command, **kwargs: commands.append(command))
+ monkeypatch.setattr(
+ tool_module,
+ "open_ome_zarr_from_store",
+ lambda *_args, **_kwargs: pytest.fail("label maps must not open as a volume"),
+ )
+
+ tool.show_volume_from_segmentation(segmentation)
+
+ assert shown == [segmentation]
+ assert commands == []
+ assert tool.seg_map == {}