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 + + + + + ChimeraX docs icon +

Command: copick filaments

+

Usage: +
copick (open | show | hide) filaments [ copick-uri ] +
copick new filaments object-name [ userId id ] [ sessionId id ] +
copick filament trace [ on | off | toggle ] +
copick filament join [ id1,id2,… ] [ target id ] +
copick filament cut [ on | off | toggle ] + [ at x,y,z [ instanceId filament-id ] ] +
copick filament (new | select | focus | go | next | previous | + reverse | delete | convert) [ filament-id ] +
copick filament save [ userId id ] [ sessionId id ] + [ 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 at x,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 target id), 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. +

+ + + + + + + + + + + + + + +
actionmeaning
newStart a new filament (the next free ID) and make it active.
select idMake filament id active (new control points go there).
go idMake 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 / previousStep 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.
saveSave 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.

+

See also: + copick instance, + copick open / show / hide, + Copick URIs.

+
+
Copick / chimerax-copick
+ + diff --git a/src/docs/user/commands/copick_instance.html b/src/docs/user/commands/copick_instance.html new file mode 100644 index 0000000..2ff3348 --- /dev/null +++ b/src/docs/user/commands/copick_instance.html @@ -0,0 +1,63 @@ + + + + Command: copick instance + + + + + ChimeraX docs icon +

Command: copick instance

+

Usage: +
copick instance edit [ copick-uri ] +
copick new segmentation object-name [ userId id ] [ sessionId id ] +
copick instance (paint | erase | pick | new | undo) +
copick instance id N +
copick instance merge ids into id +
copick instance delete ids +
copick instance (show | hide | isolate) ids +
copick instance brush radius +
copick instance save [ userId id ] [ sessionId id ] +
copick colorpicks [ on | off ] +
copick annotate [ show | hide | toggle ] [ page filaments | instances | picks ] +
copick save

+ +

+ 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.

+

See also: + copick filaments, + copick open / show / hide.

+
+
Copick / chimerax-copick
+ + diff --git a/src/docs/user/copick_index.html b/src/docs/user/copick_index.html index 9c88fc5..d3cbd64 100644 --- a/src/docs/user/copick_index.html +++ b/src/docs/user/copick_index.html @@ -64,6 +64,10 @@

new picks  – create a new empty set of picks in the active run +
  • filaments +  – show, trace, edit and save filament sets
  • +
  • instance +  – browse, paint, merge and save instance segmentations
  • reload  – reload the current session from its config file
  • view diff --git a/src/docs/user/tools/copick.html b/src/docs/user/tools/copick.html index c6becc4..385e2dc 100644 --- a/src/docs/user/tools/copick.html +++ b/src/docs/user/tools/copick.html @@ -47,8 +47,34 @@

    Scripting

    Toolbar

    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 == {}