Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1f07d64
feat(gui): add shared text levels and spacing
holsam Nov 2, 2026
c87cbf7
feat(gui): show tomogram dock on the pick tab only
holsam Nov 2, 2026
3d57477
feat(config): add titles & descriptions to params
holsam Nov 2, 2026
8087b32
feat(project): label settings & add tooltips
holsam Nov 2, 2026
09e0e81
chore: add help button icon
holsam Nov 2, 2026
1fce4a0
feat(project): split settings into sections
holsam Nov 3, 2026
24687af
feat(project): add start page for projects
holsam Nov 3, 2026
7050010
feat(config): add hotkeys setting
holsam Nov 3, 2026
4ba8b49
feat(pick): follow hotkeys from the config
holsam Nov 3, 2026
ee682ab
feat(config): allow a config with no kinds
holsam Nov 3, 2026
b29841f
feat(config): add titles and descriptions to kinds
holsam Nov 3, 2026
1ccabc4
refactor(gui): share help marker and form labels
holsam Nov 3, 2026
0c9361e
feat(kinds): add kind dialog
holsam Nov 3, 2026
808a406
feat(model): allow kind renaming & removing
holsam Nov 3, 2026
35aa9ba
feat(kinds): list kinds with hotkey dropdowns
holsam Nov 3, 2026
2523481
style(kinds): tidy the kinds layout
holsam Nov 3, 2026
d26bde0
feat(average): summarise tomograms with picks
holsam Nov 3, 2026
da7638d
style(gui): restyle tomogram dock
holsam Nov 3, 2026
2b05fe2
feat(pick): enlarge tomogram title
holsam Nov 3, 2026
7802701
style(pick): update hotkeys row styling
holsam Nov 3, 2026
8867f19
refactor(pick): move controls to settings pane
holsam Nov 3, 2026
45a48b3
refactor(gui): standardise margins/spacing/font
holsam Nov 3, 2026
88bb593
fix(gui): fix pan mouse click
holsam Nov 3, 2026
0ba7e7c
chore: add help button icon
holsam Nov 3, 2026
ecc6d34
chore: add license
holsam Nov 3, 2026
3bc40ad
chore: update pickmix version to v0.2.0
holsam Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright (c) 2026 Samuel Holland

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
36 changes: 36 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
pickmix bundles the following third-party material.

add.svg
Material Symbols "add_circle" icon
Copyright (c) Google LLC
Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory)
Source: https://github.com/google/material-design-icons
Modifications: fill colour set to #ffffff

edit.svg
Material Symbols "edit" icon
Copyright (c) Google LLC
Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory)
Source: https://github.com/google/material-design-icons
Modifications: fill colour set to #ffffff

help.svg
Material Symbols "help" icon
Copyright (c) Google LLC
Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory)
Source: https://github.com/google/material-design-icons
Modifications: fill colour set to #8a8a8a

visibility.svg
Material Symbols "visibiility" icon
Copyright (c) Google LLC
Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory)
Source: https://github.com/google/material-design-icons
Modifications: fill colour set to #8a8a8a

visibility_off.svg
Material Symbols "visibility_off" icon
Copyright (c) Google LLC
Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory)
Source: https://github.com/google/material-design-icons
Modifications: fill colour set to #8a8a8a
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pickmix"
version = "0.1.0"
version = "0.2.0"
description = "Multi-kind particle picking GUI for cryo-ET sub-tomogram averaging workflows."
readme = "README.md"
authors = [
Expand Down
69 changes: 44 additions & 25 deletions src/pickmix/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,48 @@
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Literal

# Kind: one class of particle
# HOTKEYS: number of hotkeys, keys 1 to 9
HOTKEYS: int = 9

# Kind: one class of particle; title and description feed the kind dialog
class Kind(BaseModel):
# displayed marker diameter, Angstrom
marker_a: float = Field(gt=0)
marker_a: float = Field(gt=0, title='Marker diameter (Å)', description='Diameter of the marker drawn on a pick.')
# extraction box edge, Angstrom; 0 takes the box from the estimator
box_a: float = Field(default=0.0, ge=0)
box_a: float = Field(default=0.0, ge=0, title='Box edge (Å)', description='Extraction box edge. 0 takes the box from the estimator.')
# name in the estimator registry
estimator: str = 'none'
estimator: str = Field(default='none', title='Estimator', description='Estimator that fits each pick of this kind.')
# any Qt colour name or #rrggbb
colour: str = 'yellow'
colour: str = Field(default='blue', title='Colour', description='Marker colour: a name or hex code (#rrggbb).')
# kind whose nearest estimate supplies membrane-normal geometry
parent: str | None = None
parent: str | None = Field(default=None, title='Parent', description='Kind whose nearest estimate supplies membrane-normal geometry.')
# how far a pick may sit from the parent's surface and still count as on it
parent_tolerance_a: float = Field(default=100.0, gt=0)
parent_tolerance_a: float = Field(default=100.0, gt=0, title='Parent tolerance (Å)', description='How far a pick may sit from the parent surface and still count as on it.')
# written as rlnAngleTiltPrior when set
tilt_prior_deg: float | None = None
tilt_prior_deg: float | None = Field(default=None, title='Tilt prior (°)', description='Written as rlnAngleTiltPrior when set, and left out otherwise.')
# pixel size of the RELION extraction in Angstrom per pixel
extract_angpix: float = Field(default=10.68, gt=0)
extract_angpix: float = Field(default=10.68, gt=0, title='Extract pixel size (Å/px)', description='Pixel size of the RELION extraction in Å per pixel.')

# RingParams: settings of the ring estimator, all lengths in Angstrom
# RingParams: settings of the ring estimator, all lengths in Angstrom; title and description feed the settings form
class RingParams(BaseModel):
# cage radius sweep and ring width
radius_range_a: tuple[float, float] = (150.0, 500.0)
radius_step_a: float = 25.0
ring_sigma_a: float = 30.0
radius_range_a: tuple[float, float] = Field(default=(150.0, 500.0), title='Radius range (Å)', description='Smallest and largest cage radius tried.')
radius_step_a: float = Field(default=25.0, title='Radius step (Å)', description='Spacing of the radii tried across the range.')
ring_sigma_a: float = Field(default=30.0, title='Ring width σ (Å)', description='Width of the dark ring template.')
# background flattening and light smoothing of each projection
highpass_sigma_a: float = 500.0
smooth_sigma_a: float = 8.0
highpass_sigma_a: float = Field(default=500.0, title='High-pass σ (Å)', description='Gaussian width removed from each projection to flatten the background.')
smooth_sigma_a: float = Field(default=8.0, title='Smoothing σ (Å)', description='Light Gaussian smoothing of each projection.')
# ring half-width and the background annulus as multiples of the ring radius
ring_halfwidth_a: float = 40.0
bg_range: tuple[float, float] = (1.3, 1.8)
ring_halfwidth_a: float = Field(default=40.0, title='Ring half-width (Å)', description='Half-width of the ring used to score its contrast.')
bg_range: tuple[float, float] = Field(default=(1.3, 1.8), title='Background annulus (× radius)', description='Inner and outer edge of the background annulus, as multiples of the ring radius.')
# z slab grown up to walk_max_half_a, stopping after walk_patience steps without a better SNR
walk_max_half_a: float = 400.0
walk_patience: int = 3
walk_max_half_a: float = Field(default=400.0, title='Max slab half-thickness (Å)', description='The z slab grows up to this half-thickness.')
walk_patience: int = Field(default=3, title='Walk patience (steps)', description='Growth stops after this many steps without a better SNR.')
# how far the centre may move from the pick in xy, and the slab centre in z
locate_range_a: float = 200.0
z_shift_a: float = 100.0
locate_range_a: float = Field(default=200.0, title='Locate range (Å)', description='How far the ring centre may move from the pick in xy.')
z_shift_a: float = Field(default=100.0, title='Z shift (Å)', description='How far the slab centre may move from the pick in z.')
# box edge as a multiple of the ring diameter when the kind takes its box from the estimator
box_margin: float = 1.25
box_margin: float = Field(default=1.25, title='Box margin (× diameter)', description='Box edge as a multiple of the ring diameter, for kinds that take their box from the estimator.')

# _default_kinds: the single cage kind used when a config has no kinds table
def _default_kinds() -> dict[str, Kind]:
Expand All @@ -66,24 +69,40 @@ class Config(BaseModel):
# volume read for display, estimation and extraction; falls back to raw when a tomogram has no denoised pair
source: Literal['raw', 'denoised'] = 'denoised'
ring: RingParams = Field(default_factory=RingParams)
# table order sets the 1 to 9 hotkeys
kinds: dict[str, Kind] = Field(default_factory=_default_kinds)
# kind chosen by each of the keys 1 to 9, '' for an unused key
hotkeys: list[str] = Field(default_factory=list)

# replace: validated copy with some fields swapped
def replace(self, **changes: object) -> 'Config':
if 'kinds' in changes and 'hotkeys' not in changes:
changes = {**changes, 'hotkeys': []}
return Config.model_validate({**self.model_dump(), **changes})

# _names: kind names become folder names, so keep them plain
@field_validator('kinds')
@classmethod
def _names(cls, kinds: dict[str, Kind]) -> dict[str, Kind]:
if not kinds:
raise ValueError('at least one kind is needed')
for name in kinds:
if not re.fullmatch(r'[A-Za-z0-9_]+', name):
raise ValueError(f'kind name {name!r} may only use letters, digits and underscores')
return kinds

# _fill_hotkeys: fill the default, pad to nine slots, and check each names a known kind and is used once
@model_validator(mode='after')
def _fill_hotkeys(self) -> 'Config':
keys = self.hotkeys or [*self.kinds][:HOTKEYS]
if len(keys) > HOTKEYS:
raise ValueError(f'at most {HOTKEYS} hotkeys, got {len(keys)}')
used = [k for k in keys if k]
for name in used:
if name not in self.kinds:
raise ValueError(f'hotkey names unknown kind {name!r}')
if len(set(used)) != len(used):
raise ValueError('a kind may only be on one hotkey')
self.hotkeys = [*keys, *[''] * (HOTKEYS - len(keys))]
return self

# _relations: parents must exist and form no cycle, and a passthrough kind needs a box
@model_validator(mode='after')
def _relations(self) -> 'Config':
Expand Down
5 changes: 5 additions & 0 deletions src/pickmix/estimates.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ def put(self, pick: Pick, fit: Fit, digest: str) -> None:
for listener in self.listeners:
listener()

# rename_kind: keep the fits of a renamed kind; written with the next estimate, and rows that never match a pick are dropped then
def rename_kind(self, old: str, new: str) -> None:
with self._lock:
self._rows = {(_key(r.tomogram, new, r.pick_x, r.pick_y, r.pick_z) if r.kind == old else k): r.model_copy(update={'kind': new}) if r.kind == old else r for k, r in self._rows.items()}

# _save: write the CSV atomically, keeping only rows that belong to a pick now
def _save(self) -> None:
live = {_key(p.tomogram, p.kind, p.x, p.y, p.z) for p in self.picks.all()}
Expand Down
2 changes: 2 additions & 0 deletions src/pickmix/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

# Import internal pickmix objects
from pickmix.gui.main_window import MainWindow
from pickmix.gui.style import standardise

# run: show the main window, opening the config.toml in args if one is given, and return the exit code
def run(args: list[str]) -> int:
app = QApplication.instance() or QApplication(['pickmix'])
standardise(app)
window = MainWindow()
window.show()
if args:
Expand Down
66 changes: 36 additions & 30 deletions src/pickmix/gui/average_pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

# Import external dependencies
from PySide6.QtCore import Qt
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import QComboBox, QGroupBox, QHBoxLayout, QLabel, QListWidget, QPushButton, QVBoxLayout

# Import internal pickmix objects
from pickmix.average import AverageResult, average_kind
from pickmix.estimates import EstimateService
from pickmix.export import Item
from pickmix.gui.images import to_pixmap
from pickmix.gui.style import tidy
from pickmix.gui.workers import run_in_background
from pickmix.model import Session

Expand All @@ -26,9 +28,7 @@ def __init__(self) -> None:
self.service: EstimateService | None = None
self.result: AverageResult | None = None
self.kind_box = QComboBox()
self.tomo_list = QListWidget()
self.tomo_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection)
self.tomo_list.setMaximumHeight(110)
self.summary = QLabel()
self.run_button = QPushButton('Run')
self.status = QLabel()
self.mean_label = QLabel('mean')
Expand All @@ -39,20 +39,23 @@ def __init__(self) -> None:
label.setStyleSheet('background: #141414; color: #888')
self.results_list = QListWidget()
self.results_list.setMaximumWidth(220)
left = QVBoxLayout()
left.addWidget(QLabel('Kind'))
left.addWidget(self.kind_box)
left.addWidget(QLabel('Tomograms with picks'))
left.addWidget(self.tomo_list)
left.addWidget(self.run_button)
left.addWidget(self.status)
left.addStretch()
body = QHBoxLayout(self)
body.addLayout(left)
body.addWidget(self.mean_label)
body.addWidget(self.montage_label)
body.addWidget(self.results_list)
self.kind_box.currentTextChanged.connect(self._fill_tomograms)
# kind, summary and console above the images
controls = QHBoxLayout()
controls.addWidget(QLabel('Kind'))
controls.addWidget(self.kind_box)
controls.addWidget(self.run_button)
controls.addWidget(self.summary, 1)
images = QHBoxLayout()
images.addWidget(self.mean_label)
images.addWidget(self.montage_label)
images.addWidget(self.results_list)
images.addStretch()
body = QVBoxLayout(self)
tidy(body)
body.addLayout(controls)
body.addWidget(self.status)
body.addLayout(images)
self.kind_box.currentTextChanged.connect(self._update_summary)
self.run_button.clicked.connect(self.run)
self.results_list.currentRowChanged.connect(self._show_row)

Expand All @@ -70,24 +73,24 @@ def refresh_kinds(self) -> None:
self.kind_box.addItems(list(self.session.project.config.kinds))
self.kind_box.setCurrentText(current)
self.kind_box.blockSignals(False)
self._fill_tomograms()
self._update_summary()

# items: current fits of the chosen kind in the selected tomograms
# items: current fits of the chosen kind in every tomogram
def items(self) -> list[Item]:
if self.session is None or self.service is None:
return []
chosen = {i.text() for i in self.tomo_list.selectedItems()}
out = []
for pick in self.session.store.all(self.kind_box.currentText()):
fit = self.service.store.get(pick)
if fit is not None and pick.tomogram in chosen:
if fit is not None:
out.append((pick, fit))
return out

# run: average the chosen kind in the background
def run(self) -> None:
if self.session is None:
return
self._update_summary()
items, name = self.items(), self.kind_box.currentText()
if not items:
self.status.setText('No picks with a current estimate in the selected tomograms')
Expand All @@ -97,16 +100,19 @@ def run(self) -> None:
self.status.setText(f'Averaging {len(items)} picks...')
run_in_background(lambda: average_kind(name, config.kinds[name], items, tomos, config), self._done, self._failed)

# _fill_tomograms: tomograms that have picks of the chosen kind, all selected
def _fill_tomograms(self) -> None:
self.tomo_list.clear()
if self.session is None:
# _update_summary: total picks, tomograms with picks and picks per tomogram for the chosen kind
def _update_summary(self) -> None:
picks = self.session.store.all(self.kind_box.currentText()) if self.session is not None else []
tomograms = len({p.tomogram for p in picks})
if not tomograms:
self.summary.setText('No picks of this kind')
return
kind = self.kind_box.currentText()
for name in self.session.names:
if self.session.store.count(name, kind):
self.tomo_list.addItem(name)
self.tomo_list.selectAll()
self.summary.setText(f'{len(picks)} picks in {tomograms} tomograms ({len(picks) / tomograms:.1f} per tomogram)')

# showEvent: picks change on other tabs, so refresh the summary whenever this pane appears
def showEvent(self, event: QShowEvent) -> None:
self._update_summary()
super().showEvent(event)

# _done: show the overall mean and list what can be looked at
def _done(self, result: AverageResult) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/pickmix/gui/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def fit(self) -> None:
# mousePressEvent: pan, select or drag a marker, or request a new pick
def mousePressEvent(self, event: QMouseEvent) -> None:
pos = self.mapToScene(event.position().toPoint())
if event.button() == Qt.MouseButton.MiddleButton:
if event.button() == Qt.MouseButton.RightButton:
self._pan = event.position()
self.setCursor(Qt.CursorShape.ClosedHandCursor)
elif event.button() == Qt.MouseButton.LeftButton:
Expand Down Expand Up @@ -216,7 +216,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:

# mouseReleaseEvent: finish a pan, or finish a marker press as a click or a move
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.MiddleButton and self._pan is not None:
if event.button() == Qt.MouseButton.RightButton and self._pan is not None:
self._pan = None
self.unsetCursor()
elif event.button() == Qt.MouseButton.LeftButton and self._drag is not None:
Expand Down
35 changes: 35 additions & 0 deletions src/pickmix/gui/collapsible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'''
pickmix: collapsible section widget
'''

# Import external dependencies
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QToolButton, QVBoxLayout, QWidget

# Import internal pickmix objects
from pickmix.gui.style import body_pt

# Collapsible: a header button with an arrow that shows or hides a widget
class Collapsible(QWidget):
# __init__: header over content, collapsed unless expanded
def __init__(self, text: str, content: QWidget, *, expanded: bool = False) -> None:
super().__init__()
self.content = content
self.toggle = QToolButton()
self.toggle.setText(text)
self.toggle.setCheckable(True)
# flat bold text and arrow: no button frame, no highlight when checked
self.toggle.setStyleSheet(f'QToolButton {{ border: none; background: transparent; font-weight: bold; font-size: {body_pt()}pt; }}')
self.toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.toggle, alignment=Qt.AlignmentFlag.AlignLeft)
layout.addWidget(content)
self.toggle.toggled.connect(self._show)
self.toggle.setChecked(expanded)
self._show(expanded)

# _show: arrow and content follow the toggle
def _show(self, expanded: bool) -> None:
self.toggle.setArrowType(Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow)
self.content.setVisible(expanded)
Loading
Loading