Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
da82084
chore: update pickmix description
holsam Oct 30, 2026
1f8ba13
chore: add pickmix dependencies
holsam Oct 30, 2026
0b5f77f
feat(utils): add .star read/write/merge utilities
holsam Oct 30, 2026
09e78a5
feat(config): add configuration schema
holsam Oct 30, 2026
3566099
feat(utils): add tomogram discovery/reading utils
holsam Oct 30, 2026
a3d7902
feat(utils): add voxel-coordinate conversion utils
holsam Oct 30, 2026
22f6490
feat(project): add project creation
holsam Oct 30, 2026
41757bb
chore: update pickmix dependencies
holsam Oct 30, 2026
554f5ca
feat(project): add open project state
holsam Oct 30, 2026
e3e903a
feat(gui): add background worker helpers
holsam Oct 30, 2026
30a8787
feat(gui): add new project dialog
holsam Oct 30, 2026
4a80214
feat(gui): add project tab
holsam Oct 31, 2026
556293c
feat(gui): add main window and entry point
holsam Oct 31, 2026
894928d
feat(estimate): add registry & passthrough
holsam Oct 31, 2026
038425e
feat(gui): add kinds tab
holsam Nov 1, 2026
dbdbc0d
feat(gui): show kinds tab in main window
holsam Nov 1, 2026
190ea33
feat(timing): add step timer
holsam Nov 1, 2026
fb1b967
feat(model): add pick store and session schema
holsam Nov 1, 2026
0be425f
feat(loader): add slice loader
holsam Nov 1, 2026
553bf87
feat(gui): add slice canvas
holsam Nov 1, 2026
1f7feed
feat(gui): add tomogram dock
holsam Nov 1, 2026
eb3d792
feat(gui): add pick tab
holsam Nov 1, 2026
b759c6e
feat(gui): show pick tab in main window
holsam Nov 1, 2026
26abc89
chore: update pickmix dependencies
holsam Nov 1, 2026
1be643c
feat(estimate): add ring estimator
holsam Nov 1, 2026
6eaf587
feat(estimates): add cached estimates
holsam Nov 1, 2026
768ef9e
feat(export): add kind export and box extraction
holsam Nov 1, 2026
ec7acf8
feat(gui): show fits in the pick tab
holsam Nov 1, 2026
3a2dca8
feat(gui): add review tab with export and extract
holsam Nov 1, 2026
54ef2b1
feat(coords): add normal to euler angle conversion
holsam Nov 1, 2026
37cd7e5
feat(geometry): add parent matching
holsam Nov 1, 2026
23ba784
feat(export): write parent geometry columns
holsam Nov 1, 2026
7b41e7b
feat(gui): show parent normals and orphan picks
holsam Nov 1, 2026
0579fc9
refactor: refactor config tests
holsam Nov 1, 2026
6c667ad
chore: update pickmix dependencies
holsam Nov 1, 2026
064e4fc
feat(average): add kind averages
holsam Nov 1, 2026
e7262d9
feat(gui): add average pane to the kinds tab
holsam Nov 1, 2026
6d629d9
docs: update readme & add screenshots
holsam Nov 2, 2026
f10be6e
pr: merge holsam/v0.1.0-release to holsam/release
holsam Nov 2, 2026
5e51ac8
chore: add github actions workflows
holsam Nov 2, 2026
ab6a86e
chore: update pickmix version to v0.1.0
holsam Nov 2, 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
66 changes: 66 additions & 0 deletions .github/workflows/check-version-increment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Check Version Number Increments

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
version-check:
name: Check version bump
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v7

- name: Read PR version
id: pr_version
run: |
VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Read main version
id: main_version
run: |
git fetch origin main --depth=1
VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version *= *' | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from main's pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d

- name: Compare versions
run: |
PR_VERSION="${{ steps.pr_version.outputs.version }}"
MAIN_VERSION="${{ steps.main_version.outputs.version }}"
echo "PR version: $PR_VERSION"
echo "main version: $MAIN_VERSION"
uv run --with packaging python3 - "$PR_VERSION" "$MAIN_VERSION" <<'EOF'
import sys
from packaging.version import Version, InvalidVersion

pr_raw, main_raw = sys.argv[1], sys.argv[2]

try:
pr = Version(pr_raw)
main = Version(main_raw)
except InvalidVersion as e:
print(f"Could not parse version as PEP 440: {e}", file=sys.stderr)
sys.exit(1)

if pr <= main:
print(f'Version in pyproject.toml ({pr_raw}) is not greater than main ({main_raw}). Versions must strictly increase.', file=sys.stderr)
sys.exit(1)

print(f'OK: {pr_raw} > {main_raw}')
EOF
42 changes: 42 additions & 0 deletions .github/workflows/publish-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Publish New Release

on:
push:
branches: [main]

permissions:
contents: write

jobs:
release:
name: Build and publish release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d

- name: Read version
id: version
run: |
VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [ -z "$VERSION" ]; then
echo "Could not read version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Build package
run: uv build

- name: Publish release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
generate_release_notes: true
draft: false
files: |
dist/*.whl
dist/*.tar.gz
74 changes: 73 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,73 @@
# pickmix
# pickmix

Multi-kind particle picking GUI for cryo-ET sub-tomogram-averaging workflows.

Pickmix supports picking multiple kinds of user-defined particles from reconstructed tomograms, with built-in SNR-based refinement estimation and a 2D average validation step, for export as a RELION 5-compatible coordinate `.star` file.

## Installation
### Requirements
- Python (≥3.14)
- [`uv`](https://docs.astral.sh/uv/) package manager

### Installation command

```sh
uv tool install git+https://github.com/holsam/pickmix
pickmix [path/to/config.toml]
```
For development, use `uv sync` then `uv run pickmix`.

## Inputs

- One or more RELION projects. Each project must have a tomogram job (default `Tomograms/job006`) which contains a `tomograms.star`.
- Reconstructed tomograms. All raw tomograms should be located (symbolic linking is supported) within a single directory as MRC files. The header shape and pixel size of each MRC file must agree with `tomograms.star`, otherwise the tomogram will be rejected.
- [OPTIONAL] Denoised tomograms. All denoised tomograms should be located (symbolic linking is supported) within a single directory as MRC files. Denoised tomograms can contain an additional specified suffix (e.g. `.denoised`) that will be included when matching to raw tomograms.

## How to use `pickmix`
### 1. Create a project

Launch pickmix using `pickmix`, which will open on the Project tab.

![Project tab](docs/images/empty-project-tab.png)

Click `[New project]`, and complete the fields in the dialog. Pickmix merges multiple runs into a single `tomograms.star` and writes `config.toml` in the project directory. Tomogram names get the run name as a prefix. Existing files are never overwritten.

![New project dialog](docs/images/new-project.png)

Once created, the Project tab lists usable and rejected tomograms and the shared settings.

![Project tab](docs/images/project-tab.png)

### 2. Define kinds

Open the Kinds tab, and add additional 'kinds' (types of pick) as required. Each kind has a marker size, an extraction box, an estimator (`ring` fits a circle to a pick, `none` passes the click straight through), a colour, an optional parent kind and an extraction pixel size. The table order sets the `1` to `9` hotkeys. A kind with a parent takes its membrane-normal orientation from the nearest parent estimate.

![Kinds tab](docs/images/kinds-tab.png)

### 3. Pick

Open the Pick tab and select a kind with `1` to `9`. Left-click adds or selects a pick; picks can be moved by selecting & dragging, or deleted using `Delete`. Right-click to pan across the current slice, and Ctrl/Cmd+Scroll to adjust zoom. Scrolling updates the current Z slice, and `Enter`/`Shift+Enter` steps forward/backward between tomograms. Fitted rings, boxes and normals can be toggled on/off as desired, and contrast sliders adjust the display.

![Pick tab](docs/images/pick-tab.png)

### 4. Average

Return to the Kinds tab, and select a kind. Click `[Run averaging]` to extract a box around each pick and create a mean projection & montage.

![Average pane](docs/images/average-pane.png)

### 5. Review and export

Open the Review tab. Picks can be filtered by SNR and any flags, delete flagged picks, re-estimate, then **Export star files** to write `out/relion/<kind>/particles.star` plus one `NAME_coords.star` per tomogram. **Extract boxes** cuts the sub-volumes as MRC files. Kinds with a parent also write the subtomogram angles derived from it.

![Review tab](docs/images/review-tab.png)

## Project layout

```
project/
config.toml settings and kinds
tomograms.star merged RELION tomogram table
tilt_series/ one star file per tomogram
out/ picks, estimates, averages, RELION export
```
Binary file added docs/images/average-pane.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/empty-project-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/kinds-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/new-project.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pick-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/project-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/review-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 18 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
[project]
name = "pickmix"
version = "0.0.0"
description = "Add your description here"
version = "0.1.0"
description = "Multi-kind particle picking GUI for cryo-ET sub-tomogram averaging workflows."
readme = "README.md"
authors = [
{ name = "Sam Holland", email = "hol_sam@icloud.com" }
]
requires-python = ">=3.14"
dependencies = []
dependencies = [
"imageio>=2.38.1",
"mrcfile>=1.5.4",
"numpy>=2.5.4",
"pydantic>=2.14.2",
"pyside6>6.9",
"scikit-image>=0.26.0",
"scipy>=1.18.1",
"tomli-w>=1.2.0",
]

[project.scripts]
pickmix = "pickmix:main"

[build-system]
requires = ["uv_build>=0.13.6,<0.14.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
"pytest>=9.2.0",
"pytest-qt>=4.5.0",
]
9 changes: 9 additions & 0 deletions src/pickmix/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'''
pickmix: entrypoint
'''

# main: launch the window, opening the config.toml given as the first argument
def main() -> None:
import sys
from pickmix.gui.app import run
raise SystemExit(run(sys.argv[1:]))
76 changes: 76 additions & 0 deletions src/pickmix/average.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'''
pickmix: mean projection and montage of a kind's boxes
'''

# Import external dependencies
import math, imageio.v3 as iio, numpy as np
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from skimage.util import montage

# Import internal pickmix objects
from pickmix.config import Config, Kind
from pickmix.export import Item
from pickmix.extract import box_pixels, crop_box, pixel_size_of
from pickmix.utils.io import Tomogram, load_volume

# _MONTAGE_MAX: most boxes shown in the montage, spread evenly over the picks
_MONTAGE_MAX: int = 64

# AverageResult: normalised 2D images, ready to show; overall and per-tomogram means, and the montage
@dataclass(frozen=True)
class AverageResult:
kind: str
count: int
box_px: int
overall: np.ndarray
montage: np.ndarray
per_tomogram: dict[str, np.ndarray]
folder: Path

# average_kind: cut a box at every item, project the central slab of each along z, normalise it, and average per tomogram and overall; PNGs go to out_dir/average/<kind>/
def average_kind(
name: str, kind: Kind, items: list[Item], tomos: dict[str, Tomogram], config: Config,
*, log: Callable[[str], None] = lambda _: None,
) -> AverageResult:
if not items:
raise ValueError(f'kind {name!r}: nothing to average')
px = pixel_size_of(name, items, tomos)
box = box_pixels(kind, items, px)
# slab as thick as the particle, so the projection is not washed out by empty box
half = min(max(round(kind.marker_a / 2 / px), 1), box // 2)
centre = box // 2
images: dict[str, list[np.ndarray]] = {}
for tomogram in dict.fromkeys(p.tomogram for p, _ in items):
vol = load_volume(tomos[tomogram], source=config.source)
for p, f in items:
if p.tomogram != tomogram:
continue
cube = crop_box(vol, (round(f.z), round(f.y), round(f.x)), box)
img = cube[centre - half : centre + half + 1].mean(axis=0)
images.setdefault(tomogram, []).append((img - img.mean()) / (img.std() or 1.0))
log(f'{name} {tomogram}: {len(images[tomogram])} boxes')
everything = [img for imgs in images.values() for img in imgs]
chosen = np.linspace(0, len(everything) - 1, min(len(everything), _MONTAGE_MAX)).astype(int)
grid = math.ceil(math.sqrt(len(chosen)))
result = AverageResult(
name, len(everything), box,
np.mean(everything, axis=0),
montage(np.stack([everything[i] for i in chosen]), grid_shape=(grid, grid), padding_width=2),
{t: np.mean(imgs, axis=0) for t, imgs in images.items()},
config.out_dir / 'average' / name,
)
result.folder.mkdir(parents=True, exist_ok=True)
for stale in result.folder.glob('*.png'):
stale.unlink()
_save(result.folder / 'overall_mean.png', result.overall)
_save(result.folder / 'overall_montage.png', result.montage)
for tomogram, img in result.per_tomogram.items():
_save(result.folder / f'{tomogram}_mean.png', img)
return result

# _save: write an image stretched between its 1st and 99th percentiles as an 8-bit PNG
def _save(path: Path, arr: np.ndarray) -> None:
lo, hi = np.percentile(arr, (1, 99))
iio.imwrite(path, np.clip((arr - lo) * (255.0 / max(hi - lo, 1e-6)), 0, 255).astype(np.uint8))
Loading
Loading