diff --git a/NOTICE b/NOTICE index 220b8f7..984a462 100644 --- a/NOTICE +++ b/NOTICE @@ -7,6 +7,41 @@ add.svg Source: https://github.com/google/material-design-icons Modifications: fill colour set to #ffffff +cached.svg + Material Symbols "cached" 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 + +check.svg + Material Symbols "check" 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 + +close.svg + Material Symbols "close" 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 + +create_new_folder.svg + Material Symbols "create_new_folder" 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 + +delete.svg + Material Symbols "delete" 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 + edit.svg Material Symbols "edit" icon Copyright (c) Google LLC @@ -14,6 +49,34 @@ edit.svg Source: https://github.com/google/material-design-icons Modifications: fill colour set to #ffffff +error.svg + Material Symbols "error" 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 + +fit_screen.svg + Material Symbols "fit_screen" 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 + +folder_open.svg + Material Symbols "folder_open" 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 + +fullscreen.svg + Material Symbols "fullscreen" 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 + help.svg Material Symbols "help" icon Copyright (c) Google LLC @@ -21,6 +84,34 @@ help.svg Source: https://github.com/google/material-design-icons Modifications: fill colour set to #8a8a8a +left_panel_close.svg + Material Symbols "left_panel_close" 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 + +left_panel_open.svg + Material Symbols "left_panel_open" 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 + +logout.svg + Material Symbols "logout" 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 + +refresh.svg + Material Symbols "refresh" 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 @@ -33,4 +124,11 @@ visibility_off.svg 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 \ No newline at end of file + Modifications: fill colour set to #8a8a8a + +warning.svg + Material Symbols "warning" 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 diff --git a/README.md b/README.md index 941be95..0f5f384 100644 --- a/README.md +++ b/README.md @@ -26,41 +26,48 @@ For development, use `uv sync` then `uv run pickmix`. ## How to use `pickmix` ### 1. Create a project -Launch pickmix using `pickmix`, which will open on the Project tab. +Launch pickmix using `pickmix`. The welcome page has two buttons for opening an existing project (also via `Ctrl/Cmd+O`) and creating a new project (also via `Ctrl/Cmd+N`). -![Project tab](docs/images/empty-project-tab.png) +![Welcome page](docs/images/welcome-page.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. +Click `[New project]`, and complete the fields in the dialog. Hover over a field's `(?)` for further information about each field. Pickmix merges all the entered 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. +### 2. Check the project and define kinds + +`pickmix` will now open to the Project tab, which contains the three columns described below. ![Project tab](docs/images/project-tab.png) -### 2. Define kinds +#### 2.1 Tomograms +This table lists every tomogram alongside its shape, pixel size and whether a matching denoised volume was identified. Yellow warning indicate usable tomograms with issues to be aware of (e.g. no denoised tomogram found, unusual pixel size), while a red error marks a rejected or unusable tomogram - hover over the error to see the reaon. + +#### 2.2 Kinds +Each 'kind' is a type of pick and multiple can be added 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. A kind with a parent takes its membrane-normal orientation from the nearest parent estimate. -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 dialog](docs/images/kinds-dialog.png) -![Kinds tab](docs/images/kinds-tab.png) +#### 2.3 Settings +This pane controls general settings for the project, including which volume to use in the picking interface and which hotkeys map to which kinds. Parameters for the `ring` estimator can also be adjusted here as required. ### 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. +Open the Pick tab. To the left is a a list of tomograms in the project, clicking a tomogram will jump there as will typing either its name or number into the search bar (the search bar automatically moves to the first match). If needed, the tomogram list can be hidden using the button next to the tomogram title. Select a kind using a hotkey or from the Kinds group, and left-click to add or select a pick. Picks can be moved by selecting & dragging, or deleted using the delete button (or `Delete` key). 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. The settings pane has brightness/contrast controls, including `Auto` (the tomogram's own limits) and `Invert`, as well as auto-zoom functions `Fit` and `Fill`. The views group shows/hides fitted rings, boxes and normals. The pick table beneath shows each pick's estimate status: a green tick, a yellow warning (when flagged), a red error (when error occured during estimation), or a blue spinner (during processing). ![Pick tab](docs/images/pick-tab.png) -### 4. Average +### 4. Review -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. +Open the Review tab. Picks can be filtered by kind, tomogram, minimum SNR and whether they were flagged, with options to delete the selected pick or all currently shown. All picks can be queued for re-estimation here if required as well. Single-click a pick to view XY/XZ/YZ previews, or double-click to open that pick in the Pick tab. -![Average pane](docs/images/average-pane.png) +![Review tab](docs/images/review-tab.png) -### 5. Review and export +### 5. Average 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//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. +Open the Export tab. At the top, the averaging pane allows selection of a kind to build a mean projection and montage of the picks for that kind (optionally filterable by tomogram). The mean and montage images can be saved to `out/` from here, as well as per-tomogram extracted picks if desired. Below, a table summarises the collected data for all kinds and allow specification of a minimum SNR and whether to include flagged picks in exports. Click the checkboxes for the desired output filetype(s) and click `Export` to save. `Extract` alternatively generates a subvolume from each pick and saves as MRC files under `out/particles/`. -![Review tab](docs/images/review-tab.png) +![Export tab](docs/images/export-tab.png) ## Project layout @@ -69,5 +76,5 @@ 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 + out/ picks, estimates, averages, RELION & .csv exports ``` diff --git a/docs/images/average-pane.png b/docs/images/average-pane.png deleted file mode 100644 index b7906d0..0000000 Binary files a/docs/images/average-pane.png and /dev/null differ diff --git a/docs/images/empty-project-tab.png b/docs/images/empty-project-tab.png deleted file mode 100644 index 304f287..0000000 Binary files a/docs/images/empty-project-tab.png and /dev/null differ diff --git a/docs/images/export-tab.png b/docs/images/export-tab.png new file mode 100644 index 0000000..5af1f9d Binary files /dev/null and b/docs/images/export-tab.png differ diff --git a/docs/images/kinds-dialog.png b/docs/images/kinds-dialog.png new file mode 100644 index 0000000..9e2a41c Binary files /dev/null and b/docs/images/kinds-dialog.png differ diff --git a/docs/images/kinds-tab.png b/docs/images/kinds-tab.png deleted file mode 100644 index 82e2935..0000000 Binary files a/docs/images/kinds-tab.png and /dev/null differ diff --git a/docs/images/new-project.png b/docs/images/new-project.png index 98a4a7a..38b4576 100644 Binary files a/docs/images/new-project.png and b/docs/images/new-project.png differ diff --git a/docs/images/pick-tab.png b/docs/images/pick-tab.png index 771a1e6..8b6286b 100644 Binary files a/docs/images/pick-tab.png and b/docs/images/pick-tab.png differ diff --git a/docs/images/project-tab.png b/docs/images/project-tab.png index 0192747..503a1dd 100644 Binary files a/docs/images/project-tab.png and b/docs/images/project-tab.png differ diff --git a/docs/images/review-tab.png b/docs/images/review-tab.png index 604beee..ed7b493 100644 Binary files a/docs/images/review-tab.png and b/docs/images/review-tab.png differ diff --git a/docs/images/welcome-page.png b/docs/images/welcome-page.png new file mode 100644 index 0000000..1f18f4b Binary files /dev/null and b/docs/images/welcome-page.png differ diff --git a/pyproject.toml b/pyproject.toml index 5abb4ee..9663db2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pickmix" -version = "0.2.0" +version = "0.3.0" description = "Multi-kind particle picking GUI for cryo-ET sub-tomogram averaging workflows." readme = "README.md" authors = [ @@ -12,7 +12,7 @@ dependencies = [ "mrcfile>=1.5.4", "numpy>=2.5.4", "pydantic>=2.14.2", - "pyside6>6.9", + "pyside6-essentials>6.9", "scikit-image>=0.26.0", "scipy>=1.18.1", "tomli-w>=1.2.0", diff --git a/src/pickmix/__init__.py b/src/pickmix/__init__.py index 638ae35..cc634dd 100644 --- a/src/pickmix/__init__.py +++ b/src/pickmix/__init__.py @@ -2,6 +2,15 @@ pickmix: entrypoint ''' +# Import external dependencies +from importlib.metadata import PackageNotFoundError, version + +# __version__: installed version, 'unknown' when running from a bare checkout +try: + __version__: str = version('pickmix') +except PackageNotFoundError: + __version__ = 'unknown' + # main: launch the window, opening the config.toml given as the first argument def main() -> None: import sys diff --git a/src/pickmix/average.py b/src/pickmix/average.py index 4ce33d7..1ed6991 100644 --- a/src/pickmix/average.py +++ b/src/pickmix/average.py @@ -18,6 +18,9 @@ # _MONTAGE_MAX: most boxes shown in the montage, spread evenly over the picks _MONTAGE_MAX: int = 64 +# MONTAGE_PAD: pixels of padding around every cell of the montage +MONTAGE_PAD: int = 2 + # AverageResult: normalised 2D images, ready to show; overall and per-tomogram means, and the montage @dataclass(frozen=True) class AverageResult: @@ -28,8 +31,15 @@ class AverageResult: montage: np.ndarray per_tomogram: dict[str, np.ndarray] folder: Path + # montage geometry and label + grid: int + pad: int + labels: list[str] + # boxes averaged per tomogram + counts: dict[str, int] + -# 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// +# average_kind: cut a box at every item, project the central slab of each along z, normalise it, and average per tomogram and overall; nothing is written until save_average def average_kind( name: str, kind: Kind, items: list[Item], tomos: dict[str, Tomogram], config: Config, *, log: Callable[[str], None] = lambda _: None, @@ -42,6 +52,7 @@ def average_kind( half = min(max(round(kind.marker_a / 2 / px), 1), box // 2) centre = box // 2 images: dict[str, list[np.ndarray]] = {} + origins: list[str] = [] for tomogram in dict.fromkeys(p.tomogram for p, _ in items): vol = load_volume(tomos[tomogram], source=config.source) for p, f in items: @@ -50,25 +61,39 @@ def average_kind( 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)) + origins.append(f'{len(images[tomogram])} ({tomogram})') 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, + 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), + montage(np.stack([everything[i] for i in chosen]), grid_shape=(grid, grid), padding_width=MONTAGE_PAD), {t: np.mean(imgs, axis=0) for t, imgs in images.items()}, config.out_dir / 'average' / name, + grid, + MONTAGE_PAD, + [origins[i] for i in chosen], + {t: len(imgs) for t, imgs in images.items()}, ) + return result + +# save_average: clear the kind's old PNGs and write the overall mean and montage, and each tomogram's mean when asked; returns the paths written +def save_average(result: AverageResult, *, per_tomogram: bool = False) -> list[Path]: 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 + written = [result.folder / 'overall_mean.png', result.folder / 'overall_montage.png'] + _save(written[0], result.overall) + _save(written[1], result.montage) + if per_tomogram: + for tomogram, img in result.per_tomogram.items(): + written.append(result.folder / f'{tomogram}_mean.png') + _save(written[-1], img) + return written # _save: write an image stretched between its 1st and 99th percentiles as an 8-bit PNG def _save(path: Path, arr: np.ndarray) -> None: diff --git a/src/pickmix/config.py b/src/pickmix/config.py index 9fff7aa..16822e7 100644 --- a/src/pickmix/config.py +++ b/src/pickmix/config.py @@ -57,6 +57,8 @@ def _default_kinds() -> dict[str, Kind]: # Config: validated project settings class Config(BaseModel): + # project name (shown on Project tab and in window title, None falls back to directory name) + name: str | None = None # tomogram locations; denoised_dir None means raw only raw_dir: Path denoised_dir: Path | None = None @@ -79,6 +81,12 @@ def replace(self, **changes: object) -> 'Config': changes = {**changes, 'hotkeys': []} return Config.model_validate({**self.model_dump(), **changes}) + # _blank_name: a blank name is no name + @field_validator('name') + @classmethod + def _blank_name(cls, name: str | None) -> str | None: + return (name or '').strip() or None + # _names: kind names become folder names, so keep them plain @field_validator('kinds') @classmethod diff --git a/src/pickmix/export.py b/src/pickmix/export.py index 44deab5..327959e 100644 --- a/src/pickmix/export.py +++ b/src/pickmix/export.py @@ -3,7 +3,7 @@ ''' # Import external dependencies -import numpy as np +import csv, numpy as np from dataclasses import dataclass from pathlib import Path @@ -31,6 +31,15 @@ class KindSummary: box_px: int folder: Path +# CsvSummary: what a csv export of one kind wrote +@dataclass(frozen=True) +class CsvSummary: + kind: str + written: int + below_snr: int + excluded_flagged: int + path: Path + # recommend_box: (box edge in Angstrom, in pixels at the kind's extraction pixel size rounded up to even); the kind's own box if set, else a percentile of the fits def recommend_box(kind: Kind, items: list[Item], *, percentile: float = 95.0) -> tuple[float, int]: if kind.box_a > 0: @@ -80,3 +89,24 @@ def export_kind( write_star_loop(folder / f'{tomogram}_coords.star', 'particles', columns, [r for r in rows if r[0] == tomogram]) box_a, box_px = recommend_box(kind, kept, percentile=percentile) return KindSummary(name, len(kept), below, dropped, unmatched, box_a, box_px, folder) + +# export_csv: write out_dir/csv/.csv, one row per pick passing the SNR and flag settings, with the pick and fitted centres in voxels, the fit's ring radius and box in Angstrom, SNR and flags +def export_csv( + name: str, items: list[Item], out_dir: Path, *, min_snr: float = 0.0, include_flagged: bool = True, +) -> CsvSummary: + kept = [(p, f) for p, f in items if f.snr >= min_snr and (include_flagged or not f.flag)] + below = sum(f.snr < min_snr for _, f in items) + excluded = 0 if include_flagged else sum(bool(f.flag) for _, f in items if f.snr >= min_snr) + if not kept: + raise ValueError(f'kind {name!r}: no picks left after filtering') + path = out_dir / 'csv' / f'{name}.csv' + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'w', newline='') as handle: + writer = csv.writer(handle) + writer.writerow(['tomogram', 'kind', 'pick_x', 'pick_y', 'pick_z', 'fit_x', 'fit_y', 'fit_z', 'radius_a', 'box_a', 'snr', 'flag']) + for p, f in kept: + writer.writerow([ + p.tomogram, p.kind, f'{p.x:.2f}', f'{p.y:.2f}', f'{p.z:.2f}', f'{f.x:.2f}', f'{f.y:.2f}', f'{f.z:.2f}', + f'{f.radius_a:.2f}', f'{f.box_a:.2f}', f'{f.snr:.3f}', f.flag, + ]) + return CsvSummary(name, len(kept), below, excluded, path) diff --git a/src/pickmix/gui/average_pane.py b/src/pickmix/gui/average_pane.py deleted file mode 100644 index 94b37f9..0000000 --- a/src/pickmix/gui/average_pane.py +++ /dev/null @@ -1,137 +0,0 @@ -''' -pickmix: Average pane of the Kinds tab -''' - -# 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 - -# _SIZE: edge in pixels of each image shown -_SIZE: int = 300 - -# AveragePane: choose a kind and tomograms, run, and look at the overall mean, the montage and each tomogram's mean -class AveragePane(QGroupBox): - # __init__: build the layout - def __init__(self) -> None: - super().__init__('Average') - self.session: Session | None = None - self.service: EstimateService | None = None - self.result: AverageResult | None = None - self.kind_box = QComboBox() - self.summary = QLabel() - self.run_button = QPushButton('Run') - self.status = QLabel() - self.mean_label = QLabel('mean') - self.montage_label = QLabel('montage') - for label in (self.mean_label, self.montage_label): - label.setFixedSize(_SIZE, _SIZE) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - label.setStyleSheet('background: #141414; color: #888') - self.results_list = QListWidget() - self.results_list.setMaximumWidth(220) - # 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) - - # set_session: use a session's picks and the service's fits, or none - def set_session(self, session: Session | None, service: EstimateService | None) -> None: - self.session, self.service, self.result = session, service, None - self.refresh_kinds() - - # refresh_kinds: list the kinds of the config - def refresh_kinds(self) -> None: - current = self.kind_box.currentText() - self.kind_box.blockSignals(True) - self.kind_box.clear() - if self.session is not None: - self.kind_box.addItems(list(self.session.project.config.kinds)) - self.kind_box.setCurrentText(current) - self.kind_box.blockSignals(False) - self._update_summary() - - # 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 [] - out = [] - for pick in self.session.store.all(self.kind_box.currentText()): - fit = self.service.store.get(pick) - 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') - return - config, tomos = self.session.project.config, self.session.project.tomos - self.run_button.setEnabled(False) - 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) - - # _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 - 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: - self.result = result - self.run_button.setEnabled(True) - self.status.setText(f'{result.count} boxes of {result.box_px} px, PNGs in {str(result.folder)!r}') - self.montage_label.setPixmap(to_pixmap(result.montage, _SIZE)) - self.results_list.clear() - self.results_list.addItems(['overall', *result.per_tomogram]) - self.results_list.setCurrentRow(0) - - # _failed: show an error from the background run - def _failed(self, message: str) -> None: - self.run_button.setEnabled(True) - self.status.setText(message) - - # _show_row: show the overall mean or one tomogram's mean - def _show_row(self, row: int) -> None: - if self.result is None or row < 0: - return - name = self.results_list.item(row).text() - self.mean_label.setPixmap(to_pixmap(self.result.overall if name == 'overall' else self.result.per_tomogram[name], _SIZE)) diff --git a/src/pickmix/gui/icons/LICENSE b/src/pickmix/gui/components/assets/LICENSE similarity index 100% rename from src/pickmix/gui/icons/LICENSE rename to src/pickmix/gui/components/assets/LICENSE diff --git a/src/pickmix/gui/icons/add.svg b/src/pickmix/gui/components/assets/add.svg similarity index 100% rename from src/pickmix/gui/icons/add.svg rename to src/pickmix/gui/components/assets/add.svg diff --git a/src/pickmix/gui/components/assets/cached.svg b/src/pickmix/gui/components/assets/cached.svg new file mode 100644 index 0000000..7a8c813 --- /dev/null +++ b/src/pickmix/gui/components/assets/cached.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/check.svg b/src/pickmix/gui/components/assets/check.svg new file mode 100644 index 0000000..620f0fe --- /dev/null +++ b/src/pickmix/gui/components/assets/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/close.svg b/src/pickmix/gui/components/assets/close.svg new file mode 100644 index 0000000..8030372 --- /dev/null +++ b/src/pickmix/gui/components/assets/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/create_new_folder.svg b/src/pickmix/gui/components/assets/create_new_folder.svg new file mode 100644 index 0000000..c12f617 --- /dev/null +++ b/src/pickmix/gui/components/assets/create_new_folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/crop.svg b/src/pickmix/gui/components/assets/crop.svg new file mode 100644 index 0000000..b9854c8 --- /dev/null +++ b/src/pickmix/gui/components/assets/crop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/delete.svg b/src/pickmix/gui/components/assets/delete.svg new file mode 100644 index 0000000..4bd5da9 --- /dev/null +++ b/src/pickmix/gui/components/assets/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/download.svg b/src/pickmix/gui/components/assets/download.svg new file mode 100644 index 0000000..cdc27b6 --- /dev/null +++ b/src/pickmix/gui/components/assets/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/edit.svg b/src/pickmix/gui/components/assets/edit.svg similarity index 100% rename from src/pickmix/gui/icons/edit.svg rename to src/pickmix/gui/components/assets/edit.svg diff --git a/src/pickmix/gui/components/assets/error.svg b/src/pickmix/gui/components/assets/error.svg new file mode 100644 index 0000000..f22c2d7 --- /dev/null +++ b/src/pickmix/gui/components/assets/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/fit_screen.svg b/src/pickmix/gui/components/assets/fit_screen.svg new file mode 100644 index 0000000..b06fd77 --- /dev/null +++ b/src/pickmix/gui/components/assets/fit_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/folder_open.svg b/src/pickmix/gui/components/assets/folder_open.svg new file mode 100644 index 0000000..56a1184 --- /dev/null +++ b/src/pickmix/gui/components/assets/folder_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/fullscreen.svg b/src/pickmix/gui/components/assets/fullscreen.svg new file mode 100644 index 0000000..a2c705e --- /dev/null +++ b/src/pickmix/gui/components/assets/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/help.svg b/src/pickmix/gui/components/assets/help.svg similarity index 100% rename from src/pickmix/gui/icons/help.svg rename to src/pickmix/gui/components/assets/help.svg diff --git a/src/pickmix/gui/components/assets/left_panel_close.svg b/src/pickmix/gui/components/assets/left_panel_close.svg new file mode 100644 index 0000000..a965184 --- /dev/null +++ b/src/pickmix/gui/components/assets/left_panel_close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/left_panel_open.svg b/src/pickmix/gui/components/assets/left_panel_open.svg new file mode 100644 index 0000000..edb2941 --- /dev/null +++ b/src/pickmix/gui/components/assets/left_panel_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/logout.svg b/src/pickmix/gui/components/assets/logout.svg new file mode 100644 index 0000000..7dcc136 --- /dev/null +++ b/src/pickmix/gui/components/assets/logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/components/assets/refresh.svg b/src/pickmix/gui/components/assets/refresh.svg new file mode 100644 index 0000000..633c3fe --- /dev/null +++ b/src/pickmix/gui/components/assets/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/visibility.svg b/src/pickmix/gui/components/assets/visibility.svg similarity index 100% rename from src/pickmix/gui/icons/visibility.svg rename to src/pickmix/gui/components/assets/visibility.svg diff --git a/src/pickmix/gui/icons/visibility_off.svg b/src/pickmix/gui/components/assets/visibility_off.svg similarity index 100% rename from src/pickmix/gui/icons/visibility_off.svg rename to src/pickmix/gui/components/assets/visibility_off.svg diff --git a/src/pickmix/gui/components/assets/warning.svg b/src/pickmix/gui/components/assets/warning.svg new file mode 100644 index 0000000..cdd712a --- /dev/null +++ b/src/pickmix/gui/components/assets/warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/canvas.py b/src/pickmix/gui/components/canvas.py similarity index 97% rename from src/pickmix/gui/canvas.py rename to src/pickmix/gui/components/canvas.py index 4fe512a..2a023f0 100644 --- a/src/pickmix/gui/canvas.py +++ b/src/pickmix/gui/components/canvas.py @@ -9,6 +9,9 @@ from PySide6.QtGui import QColor, QImage, QMouseEvent, QPainter, QPen, QPixmap, QWheelEvent from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsRectItem, QGraphicsScene, QGraphicsView +# Import internal pickmix objects +from pickmix.gui.style import WELL_BG + # _HIT_PX: screen distance from a marker centre that counts as clicking it _HIT_PX: float = 6.0 @@ -78,7 +81,7 @@ def __init__(self) -> None: self._wheel = 0 self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.setRenderHint(QPainter.RenderHint.Antialiasing) - self.setBackgroundBrush(QColor(20, 20, 20)) + self.setBackgroundBrush(QColor(WELL_BG)) self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) # has_image: whether a slice is showing @@ -180,6 +183,10 @@ def clear(self) -> None: def fit(self) -> None: self.fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + # fill: zoom to cover whole canvas + def fill(self) -> None: + self.fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatioByExpanding) + # mousePressEvent: pan, select or drag a marker, or request a new pick def mousePressEvent(self, event: QMouseEvent) -> None: pos = self.mapToScene(event.position().toPoint()) diff --git a/src/pickmix/gui/collapsible.py b/src/pickmix/gui/components/collapsible.py similarity index 100% rename from src/pickmix/gui/collapsible.py rename to src/pickmix/gui/components/collapsible.py diff --git a/src/pickmix/gui/components/icons.py b/src/pickmix/gui/components/icons.py new file mode 100644 index 0000000..7f1ede6 --- /dev/null +++ b/src/pickmix/gui/components/icons.py @@ -0,0 +1,28 @@ +''' +pickmix: bundled icon loading +''' + +# Import external dependencies +from pathlib import Path +from PySide6.QtGui import QColor, QIcon, QPainter + +# ICON_DIR: bundled icon files +ICON_DIR: Path = Path(__file__).parent / 'assets' + +# icon: a bundled icon by file stem, or None when its file is not there so the caller can fall back to text +def icon(stem: str) -> QIcon | None: + path = ICON_DIR / f'{stem}.svg' + return QIcon(str(path)) if path.exists() else None + +# tinted: a bundled icon redrawn in one colour at a fixed size (or None when its file is not there) +def tinted(stem: str, colour: str, size: int = 16) -> QIcon | None: + base = icon(stem) + if base is None: + return None + pixmap = base.pixmap(size, size) + painter = QPainter(pixmap) + # keep the glyph's shape, replace its colour + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) + painter.fillRect(pixmap.rect(), QColor(colour)) + painter.end() + return QIcon(pixmap) diff --git a/src/pickmix/gui/components/images.py b/src/pickmix/gui/components/images.py new file mode 100644 index 0000000..825a18c --- /dev/null +++ b/src/pickmix/gui/components/images.py @@ -0,0 +1,123 @@ +''' +pickmix: turning arrays into images and pixmaps for gui +''' + +# Import external dependencies +import math, numpy as np +from collections.abc import Callable +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QColor, QImage, QPainter, QPixmap +from PySide6.QtWidgets import QLabel, QSizePolicy + +# Import internal pickmix objects +from pickmix.gui.style import LABEL_BG, OVERLAY_TEXT, WELL_BG + +# TILE_GAP: pixels between the cells of a montage laid out again to fit a pane +TILE_GAP: int = 2 + +# FitLabel: a label that draws its image to fill all the room it has, again whenever it is resized +class FitLabel(QLabel): + # __init__: expands to fill its space, never smaller than minimum pixels a side + def __init__(self, text: str = '', minimum: int = 240) -> None: + super().__init__(text) + self._render: Callable[[int, int], QPixmap] | None = None + self._preferred = minimum + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setMinimumSize(minimum, minimum) + + # sizeHint: the smallest size it is happy with, so the layout hands it any spare room + def sizeHint(self) -> QSize: + return QSize(self._preferred, self._preferred) + + # set_render: how to draw the image at a given width and height in pixels, or None to show the text instead + def set_render(self, render: Callable[[int, int], QPixmap] | None) -> None: + self._render = render + self._redraw() + + # resizeEvent: draw the image again at the new size + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._redraw() + + # _redraw: the image at the label's own size + def _redraw(self) -> None: + if self._render is not None and self.width() > 0 and self.height() > 0: + self.setPixmap(self._render(self.width(), self.height())) + +# to_image: a 2D array stretched between its 1st and 99th percentiles as an 8-bit grey image +def to_image(arr: np.ndarray) -> QImage: + lo, hi = np.percentile(arr, (1, 99)) + img = np.ascontiguousarray(np.clip((arr - lo) * (255.0 / max(hi - lo, 1e-6)), 0, 255).astype(np.uint8)) + h, w = img.shape + # copy so the image owns its pixels once the array is gone + return QImage(img.data, w, h, w, QImage.Format.Format_Grayscale8).copy() + +# to_pixmap: a 2D array stretched between its 1st and 99th percentiles and scaled to fit a square of the given edge +def to_pixmap(arr: np.ndarray, size: int) -> QPixmap: + pixmap = QPixmap.fromImage(to_image(arr)) + return pixmap.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + +# label_cells: a copy of a montage image with a small translucent box of text in the corner of each cell; cells are grid x grid, row by row, each pad pixels apart (times scale when the image has been resized) +def label_cells(image: QImage, labels: list[str], grid: int, pad: int, *, scale: float = 1.0) -> QImage: + out = image.convertToFormat(QImage.Format.Format_RGB32) + gap = pad * scale + cell = (out.width() - gap) / grid - gap + painter = QPainter(out) + font = painter.font() + font.setPixelSize(max(6, int(cell / 8))) + painter.setFont(font) + for i, text in enumerate(labels): + row, col = divmod(i, grid) + _draw_label(painter, int(gap + col * (cell + gap)), int(gap + row * (cell + gap)), text, int(cell)) + painter.end() + return out + +# tile_cells: the cells of a square montage laid out again in the number of columns that makes them biggest in a width x height area, each labelled; the block is centred on a dark background +def tile_cells(image: QImage, labels: list[str], grid: int, pad: int, width: int, height: int) -> QImage: + count = len(labels) + # side of one cell in the source image + box = (image.width() - pad) / grid - pad + def edge(cols: int) -> float: + rows = math.ceil(count / cols) + return min((width - TILE_GAP * (cols + 1)) / cols, (height - TILE_GAP * (rows + 1)) / rows) + cols = max(range(1, count + 1), key=edge) + rows, side = math.ceil(count / cols), max(edge(cols), 1.0) + out = QImage(width, height, QImage.Format.Format_RGB32) + out.fill(QColor(WELL_BG)) + left = (width - cols * side - (cols + 1) * TILE_GAP) / 2 + top = (height - rows * side - (rows + 1) * TILE_GAP) / 2 + painter = QPainter(out) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + font = painter.font() + font.setPixelSize(max(6, int(side / 8))) + painter.setFont(font) + for i, text in enumerate(labels): + row, col = divmod(i, cols) + src_row, src_col = divmod(i, grid) + source = QRect(round(pad + src_col * (box + pad)), round(pad + src_row * (box + pad)), round(box), round(box)) + x, y = round(left + TILE_GAP + col * (side + TILE_GAP)), round(top + TILE_GAP + row * (side + TILE_GAP)) + painter.drawImage(QRect(x, y, round(side), round(side)), image, source) + _draw_label(painter, x, y, text, round(side)) + painter.end() + return out + +# label_image: a copy of a pixmap with a label in its top-left corner +def label_image(pixmap: QPixmap, text: str) -> QPixmap: + out = QPixmap(pixmap) + painter = QPainter(out) + font = painter.font() + font.setPixelSize(max(10, out.height() // 16)) + painter.setFont(font) + _draw_label(painter, 4, 4, text, out.width() - 8) + painter.end() + return out + +# _draw_label: a translucent dark box with white text at a corner position, no wider than max_width (text elided on the right) +def _draw_label(painter: QPainter, x: int, y: int, text: str, max_width: int) -> None: + metrics = painter.fontMetrics() + shown = metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(max_width - 6, 1)) + box = QRect(x, y, min(metrics.horizontalAdvance(shown) + 6, max_width), metrics.height() + 2) + painter.fillRect(box, LABEL_BG) + painter.setPen(OVERLAY_TEXT) + painter.drawText(box, Qt.AlignmentFlag.AlignCenter, shown) diff --git a/src/pickmix/gui/components/path_field.py b/src/pickmix/gui/components/path_field.py new file mode 100644 index 0000000..ed9d548 --- /dev/null +++ b/src/pickmix/gui/components/path_field.py @@ -0,0 +1,29 @@ +''' +pickmix: a directory chooser made of a line edit and a browse button +''' + +# Import external dependencies +from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLineEdit, QPushButton, QWidget + +# PathField: a line edit with a browse button for a directory +class PathField(QWidget): + # __init__: build the field + def __init__(self) -> None: + super().__init__() + self.edit = QLineEdit() + button = QPushButton('Browse...') + button.clicked.connect(self._browse) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.edit) + layout.addWidget(button) + + # text: the entered path, stripped + def text(self) -> str: + return self.edit.text().strip() + + # _browse: pick a directory + def _browse(self) -> None: + chosen = QFileDialog.getExistingDirectory(self, 'Choose directory', self.text()) + if chosen: + self.edit.setText(chosen) diff --git a/src/pickmix/gui/components/range_slider.py b/src/pickmix/gui/components/range_slider.py new file mode 100644 index 0000000..f393c4f --- /dev/null +++ b/src/pickmix/gui/components/range_slider.py @@ -0,0 +1,116 @@ +''' +pickmix: slider with two handles for a low and a high value +''' + +# Import external dependencies +from typing import Literal +from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtGui import QKeyEvent, QMouseEvent, QPainter, QPaintEvent, QPen +from PySide6.QtWidgets import QSizePolicy, QWidget + +# _HANDLE: radius in pixels of a handle +_HANDLE: int = 7 + +# RangeSlider: integer low and high between a minimum and maximum, low never above high +class RangeSlider(QWidget): + # valueChanged: low or high changed through the user or set_values(emit=True) + valueChanged = Signal(int, int) + + # __init__: both handles at the ends + def __init__(self, minimum: int = 0, maximum: int = 1000) -> None: + super().__init__() + self._min, self._max = minimum, maximum + self._low, self._high = minimum, maximum + self._active: Literal['low', 'high'] = 'low' + self._dragging: Literal['low', 'high'] | None = None + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setMinimumHeight(2 * _HANDLE + 6) + + # sizeHint: as wide as a normal slider and as tall as the handles + def sizeHint(self) -> QSize: + return QSize(120, 2 * _HANDLE + 6) + + # set_range: new ends; the values are pulled inside + def set_range(self, minimum: int, maximum: int) -> None: + self._min, self._max = minimum, maximum + self.set_values(self._low, self._high) + + # values: (low, high) + def values(self) -> tuple[int, int]: + return self._low, self._high + + # set_values: move both handles, clamped; announces the change only when emit is set + def set_values(self, low: int, high: int, *, emit: bool = False) -> None: + low = min(max(low, self._min), self._max) + high = min(max(high, low), self._max) + changed = (low, high) != (self._low, self._high) + self._low, self._high = low, high + self.update() + if changed and emit: + self.valueChanged.emit(low, high) + + # keyPressEvent: Left and Right move the active handle, Up and Down switch handle, PageUp and PageDown move by a tenth + def keyPressEvent(self, event: QKeyEvent) -> None: + small, large = max(1, (self._max - self._min) // 100), max(1, (self._max - self._min) // 10) + steps = {Qt.Key.Key_Left: -small, Qt.Key.Key_Right: small, Qt.Key.Key_PageDown: -large, Qt.Key.Key_PageUp: large} + if event.key() in steps: + self._move(self._active, (self._low if self._active == 'low' else self._high) + steps[event.key()]) + elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): + self._active = 'high' if self._active == 'low' else 'low' + self.update() + else: + super().keyPressEvent(event) + + # mousePressEvent: grab the nearest handle (the high one when they overlap and the press is to the right) + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() != Qt.MouseButton.LeftButton: + return + x = event.position().x() + low_gap, high_gap = abs(x - self._x(self._low)), abs(x - self._x(self._high)) + self._dragging = self._active = 'low' if low_gap < high_gap or (low_gap == high_gap and x < self._x(self._low)) else 'high' + self.setFocus() + self._move(self._dragging, self._value_at(x)) + + # mouseMoveEvent: drag the grabbed handle + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if self._dragging is not None: + self._move(self._dragging, self._value_at(event.position().x())) + + # mouseReleaseEvent: let go + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + self._dragging = None + + # paintEvent: groove, the span between the handles, then the handles + def paintEvent(self, event: QPaintEvent) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + palette, mid = self.palette(), self.height() / 2 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(palette.mid()) + painter.drawRoundedRect(QRectF(_HANDLE, mid - 2, self.width() - 2 * _HANDLE, 4), 2, 2) + painter.setBrush(palette.highlight()) + painter.drawRoundedRect(QRectF(self._x(self._low), mid - 2, self._x(self._high) - self._x(self._low), 4), 2, 2) + for which, value in (('low', self._low), ('high', self._high)): + focused = self.hasFocus() and which == self._active + # solid, the same colour as the span; a light ring marks the handle the keys move + painter.setPen(QPen(palette.light().color(), 2) if focused else Qt.PenStyle.NoPen) + painter.setBrush(palette.highlight()) + painter.drawEllipse(QPointF(self._x(value), mid), _HANDLE - 1, _HANDLE - 1) + + # _x: pixel position of a value + def _x(self, value: int) -> float: + span = max(self._max - self._min, 1) + return _HANDLE + (value - self._min) / span * (self.width() - 2 * _HANDLE) + + # _value_at: value under a pixel position + def _value_at(self, x: float) -> int: + span = max(self._max - self._min, 1) + return round(self._min + (x - _HANDLE) / max(self.width() - 2 * _HANDLE, 1) * span) + + # _move: set one handle, never past the other + def _move(self, which: Literal['low', 'high'], value: int) -> None: + if which == 'low': + self.set_values(min(value, self._high), self._high, emit=True) + else: + self.set_values(self._low, max(value, self._low), emit=True) diff --git a/src/pickmix/gui/components/status.py b/src/pickmix/gui/components/status.py new file mode 100644 index 0000000..a86830e --- /dev/null +++ b/src/pickmix/gui/components/status.py @@ -0,0 +1,104 @@ +''' +pickmix: status icons and badges +''' + +# Import external dependencies +from typing import Literal +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QIcon, QPainter, QPixmap +from PySide6.QtWidgets import QLabel, QTableWidgetItem + +# Import internal pickmix objects +from pickmix.gui.components.icons import tinted +from pickmix.gui.style import ERROR, OK, RUNNING, WARN + +# Status: ok is a green tick, warn a yellow warning sign, error a red error sign, running a blue spinner +type Status = Literal['ok', 'warn', 'error', 'running'] + +# STATUS_STYLE: icon stem, colour and the text used when the icon file is missing +STATUS_STYLE: dict[Status, tuple[str, str, str]] = { + 'ok': ('check', OK, '✓'), + 'warn': ('warning', WARN, '!'), + 'error': ('error', ERROR, '✖'), + 'running': ('refresh', RUNNING, '…'), +} + +# _SIZE: edge in pixels of a status icon +_SIZE: int = 16 + +# _FRAME_MS, _FRAME_DEG: time between spinner frames and the turn each one makes +_FRAME_MS: int = 80 +_FRAME_DEG: int = 30 + +# status_icon: the icon of a status, or None without its file +def status_icon(status: Status) -> QIcon | None: + stem, colour, _ = STATUS_STYLE[status] + return tinted(stem, colour, _SIZE) + +# status_item: a centred table cell showing a status, empty for None +def status_item(status: Status | None, tip: str = '') -> QTableWidgetItem: + item = QTableWidgetItem() + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + if status is not None: + found = status_icon(status) + if found is None: + item.setText(STATUS_STYLE[status][2]) + else: + item.setIcon(found) + item.setToolTip(tip) + return item + +# fit_status: running while there is no current fit, error for an error flag, warn for any other flag, else ok +def fit_status(state: str, flag: str) -> Status: + if state != 'ok': + return 'running' + flags = set(filter(None, flag.split(','))) + if 'error' in flags: + return 'error' + return 'warn' if flags else 'ok' + +# StatusBadge: one status as a label; running turns the icon, so it suits single widgets and short tables +class StatusBadge(QLabel): + # __init__: start empty + def __init__(self, status: Status | None = None, tip: str = '') -> None: + super().__init__() + self.status: Status | None = None + self._base: QPixmap | None = None + self._angle = 0 + self._timer = QTimer(self) + self._timer.setInterval(_FRAME_MS) + self._timer.timeout.connect(self._turn) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.set_status(status, tip) + + # set_status: show a status, or nothing for None + def set_status(self, status: Status | None, tip: str = '') -> None: + self.status = status + self._timer.stop() + self.setToolTip(tip) + self.clear() + if status is None: + return + found = status_icon(status) + if found is None: + self.setText(STATUS_STYLE[status][2]) + return + self._base = found.pixmap(_SIZE, _SIZE) + self.setPixmap(self._base) + if status == 'running': + self._timer.start() + + # _turn: draw the next spinner frame + def _turn(self) -> None: + assert self._base is not None + self._angle = (self._angle + _FRAME_DEG) % 360 + frame = QPixmap(self._base.size()) + frame.fill(Qt.GlobalColor.transparent) + painter = QPainter(frame) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.translate(frame.width() / 2, frame.height() / 2) + painter.rotate(self._angle) + painter.translate(-frame.width() / 2, -frame.height() / 2) + painter.drawPixmap(0, 0, self._base) + painter.end() + self.setPixmap(frame) diff --git a/src/pickmix/gui/components/toast.py b/src/pickmix/gui/components/toast.py new file mode 100644 index 0000000..e5562cd --- /dev/null +++ b/src/pickmix/gui/components/toast.py @@ -0,0 +1,65 @@ +''' +pickmix: short-lived messages over the main window, also printed to the terminal +''' + +# Import external dependencies +import html +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QLabel, QWidget +from shiboken6 import isValid + +# Import internal pickmix objects +from pickmix.gui.style import OK, toast_look + +# _SHOWN_LINES: most lines of a message the toast shows; stdout always gets all of it +_SHOWN_LINES: int = 3 + +# _MARGIN: gap in pixels between the toast and the bottom of the window +_MARGIN: int = 24 + +# Toast: a dark rounded label near the bottom of its parent that hides itself +class Toast(QLabel): + # __init__: hidden until a message is shown + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + toast_look(self) + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self.hide) + self.hide() + + # show_message: show a message for ms milliseconds, centred near the bottom of the parent; a saved message starts with a green tick + def show_message(self, message: str, ms: int = 4000, *, saved: bool = False) -> None: + if saved: + # rich text only to colour the tick, so the message itself is escaped + self.setTextFormat(Qt.TextFormat.RichText) + self.setText(f'✓  {html.escape(message).replace(chr(10), "
")}') + else: + self.setTextFormat(Qt.TextFormat.PlainText) + self.setText(message) + self.adjustSize() + parent = self.parentWidget() + self.move((parent.width() - self.width()) // 2, parent.height() - self.height() - _MARGIN) + self.raise_() + self.show() + self._timer.start(ms) + +# _toast: the toast of the main window, set by install +_toast: Toast | None = None + +# install: make a toast on a window; notify shows its messages there +def install(window: QWidget) -> Toast: + global _toast + _toast = Toast(window) + return _toast + +# notify: print a message to stdout and show its first lines in the toast, if one is installed; saved puts a green tick before it +def notify(message: str, *, saved: bool = False) -> None: + print(message, flush=True) + if _toast is None or not isValid(_toast): + return + lines = message.splitlines() or [''] + shown = lines[:_SHOWN_LINES] + if len(lines) > _SHOWN_LINES: + shown.append('...') + _toast.show_message('\n'.join(shown), saved=saved) diff --git a/src/pickmix/gui/kind_dialog.py b/src/pickmix/gui/dialogs/kind_dialog.py similarity index 97% rename from src/pickmix/gui/kind_dialog.py rename to src/pickmix/gui/dialogs/kind_dialog.py index 0622627..4c67f89 100644 --- a/src/pickmix/gui/kind_dialog.py +++ b/src/pickmix/gui/dialogs/kind_dialog.py @@ -8,14 +8,14 @@ from pydantic import ValidationError from PySide6.QtGui import QColor from PySide6.QtWidgets import ( - QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, + QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, ) # Import internal pickmix objects from pickmix.config import Kind from pickmix.estimate.registry import ESTIMATORS -from pickmix.gui.style import form_label, tidy +from pickmix.gui.style import error_look, form_label, tidy # DELETED: result code of the dialog when the user chose to delete the kind DELETED: int = 2 @@ -63,7 +63,7 @@ def __init__( self.tilt_box.toggled.connect(self.tilt_spin.setEnabled) self.angpix_spin = _spin(kind.extract_angpix, minimum=0.01) self.error = QLabel() - self.error.setStyleSheet('color: #b00020') + error_look(self.error) self.error.setWordWrap(True) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self.accept) diff --git a/src/pickmix/gui/new_project_dialog.py b/src/pickmix/gui/dialogs/new_project_dialog.py similarity index 67% rename from src/pickmix/gui/new_project_dialog.py rename to src/pickmix/gui/dialogs/new_project_dialog.py index dde7f6d..47b3a6a 100644 --- a/src/pickmix/gui/new_project_dialog.py +++ b/src/pickmix/gui/dialogs/new_project_dialog.py @@ -5,35 +5,13 @@ # Import external dependencies from pathlib import Path from PySide6.QtWidgets import ( - QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, - QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) # Import internal pickmix objects -from pickmix.gui.style import tidy - -# PathField: a line edit with a browse button for a directory -class PathField(QWidget): - # __init__: build the field - def __init__(self) -> None: - super().__init__() - self.edit = QLineEdit() - button = QPushButton('Browse...') - button.clicked.connect(self._browse) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.edit) - layout.addWidget(button) - - # text: the entered path, stripped - def text(self) -> str: - return self.edit.text().strip() - - # _browse: pick a directory - def _browse(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, 'Choose directory', self.text()) - if chosen: - self.edit.setText(chosen) +from pickmix.gui.components.path_field import PathField +from pickmix.gui.style import error_look, form_label, quiet_table, tidy # NewProjectDialog: runs table plus directories; values() is the argument set for create_project class NewProjectDialog(QDialog): @@ -42,28 +20,36 @@ def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setWindowTitle('New project') self.project_dir = PathField() + self.name = QLineEdit() + self.name.setPlaceholderText('project directory name') + self.project_dir.edit.textChanged.connect(self._suggest_name) self.raw_dir = PathField() self.denoised_dir = PathField() self.denoised_suffix = QLineEdit('.denoised') self.tomo_job = QLineEdit('Tomograms/job006') self.runs = QTableWidget(0, 2) self.runs.setHorizontalHeaderLabels(['Run', 'RELION project root']) + quiet_table(self.runs, editable=True) self.runs.horizontalHeader().setStretchLastSection(True) + self.runs.setToolTip('One row per RELION run to merge: a name for the run and the RELION project root it lives in.') self.error = QLabel() - self.error.setStyleSheet('color: #b00020') + error_look(self.error) add = QPushButton('Add run...') add.clicked.connect(self._add_dialog) + add.setToolTip('Choose a RELION project root to add as a run.') remove = QPushButton('Remove selected') remove.clicked.connect(self._remove) + remove.setToolTip('Remove the selected run(s) from the list.') buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) form = QFormLayout() - form.addRow('Project directory', self.project_dir) - form.addRow('Tomogram job (in each root)', self.tomo_job) - form.addRow('Raw tomograms', self.raw_dir) - form.addRow('Denoised tomograms (optional)', self.denoised_dir) - form.addRow('Denoised suffix', self.denoised_suffix) + form.addRow(form_label('Name', 'Shown on the Project tab and in the window title. Left empty, the project directory name is used.'), self.name) + form.addRow(form_label('Project directory', 'Directory that will hold config.toml and the merged tomograms.star. It must not already contain either.'), self.project_dir) + form.addRow(form_label('Tomogram job', 'Path of the tomogram job inside each RELION project root, for example Tomograms/job006.'), self.tomo_job) + form.addRow(form_label('Raw tomograms', 'Directory holding the raw reconstructed tomograms (MRC files).'), self.raw_dir) + form.addRow(form_label('Denoised tomograms', 'Optional directory of denoised tomograms. Leave empty to work from the raw ones only.'), self.denoised_dir) + form.addRow(form_label('Denoised suffix', 'Text removed from denoised file names so they pair with the raw ones, for example .denoised.'), self.denoised_suffix) row = QHBoxLayout() row.addWidget(add) row.addWidget(remove) @@ -99,6 +85,7 @@ def values(self) -> dict[str, object]: raise ValueError(f'{label} is required') denoised = self.denoised_dir.text() return { + 'name': self.name.text().strip() or None, 'project_dir': Path(self.project_dir.text()), 'runs': runs, 'tomo_job': self.tomo_job.text().strip(), @@ -131,3 +118,7 @@ def _add_dialog(self) -> None: def _remove(self) -> None: for r in sorted({i.row() for i in self.runs.selectedIndexes()}, reverse=True): self.runs.removeRow(r) + + # _suggest_name: the name placeholder follows the project directory + def _suggest_name(self, text: str) -> None: + self.name.setPlaceholderText(Path(text.strip()).name or 'project directory name') diff --git a/src/pickmix/gui/images.py b/src/pickmix/gui/images.py deleted file mode 100644 index f3af853..0000000 --- a/src/pickmix/gui/images.py +++ /dev/null @@ -1,16 +0,0 @@ -''' -pickmix: turning arrays into pixmaps for the GUI -''' - -# Import external dependencies -import numpy as np -from PySide6.QtCore import Qt -from PySide6.QtGui import QImage, QPixmap - -# to_pixmap: a 2D array stretched between its 1st and 99th percentiles and scaled to fit a square of the given edge -def to_pixmap(arr: np.ndarray, size: int) -> QPixmap: - lo, hi = np.percentile(arr, (1, 99)) - img = np.ascontiguousarray(np.clip((arr - lo) * (255.0 / max(hi - lo, 1e-6)), 0, 255).astype(np.uint8)) - h, w = img.shape - pixmap = QPixmap.fromImage(QImage(img.data, w, h, w, QImage.Format.Format_Grayscale8)) - return pixmap.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) diff --git a/src/pickmix/gui/main_window.py b/src/pickmix/gui/main_window.py index 6bdfdac..ffd8866 100644 --- a/src/pickmix/gui/main_window.py +++ b/src/pickmix/gui/main_window.py @@ -4,66 +4,90 @@ # Import external dependencies from pathlib import Path -from PySide6.QtCore import Qt -from PySide6.QtGui import QKeySequence, QShortcut -from PySide6.QtWidgets import QApplication, QDockWidget, QLineEdit, QMainWindow, QTabWidget +from PySide6.QtCore import QAbstractAnimation, QEasingCurve, QPropertyAnimation, QRect, QSize, Qt, QTimer +from PySide6.QtGui import QKeyEvent, QKeySequence, QShortcut +from PySide6.QtWidgets import QApplication, QLineEdit, QLayout, QMainWindow, QTabWidget # Import internal pickmix objects -from pickmix.gui.kinds_tab import KindsTab from pickmix.estimates import EstimateService -from pickmix.gui.pick_tab import PickTab -from pickmix.gui.project_tab import ProjectTab -from pickmix.gui.review_tab import ReviewTab -from pickmix.gui.tomo_dock import TomoDock +from pickmix.gui.components.toast import install +from pickmix.gui.tabs.export_tab import ExportTab +from pickmix.gui.tabs.pick_tab import PickTab +from pickmix.gui.tabs.project_tab import ProjectTab +from pickmix.gui.tabs.review_tab import ReviewTab from pickmix.loader import Loader from pickmix.model import Session from pickmix.project import Project -# MainWindow: tabs in workflow order; everything after Project waits for an open project +# WELCOME_SIZE, PROJECT_SIZE: window size in pixels on the welcome page and once a project is open +WELCOME_SIZE: tuple[int, int] = (500, 280) +PROJECT_SIZE: tuple[int, int] = (1440, 880) + +# RESIZE_MS: milliseconds the window takes to change size +RESIZE_MS: int = 220 + +# WELCOME_MIN: smallest the window may be dragged to on the welcome page +WELCOME_MIN: tuple[int, int] = (500, 280) + +# MainWindow: tabs in workflow order; everything after Project waits for usable tomograms class MainWindow(QMainWindow): - # __init__: build the tabs and dock + # __init__: build the tabs def __init__(self) -> None: super().__init__() self.setWindowTitle('pickmix') - self.resize(1400, 900) self.project: Project | None = None self.session: Session | None = None self.loader: Loader | None = None self.service: EstimateService | None = None self.tabs = QTabWidget() self.project_tab = ProjectTab() - self.kinds_tab = KindsTab() + self.kinds_pane = self.project_tab.kinds_pane self.pick_tab = PickTab() self.review_tab = ReviewTab() - self.dock = TomoDock() + self.export_tab = ExportTab() self.tabs.addTab(self.project_tab, 'Project') - self.tabs.addTab(self.kinds_tab, 'Kinds') self.tabs.addTab(self.pick_tab, 'Pick') self.tabs.addTab(self.review_tab, 'Review') - for i in range(1, self.tabs.count()): - self.tabs.setTabEnabled(i, False) + self.tabs.addTab(self.export_tab, 'Export') + self._set_enabled(session=False) self.setCentralWidget(self.tabs) - self.dock_widget = QDockWidget('Tomograms') - self.dock_widget.setWidget(self.dock) - self.dock_widget.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) - self.dock_widget.hide() - self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.dock_widget) - self.tabs.currentChanged.connect(self._sync_dock) - self.project_tab.project_changed.connect(self._on_project) + # the window's smallest size is set by hand (see _fit_welcome), not taken from the pages, which are all larger than the welcome page + self.layout().setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint) + self._fit_welcome(True) + self._resizing = QPropertyAnimation(self, b'geometry', self) + self._resizing.setDuration(RESIZE_MS) + self._resizing.setEasingCurve(QEasingCurve.Type.InOutCubic) + self._resizing.finished.connect(self._apply_minimum) + self._restart = QTimer(self) + self._restart.setSingleShot(True) + self._restart.setInterval(500) + self._restart.timeout.connect(self._restart_session) self.project_tab.project_changed.connect(self._on_project) self.project_tab.config_saved.connect(self._on_settings) self.project_tab.project_closed.connect(self._on_closed) - self.kinds_tab.config_saved.connect(self._on_kinds) + self.project_tab.kinds_saved.connect(self._on_kinds) self.review_tab.open_pick.connect(self._open_pick) - for keys, delta in (('Return', 1), ('Enter', 1), ('Shift+Return', -1), ('Shift+Enter', -1)): - shortcut = QShortcut(QKeySequence(keys), self, context=Qt.ShortcutContext.WindowShortcut) - shortcut.activated.connect(lambda delta=delta: self._step(delta)) + self.toast = install(self) + for i in range(1, 10): + QShortcut(QKeySequence(str(i)), self, context=Qt.ShortcutContext.WindowShortcut).activated.connect(lambda i=i: self._hotkey(i - 1)) + for key in ('Delete', 'Backspace'): + QShortcut(QKeySequence(key), self, context=Qt.ShortcutContext.WindowShortcut).activated.connect(self._delete_pick) + self.resize(*WELCOME_SIZE) # open_config: open a config.toml in the Project tab def open_config(self, path: Path) -> None: self.project_tab.open_config(path) - # _step: Enter and Shift+Enter move between tomograms on the Pick tab, unless a text box has focus + # keyPressEvent: Enter steps to the next tomogram and Shift+Enter to the previous. A text box handles Return first and ignores it, so a key reaching here was wanted by nothing else + def keyPressEvent(self, event: QKeyEvent) -> None: + mods = event.modifiers() & ~Qt.KeyboardModifier.KeypadModifier + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and mods in (Qt.KeyboardModifier.NoModifier, Qt.KeyboardModifier.ShiftModifier): + self._step(-1 if mods == Qt.KeyboardModifier.ShiftModifier else 1) + event.accept() + return + super().keyPressEvent(event) + + # _step: move between tomograms on the Pick tab, unless a text box has focus def _step(self, delta: int) -> None: if self.session is None or self.tabs.currentWidget() is not self.pick_tab: return @@ -71,31 +95,82 @@ def _step(self, delta: int) -> None: return self.session.step(delta) - # _sync_dock: the tomogram dock is only for the Pick tab, and only with a session - def _sync_dock(self) -> None: - self.dock_widget.setVisible(self.session is not None and self.tabs.currentWidget() is self.pick_tab) + # _picking: whether Pick tab keys apply: a session, the Pick tab showing and no text box (a spin box counts as one) being typed into + def _picking(self) -> bool: + return ( + self.session is not None and self.tabs.currentWidget() is self.pick_tab + and not isinstance(QApplication.focusWidget(), QLineEdit) + ) + + # _hotkey: the number key for the i-th kind, from wherever focus is + def _hotkey(self, i: int) -> None: + if self._picking(): + self.pick_tab.pick_kind(i) + + # _delete_pick: Delete or Backspace removes the selected pick, from wherever focus is + def _delete_pick(self) -> None: + if self._picking(): + self.pick_tab.delete_selected() + + # _resize_about_centre: glide to a new size keeping the window's centre where it is, nudged back onto its screen; no glide before the window is shown + def _resize_about_centre(self, size: tuple[int, int]) -> None: + self._resizing.stop() + frame, inner = self.frameGeometry(), self.geometry() + # the frame is the window plus its title bar and borders + wide, high = frame.width() - inner.width(), frame.height() - inner.height() + target = QRect(frame.topLeft(), QSize(size[0] + wide, size[1] + high)) + target.moveCenter(frame.center()) + area = self.screen().availableGeometry() + # keep the whole frame on screen (top-left wins if the frame is bigger than the screen) + x = max(area.left(), min(target.left(), area.right() - target.width() + 1)) + y = max(area.top(), min(target.top(), area.bottom() - target.height() + 1)) + end = QRect(x + inner.left() - frame.left(), y + inner.top() - frame.top(), size[0], size[1]) + if not self.isVisible(): + self.setGeometry(end) + return + self._resizing.setStartValue(inner) + self._resizing.setEndValue(end) + self._resizing.start() + + # _fit_welcome: the welcome page has no tab bar and may be small; with a project open the tabs show and set the smallest size. Lowering the minimum is applied now, raising it waits for _apply_minimum + def _fit_welcome(self, welcome: bool) -> None: + self.tabs.tabBar().setVisible(not welcome) + self._minimum = QSize(*WELCOME_MIN) if welcome else self.tabs.minimumSizeHint() + if welcome: + self.setMinimumSize(self._minimum) + + # _apply_minimum: set the window's smallest size once any resize has finished; raising it earlier would snap the window to it from its top-left corner and lose the centre + def _apply_minimum(self) -> None: + self.setMinimumSize(self._minimum) + + # _set_enabled: Project is always usable; Pick, Review and Export need a session + def _set_enabled(self, *, session: bool) -> None: + for tab in (self.pick_tab, self.review_tab, self.export_tab): + self.tabs.setTabEnabled(self.tabs.indexOf(tab), session) # _on_project: build the session and loader for a project and unlock the tabs def _on_project(self, project: Project) -> None: + # only the first project after the welcome page resizes; reloads and settings restarts keep the user's size + if self.project is None: + self._fit_welcome(False) + self._resize_about_centre((max(PROJECT_SIZE[0], self._minimum.width()), max(PROJECT_SIZE[1], self._minimum.height()))) + if self._resizing.state() != QAbstractAnimation.State.Running: + self._apply_minimum() self.project = project - self.setWindowTitle(f'pickmix: {project.path}') + self.setWindowTitle(f'pickmix • {project.display_name}') self._shutdown() - self.kinds_tab.set_project(project) if project.tomos: self.session, self.loader = Session(project), Loader(project) self.service = EstimateService(self.session, self.loader) self.pick_tab.set_session(self.session, self.loader, self.service) self.review_tab.set_session(self.session, self.loader, self.service) - self.dock.set_session(self.session) - self.kinds_tab.set_session(self.session, self.service) + self.export_tab.set_session(self.session, self.loader, self.service) + self.kinds_pane.set_session(self.session, self.service) else: self.session = self.loader = self.service = None - self.kinds_tab.set_session(None, None) - for i in range(1, self.tabs.count()): - self.tabs.setTabEnabled(i, True) - self.tabs.setTabEnabled(2, self.session is not None) - self.tabs.setTabEnabled(3, self.session is not None) - self._sync_dock() + self.kinds_pane.set_session(None, None) + self.export_tab.clear() + self._set_enabled(session=self.session is not None) # _shutdown: stop the estimate service and loader of the current project, if any def _shutdown(self) -> None: @@ -107,17 +182,23 @@ def _shutdown(self) -> None: # _on_closed: no project; lock everything but the Project tab def _on_closed(self) -> None: + self._restart.stop() self._shutdown() self.project = None self.setWindowTitle('pickmix') - self.kinds_tab.set_session(None, None) - for i in range(1, self.tabs.count()): - self.tabs.setTabEnabled(i, False) + self._fit_welcome(True) + self._resize_about_centre(WELCOME_SIZE) + self.kinds_pane.set_session(None, None) + self.export_tab.clear() + self._set_enabled(session=False) self.tabs.setCurrentWidget(self.project_tab) - self._sync_dock() - # _on_settings: source or estimator settings changed, so start the session and loader afresh + # _on_settings: source or estimator settings changed; rebuild once the changes stop, as they save on every edit def _on_settings(self) -> None: + self._restart.start() + + # _restart_session: start the session, loader and estimators afresh with the saved settings + def _restart_session(self) -> None: if self.project is not None: self._on_project(self.project) @@ -125,8 +206,7 @@ def _on_settings(self) -> None: def _on_kinds(self) -> None: self.pick_tab.refresh_kinds() self.review_tab.refresh_kinds() - self.kinds_tab.average.refresh_kinds() - self.dock.refresh() + self.export_tab.refresh_kinds() # _open_pick: show a pick from the Review tab in the Pick tab def _open_pick(self, _tomogram: str, pick_id: int) -> None: diff --git a/src/pickmix/gui/panes/average_pane.py b/src/pickmix/gui/panes/average_pane.py new file mode 100644 index 0000000..c097ffa --- /dev/null +++ b/src/pickmix/gui/panes/average_pane.py @@ -0,0 +1,215 @@ +''' +pickmix: Average pane of the Export tab +''' + +# Import external dependencies +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QShowEvent +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.average import AverageResult, average_kind, save_average +from pickmix.estimates import EstimateService +from pickmix.export import Item +from pickmix.gui.components.images import FitLabel, label_cells, tile_cells, to_image, to_pixmap +from pickmix.gui.components.status import StatusBadge +from pickmix.gui.components.toast import notify +from pickmix.gui.style import WELL_BG, WELL_TEXT, heading, help_marker, image_well, quiet_table, tidy +from pickmix.gui.workers import run_in_background +from pickmix.model import Session + +# _MEAN, _MONTAGE: edge in pixels of the mean, and the smallest edge of the montage, which grows to fill its space +_MEAN: int = 340 +_MONTAGE: int = 340 + +# _LEFT: width in pixels of the controls column +_LEFT: int = 280 + +# AveragePane: controls and the tomogram list on the left, the mean in the middle and the labelled montage on the right +class AveragePane(QGroupBox): + # __init__: build the layout + def __init__(self) -> None: + super().__init__('Average') + self.session: Session | None = None + self.service: EstimateService | None = None + self.result: AverageResult | None = None + self._names: list[str] = [] + self.kind_box = QComboBox() + self.summary = QLabel() + self.run_button = QPushButton('Run') + self.spinner = StatusBadge() + self.save_button = QPushButton('Save') + self.save_button.setToolTip("Write the mean and the montage (plain and labelled) to the kind's average folder") + self.save_button.setEnabled(False) + self.per_tomogram_box = QCheckBox('Include per-tomogram means') + self.per_tomogram_box.setToolTip('Also write a mean image for every tomogram in the list') + self.results_table = QTableWidget(0, 2) + self.results_table.setHorizontalHeaderLabels(['Tomogram', 'Picks']) + quiet_table(self.results_table) + self.results_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.results_table.setToolTip('Show the mean of every pick, or of one tomogram') + self.mean_label = QLabel('mean') + self.montage_label = FitLabel('montage', _MONTAGE) + self.montage_label.setStyleSheet(f'background: {WELL_BG}; color: {WELL_TEXT}') + image_well(self.mean_label, _MEAN) + # left: kind, run and summary, the tomogram list, then save + kind_row = QHBoxLayout() + kind_row.addWidget(QLabel('Kind')) + kind_row.addWidget(self.kind_box, 1) + run_row = QHBoxLayout() + run_row.addWidget(self.run_button) + run_row.addWidget(self.spinner) + run_row.addStretch() + tick_row = QHBoxLayout() + tick_row.addWidget(self.per_tomogram_box) + tick_row.addWidget(help_marker('Without this, only the overall mean and the montage are written')) + tick_row.addStretch() + left = QWidget() + left.setFixedWidth(_LEFT) + left_box = QVBoxLayout(left) + left_box.setContentsMargins(0, 0, 0, 0) + left_box.addLayout(kind_row) + left_box.addLayout(run_row) + left_box.addWidget(self.summary) + left_box.addWidget(self.results_table, 1) + left_box.addLayout(tick_row) + left_box.addWidget(self.save_button) + mean = QVBoxLayout() + mean.addWidget(heading('Mean')) + mean.addWidget(self.mean_label) + mean.addStretch() + montage = QVBoxLayout() + montage.addWidget(heading('Montage')) + montage.addWidget(self.montage_label, 1) + columns = QHBoxLayout() + columns.addWidget(left) + columns.addSpacing(16) + columns.addLayout(mean) + columns.addSpacing(16) + columns.addLayout(montage, 1) + body = QVBoxLayout(self) + tidy(body) + body.addLayout(columns) + self.kind_box.currentTextChanged.connect(self._update_summary) + self.kind_box.currentTextChanged.connect(self._clear) + self.run_button.clicked.connect(self.run) + self.save_button.clicked.connect(self.save) + self.results_table.currentCellChanged.connect(lambda row, *_: self._show_row(row)) + + # set_session: use a session's picks and the service's fits, or none + def set_session(self, session: Session | None, service: EstimateService | None) -> None: + self.session, self.service = session, service + self._clear() + self.refresh_kinds() + + # refresh_kinds: list the kinds of the config + def refresh_kinds(self) -> None: + current = self.kind_box.currentText() + self.kind_box.blockSignals(True) + self.kind_box.clear() + if self.session is not None: + self.kind_box.addItems(list(self.session.project.config.kinds)) + self.kind_box.setCurrentText(current) + self.kind_box.blockSignals(False) + self._update_summary() + + # 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 [] + out = [] + for pick in self.session.store.all(self.kind_box.currentText()): + fit = self.service.store.get(pick) + 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: + notify('No picks with a current estimate in the selected tomograms') + return + config, tomos = self.session.project.config, self.session.project.tomos + self.run_button.setEnabled(False) + self.spinner.set_status('running', f'Averaging {len(items)} picks...') + notify(f'Averaging {len(items)} picks of {name}...') + run_in_background(lambda: average_kind(name, config.kinds[name], items, tomos, config), self._done, self._failed) + + # save: write the averages of the shown result, and the labelled montage beside them; returns the paths + def save(self) -> list[str]: + if self.result is None: + return [] + r = self.result + paths = save_average(r, per_tomogram=self.per_tomogram_box.isChecked()) + labelled = r.folder / 'overall_montage_labelled.png' + label_cells(to_image(r.montage), r.labels, r.grid, r.pad).save(str(labelled)) + paths.append(labelled) + notify(f'Saved {len(paths)} images in {str(r.folder)!r}', saved=True) + return [str(p) for p in paths] + + # _clear: forget the shown result, so Save never writes under a kind the box no longer shows + def _clear(self) -> None: + self.result = None + self._names = [] + self.results_table.setRowCount(0) + self.mean_label.clear() + self.mean_label.setText('mean') + self.montage_label.set_render(None) + self.montage_label.clear() + self.montage_label.setText('montage') + self.save_button.setEnabled(False) + + # _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 + 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 the labelled montage, and list what can be looked at with its picks + def _done(self, result: AverageResult) -> None: + self.result = result + self._names = list(result.per_tomogram) + self.run_button.setEnabled(True) + self.save_button.setEnabled(True) + self.spinner.set_status(None) + notify(f'Averaged {result.count} boxes of {result.box_px} px (Save to write them)') + self.montage_label.set_render(lambda width, height: self._tiled(result, width, height)) + rows = [('overall', result.count), *((t, result.counts[t]) for t in self._names)] + self.results_table.setRowCount(len(rows)) + for r, (name, picks) in enumerate(rows): + self.results_table.setItem(r, 0, QTableWidgetItem(name)) + self.results_table.setItem(r, 1, QTableWidgetItem(str(picks))) + self.results_table.selectRow(0) + + # _failed: report an error from the background run + def _failed(self, message: str) -> None: + self.run_button.setEnabled(True) + self.spinner.set_status('error', message) + notify(message) + + # _tiled: the montage's cells laid out again to fill a pane of this size, labelled (what is saved stays the square montage) + def _tiled(self, result: AverageResult, width: int, height: int) -> QPixmap: + return QPixmap.fromImage(tile_cells(to_image(result.montage), result.labels, result.grid, result.pad, width, height)) + + # _show_row: show the overall mean (row 0) or one tomogram's mean + def _show_row(self, row: int) -> None: + if self.result is None or row < 0: + return + mean = self.result.overall if row == 0 else self.result.per_tomogram[self._names[row - 1]] + self.mean_label.setPixmap(to_pixmap(mean, _MEAN)) diff --git a/src/pickmix/gui/kinds_tab.py b/src/pickmix/gui/panes/kinds_pane.py similarity index 77% rename from src/pickmix/gui/kinds_tab.py rename to src/pickmix/gui/panes/kinds_pane.py index e17a5ee..faa0ba9 100644 --- a/src/pickmix/gui/kinds_tab.py +++ b/src/pickmix/gui/panes/kinds_pane.py @@ -1,5 +1,5 @@ ''' -pickmix: Kinds tab, listing the kinds, choosing hotkeys and editing both in config.toml +pickmix: kinds pane, listing the kinds, choosing hotkeys and editing both in config.toml ''' # Import external dependencies @@ -7,40 +7,35 @@ from pydantic import ValidationError from PySide6.QtCore import QEvent, QObject, Qt, Signal from PySide6.QtWidgets import ( - QComboBox, QFormLayout, QGridLayout, QHBoxLayout, QLabel, QMessageBox, - QPushButton, QScrollArea, QToolButton, QVBoxLayout, QWidget, + QGridLayout, QHBoxLayout, QLabel, QMessageBox, + QScrollArea, QToolButton, QVBoxLayout, QWidget, ) # Import internal pickmix objects -from pickmix.config import HOTKEYS, Kind +from pickmix.config import Kind from pickmix.estimates import EstimateService -from pickmix.gui.average_pane import AveragePane -from pickmix.gui.kind_dialog import DELETED, KindDialog -from pickmix.gui.style import heading, icon, tidy +from pickmix.gui.dialogs.kind_dialog import DELETED, KindDialog +from pickmix.gui.style import heading, icon, icon_button, transparent_scroll from pickmix.model import Session from pickmix.project import Project -# NO_KIND: text of the hotkey choice that leaves a key unused -NO_KIND: str = '(none)' - # _CARD_WIDTH: narrowest a kind's block gets, in pixels, before the grid drops a column -_CARD_WIDTH: int = 260 +_CARD_WIDTH: int = 180 -# KindsTab: the kinds as a list with an edit button each, and one dropdown per hotkey -class KindsTab(QWidget): +# KindsPane: the kinds as a list with an edit button each, and one dropdown per hotkey +class KindsPane(QWidget): # config_saved: the kinds or hotkeys were written to config.toml config_saved = Signal() - # __init__: build the Kinds and Hotkeys panes over the Average pane + # __init__: build the Kinds and Hotkeys panes def __init__(self) -> None: super().__init__() self.project: Project | None = None self.session: Session | None = None self.service: EstimateService | None = None - self._loading = False self.details: dict[str, QLabel] = {} self.edit_buttons: dict[str, QToolButton] = {} - self.add_button = QPushButton('Add') + self.add_button = icon_button('add', 'Add', 'Add a kind') self.add_button.clicked.connect(self.add_kind) self.cards: list[QWidget] = [] self._columns = 0 @@ -51,34 +46,19 @@ def __init__(self) -> None: self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll.setWidget(holder) + transparent_scroll(self.scroll) self.scroll.setMinimumHeight(220) self.scroll.viewport().installEventFilter(self) head = QHBoxLayout() head.addWidget(heading('Kinds')) head.addStretch() head.addWidget(self.add_button) - kinds_pane = QVBoxLayout() - kinds_pane.addLayout(head) - kinds_pane.addWidget(self.scroll, 1) - self.hotkey_boxes = [QComboBox() for _ in range(HOTKEYS)] - keys = QFormLayout() - for i, box in enumerate(self.hotkey_boxes): - keys.addRow(str(i + 1), box) - box.currentTextChanged.connect(lambda text, i=i: self._hotkey_changed(i, text)) - hotkeys_pane = QVBoxLayout() - hotkeys_pane.addWidget(heading('Hotkeys')) - hotkeys_pane.addLayout(keys) - hotkeys_pane.addStretch() - top = QHBoxLayout() - top.addLayout(kinds_pane, 2) - top.addLayout(hotkeys_pane, 1) self.status = QLabel() - self.average = AveragePane() layout = QVBoxLayout(self) - tidy(layout) - layout.addLayout(top, 1) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(head) + layout.addWidget(self.scroll, 1) layout.addWidget(self.status) - layout.addWidget(self.average) # set_project: show the kinds and hotkeys of a project def set_project(self, project: Project) -> None: @@ -89,7 +69,6 @@ def set_project(self, project: Project) -> None: # set_session: the picks and estimates that follow a kind when it is renamed or deleted, or none; the Average pane uses them too def set_session(self, session: Session | None, service: EstimateService | None) -> None: self.session, self.service = session, service - self.average.set_session(session, service) # eventFilter: lay the kinds out again when the list is resized def eventFilter(self, watched: QObject, event: QEvent) -> bool: @@ -109,10 +88,9 @@ def add_kind(self) -> None: def edit_kind(self, name: str) -> None: self._edit(name) - # _show: rebuild the list and the hotkey dropdowns from the config + # _show: rebuild the list from the config def _show(self) -> None: assert self.project is not None - self._loading = True for card in self.cards: self.grid.removeWidget(card) card.deleteLater() @@ -121,12 +99,6 @@ def _show(self) -> None: self.cards.append(self._card(name, kind)) self._columns = 0 self._reflow() - names = [NO_KIND, *self.project.config.kinds] - for box, chosen in zip(self.hotkey_boxes, self.project.config.hotkeys, strict=True): - box.clear() - box.addItems(names) - box.setCurrentText(chosen or NO_KIND) - self._loading = False # _reflow: put the kinds in as many equal columns as fit, filling across then down def _reflow(self) -> None: @@ -238,19 +210,6 @@ def _delete(self, name: str) -> None: def _confirm(self, text: str) -> bool: return QMessageBox.question(self, 'Delete kind', text) == QMessageBox.StandardButton.Yes - # _hotkey_changed: save a hotkey choice; a kind lives on one key, so choosing it moves it - def _hotkey_changed(self, slot: int, text: str) -> None: - if self._loading or self.project is None: - return - self._loading = True - for i, box in enumerate(self.hotkey_boxes): - if i != slot and text != NO_KIND and box.currentText() == text: - box.setCurrentText(NO_KIND) - self._loading = False - keys = [b.currentText() for b in self.hotkey_boxes] - self.project.update(hotkeys=['' if k == NO_KIND else k for k in keys]) - self._saved() - # _saved: show the config as written and tell the other tabs def _saved(self) -> None: self._show() diff --git a/src/pickmix/gui/panes/settings_pane.py b/src/pickmix/gui/panes/settings_pane.py new file mode 100644 index 0000000..0fea03d --- /dev/null +++ b/src/pickmix/gui/panes/settings_pane.py @@ -0,0 +1,160 @@ +''' +pickmix: settings pane, volume source, hotkeys and estimator parameters, written to config.toml as they change +''' + +# Import external dependencies +from pydantic import BaseModel, ValidationError +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QSpinBox, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.config import HOTKEYS, RingParams +from pickmix.gui.components.collapsible import Collapsible +from pickmix.gui.components.toast import notify +from pickmix.gui.style import form_label, heading +from pickmix.project import Project + +# NO_KIND: text of the hotkey choice that leaves a key unused +NO_KIND: str = '(none)' + +# ParamsForm: a form for a flat pydantic model of numbers and number pairs, labelled from the field titles +class ParamsForm(QWidget): + # changed: a value was committed (arrow, wheel, Enter or focus loss), not set from code + changed = Signal() + + # __init__: one row per field, "Title (units) (?): [a] - [b]" + def __init__(self, params: BaseModel) -> None: + super().__init__() + self._cls = type(params) + self._boxes: dict[str, list[QSpinBox | QDoubleSpinBox]] = {} + self._setting = False + self.labels: dict[str, QLabel] = {} + layout = QFormLayout(self) + for name, value in params: + info = type(params).model_fields[name] + values = value if isinstance(value, tuple) else (value,) + boxes = [_spin(v) for v in values] + self._boxes[name] = boxes + row = QHBoxLayout() + for i, box in enumerate(boxes): + if i: + row.addWidget(QLabel('-')) + row.addWidget(box) + # no valueChanged per keystroke; it fires when the typed value is committed + box.setKeyboardTracking(False) + box.valueChanged.connect(self._emit) + row.addStretch() + # "Title (units) (?):" in the label column, then the values + head = form_label(info.title or name, info.description or '') + self.labels[name] = head.findChild(QLabel) + layout.addRow(head, row) + + # set: show the values of a model without announcing a change + def set(self, params: BaseModel) -> None: + self._setting = True + for name, value in params: + for box, v in zip(self._boxes[name], value if isinstance(value, tuple) else (value,), strict=True): + box.setValue(v) + self._setting = False + + # get: a model of the shown values, validated by pydantic + def get(self) -> BaseModel: + return self._cls(**{n: tuple(b.value() for b in boxes) if len(boxes) > 1 else boxes[0].value() for n, boxes in self._boxes.items()}) + + # _emit: announce a committed change + def _emit(self, _value: float) -> None: + if not self._setting: + self.changed.emit() + +# SettingsPane: General (volume source and hotkeys) and Estimators, saved as they change +class SettingsPane(QWidget): + # config_saved: source or estimator parameters were written to config.toml + config_saved = Signal() + # hotkeys_saved: the hotkeys were written to config.toml + hotkeys_saved = Signal() + + # __init__: build the groups + def __init__(self) -> None: + super().__init__() + self.project: Project | None = None + self._loading = False + self.source_box = QComboBox() + self.source_box.addItems(['denoised', 'raw']) + self.hotkey_boxes = [QComboBox() for _ in range(HOTKEYS)] + self.ring_form = ParamsForm(RingParams()) + general = QGroupBox('General') + form = QFormLayout(general) + form.addRow('Volume source', self.source_box) + for i, box in enumerate(self.hotkey_boxes): + form.addRow(f'Hotkey {i + 1}', box) + box.currentTextChanged.connect(lambda text, i=i: self._hotkey_changed(i, text)) + estimators = QGroupBox('Estimators') + estimator_box = QVBoxLayout(estimators) + self.ring_section = Collapsible('Ring', self.ring_form) + estimator_box.addWidget(self.ring_section) + self.setMinimumWidth(self.ring_form.sizeHint().width() + 48) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(heading('Settings')) + layout.addWidget(general) + layout.addWidget(estimators) + layout.addStretch() + self.source_box.currentTextChanged.connect(self._save) + self.ring_form.changed.connect(self._save) + + # set_project: show a project's settings without saving them back + def set_project(self, project: Project) -> None: + self.project = project + self._loading = True + self.source_box.setCurrentText(project.config.source) + self.ring_form.set(project.config.ring) + self._loading = False + self.refresh_hotkeys() + + # refresh_hotkeys: list the project's kinds in every dropdown and select each key's kind + def refresh_hotkeys(self) -> None: + if self.project is None: + return + self._loading = True + names = [NO_KIND, *self.project.config.kinds] + for box, chosen in zip(self.hotkey_boxes, self.project.config.hotkeys, strict=True): + box.clear() + box.addItems(names) + box.setCurrentText(chosen or NO_KIND) + self._loading = False + + # _save: write source and ring parameters; a value the config refuses is not written + def _save(self) -> None: + if self._loading or self.project is None: + return + try: + self.project.update(source=self.source_box.currentText(), ring=self.ring_form.get()) + except ValidationError as e: + notify(f'Not saved: {e.errors()[0]["msg"]}') + return + self.config_saved.emit() + + # _hotkey_changed: save a hotkey choice; a kind lives on one key, so choosing it moves it + def _hotkey_changed(self, slot: int, text: str) -> None: + if self._loading or self.project is None: + return + self._loading = True + for i, box in enumerate(self.hotkey_boxes): + if i != slot and text != NO_KIND and box.currentText() == text: + box.setCurrentText(NO_KIND) + self._loading = False + keys = [b.currentText() for b in self.hotkey_boxes] + self.project.update(hotkeys=['' if k == NO_KIND else k for k in keys]) + self.hotkeys_saved.emit() + +# _spin: an integer or float spin box for a value +def _spin(value: float) -> QSpinBox | QDoubleSpinBox: + if isinstance(value, int): + box: QSpinBox | QDoubleSpinBox = QSpinBox() + box.setRange(0, 1_000_000) + else: + box = QDoubleSpinBox() + box.setRange(0, 1_000_000) + box.setDecimals(2) + box.setValue(value) + return box diff --git a/src/pickmix/gui/tomo_dock.py b/src/pickmix/gui/panes/tomo_pane.py similarity index 66% rename from src/pickmix/gui/tomo_dock.py rename to src/pickmix/gui/panes/tomo_pane.py index 21ae10f..345877e 100644 --- a/src/pickmix/gui/tomo_dock.py +++ b/src/pickmix/gui/panes/tomo_pane.py @@ -1,39 +1,48 @@ ''' -pickmix: tomogram dock +pickmix: tomogram pane ''' # Import external dependencies +from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QColor, QFontDatabase from PySide6.QtWidgets import QLabel, QLineEdit, QListWidget, QListWidgetItem, QVBoxLayout, QWidget # Import internal pickmix objects +from pickmix.gui.style import OK, RUNNING, WARN from pickmix.model import Session # COLOUR_VISITED, COLOUR_PENDING, COLOUR_CURRENT: line colours for opened, not yet opened and current tomograms -COLOUR_VISITED: QColor = QColor('#2e9e44') -COLOUR_PENDING: QColor = QColor('#e08a00') -COLOUR_CURRENT: QColor = QColor('#2f7fe0') +COLOUR_VISITED: QColor = QColor(OK) +COLOUR_PENDING: QColor = QColor(WARN) +COLOUR_CURRENT: QColor = QColor(RUNNING) -# TomoDock: one line per tomogram with number, name and picks, coloured by state -class TomoDock(QWidget): +# TomoPane: one line per tomogram with number, name and picks, coloured by state +class TomoPane(QWidget): # __init__: build the widgets def __init__(self) -> None: super().__init__() self.session: Session | None = None self.visited = QLabel() self.jump_edit = QLineEdit() - self.jump_edit.setPlaceholderText('go to number or name, then Enter') + self.jump_edit.setPlaceholderText('go to number or name') self.header = QLabel() self.list = QListWidget() self.list.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) self.header.setFont(self.list.font()) + # no highlight + self.list.setSelectionMode(QListWidget.SelectionMode.NoSelection) + self.list.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._live = QTimer(self) + self._live.setSingleShot(True) + self._live.setInterval(300) + self._live.timeout.connect(self._live_jump) layout = QVBoxLayout(self) layout.addWidget(self.visited) layout.addWidget(self.jump_edit) layout.addWidget(self.header) layout.addWidget(self.list, 1) - self.jump_edit.returnPressed.connect(self._jump) self.list.itemClicked.connect(self._clicked) + self.jump_edit.textChanged.connect(lambda _text: self._live.start()) # set_session: show a session and follow its picks and navigation def set_session(self, session: Session) -> None: @@ -53,10 +62,10 @@ def refresh(self) -> None: for _ in rows: self.list.addItem(QListWidgetItem()) width = max((len(r.name) for r in rows), default=0) - self.header.setText(f'{"No.":>3} {"Tomogram":<{width}} Picks') + self.header.setText(f'{"No.":<3} {"Tomogram":<{width}} Picks') for row in rows: item = self.list.item(row.index) - item.setText(f'{row.index + 1:>3} {row.name:<{width}} {row.count:>5}') + item.setText(f'{row.index + 1:<3} {row.name:<{width}} {row.count:>5}') item.setForeground(COLOUR_CURRENT if row.current else COLOUR_VISITED if row.visited else COLOUR_PENDING) font = item.font() font.setBold(row.current) @@ -65,13 +74,13 @@ def refresh(self) -> None: self.list.scrollToItem(self.list.item(self.session.index)) self.visited.setText(f'Visited {sum(r.visited for r in rows)}/{len(rows)}') - # _jump: go to the typed number or name - def _jump(self) -> None: - if self.session is not None and self.session.jump(self.jump_edit.text()): - self.jump_edit.clear() - self.jump_edit.clearFocus() - else: - self.jump_edit.selectAll() + # _live_jump: show the tomogram the typed text matches, keeping the text so typing can go on + def _live_jump(self) -> None: + if self.session is None: + return + hit = self.session.find(self.jump_edit.text()) + if hit is not None and hit != self.session.index: + self.session.go(hit) # _clicked: go to the clicked tomogram def _clicked(self, item: QListWidgetItem) -> None: diff --git a/src/pickmix/gui/panes/tomograms_pane.py b/src/pickmix/gui/panes/tomograms_pane.py new file mode 100644 index 0000000..0f683ba --- /dev/null +++ b/src/pickmix/gui/panes/tomograms_pane.py @@ -0,0 +1,93 @@ +''' +pickmix: tomograms pane, every tomogram of the project with warnings and rejections marked +''' + +# Import external dependencies +from collections import Counter +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.gui.components.icons import tinted +from pickmix.gui.components.status import Status, status_item +from pickmix.gui.style import ERROR, MUTED, OK, heading, hint, quiet_table +from pickmix.project import Project + +# _COLUMNS: table headings; the first column is the status marker and has none +_COLUMNS: list[str] = ['', 'Name', 'Shape (zyx)', 'Pixel size (Å)', 'Denoised'] + +# _MARKER_WIDTH: fixed width in pixels of the status marker column, so names always line up +_MARKER_WIDTH: int = 28 + +# TomogramsPane: title, summary and table +class TomogramsPane(QWidget): + # __init__: build the table + def __init__(self) -> None: + super().__init__() + self.title = heading('Tomograms') + self.summary = hint() + self.table = QTableWidget(0, len(_COLUMNS)) + self.table.setHorizontalHeaderLabels(_COLUMNS) + quiet_table(self.table) + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + header.setStretchLastSection(True) + self.table.setColumnWidth(0, _MARKER_WIDTH) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.title) + layout.addWidget(self.summary) + layout.addWidget(self.table, 1) + + # set_project: one row per star row; rejected ones are greyed with an error marker + def set_project(self, project: Project) -> None: + self.table.setRowCount(len(project.names)) + for r, name in enumerate(project.names): + status, tip = tomogram_status(project, name) + if name in project.tomos: + t = project.tomos[name] + cells = [name, str(t.shape), f'{t.pixel_size:.2f}'] + denoised: bool | None = t.denoised_path is not None + else: + e = project.issues[name] + cells = [name, '-' if e.shape is None else str(e.shape), '-' if e.pixel_size is None else f'{e.pixel_size:.2f}'] + denoised = e.denoised + self.table.setItem(r, 0, status_item(status, tip)) + for c, text in enumerate(cells, start=1): + item = QTableWidgetItem(text) + item.setToolTip(tip) + if status == 'error': + item.setForeground(QColor(MUTED)) + self.table.setItem(r, c, item) + self.table.setItem(r, 4, _flag_item(denoised)) + with_denoised = sum(t.denoised_path is not None for t in project.tomos.values()) + self.summary.setText(f'{len(project.names)} total ({with_denoised} denoised), {len(project.issues)} rejected') + +# tomogram_status: the marker and tooltip for one tomogram: error when rejected, warn when usable but off, None when fine +def tomogram_status(project: Project, name: str) -> tuple[Status | None, str]: + if name in project.issues: + e = project.issues[name] + return 'error', f'Rejected: {e.reason}. {e}' + tomo = project.tomos[name] + problems = [] + if project.config.denoised_dir is not None and tomo.denoised_path is None: + problems.append('no denoised volume found') + usual = Counter(round(t.pixel_size, 2) for t in project.tomos.values()).most_common(1)[0][0] + if round(tomo.pixel_size, 2) != usual: + problems.append(f'pixel size {tomo.pixel_size:.2f} Å differs from the usual {usual:.2f} Å') + return ('warn', '; '.join(problems)) if problems else (None, '') + +# _flag_item: a green tick for yes, a red cross for no, a dash when unknown +def _flag_item(value: bool | None) -> QTableWidgetItem: + item = QTableWidgetItem() + if value is None: + item.setText('-') + return item + found = tinted('check' if value else 'close', OK if value else ERROR) + if found is None: + item.setText('✓' if value else '✗') + else: + item.setIcon(found) + item.setToolTip('Denoised volume found' if value else 'No denoised volume') + return item diff --git a/src/pickmix/gui/panes/welcome_pane.py b/src/pickmix/gui/panes/welcome_pane.py new file mode 100644 index 0000000..8b6db4b --- /dev/null +++ b/src/pickmix/gui/panes/welcome_pane.py @@ -0,0 +1,38 @@ +''' +pickmix: welcome pane shown when no project is open +''' + +# Import external dependencies +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix import __version__ +from pickmix.gui.style import big_button, hint, tidy + +# WelcomePane: Open project and New project side by side, centred, with the version at the bottom +class WelcomePane(QWidget): + # __init__: build the buttons and the shortcuts + def __init__(self) -> None: + super().__init__() + self.open_button = big_button('Open project', 'folder_open') + self.new_button = big_button('New project', 'create_new_folder') + self.version = hint(f'pickmix v{__version__}') + row = QHBoxLayout() + row.setSpacing(48) + row.addStretch() + row.addWidget(self.open_button) + row.addWidget(self.new_button) + row.addStretch() + layout = QVBoxLayout(self) + tidy(layout) + layout.addStretch() + layout.addLayout(row) + layout.addStretch() + layout.addWidget(self.version, alignment=Qt.AlignmentFlag.AlignHCenter) + # click() so a button disabled while a project loads ignores its shortcut; window-wide, but only live while this pane is visible + for keys, button in (('Ctrl+O', self.open_button), ('Ctrl+N', self.new_button)): + QShortcut(QKeySequence(keys), self, context=Qt.ShortcutContext.WindowShortcut).activated.connect(button.click) + self.open_button.setToolTip('Open an existing project (Ctrl+O)') + self.new_button.setToolTip('Create a project from RELION runs (Ctrl+N)') diff --git a/src/pickmix/gui/project_tab.py b/src/pickmix/gui/project_tab.py deleted file mode 100644 index 2ea35b5..0000000 --- a/src/pickmix/gui/project_tab.py +++ /dev/null @@ -1,259 +0,0 @@ -''' -pickmix: 'Project' tab for creating/opening projects and setting tomogram sources & estimator parameters -''' - -# Import external dependencies -from pathlib import Path -from pydantic import BaseModel, ValidationError -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, - QPushButton, QSpinBox, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, -) - -# Import internal pickmix objects -from pickmix.config import RingParams -from pickmix.gui.collapsible import Collapsible -from pickmix.gui.new_project_dialog import NewProjectDialog -from pickmix.gui.style import form_label, heading, tidy -from pickmix.gui.workers import run_in_background -from pickmix.project import Project, create_project, open_project -from pickmix.utils.star import MergeResult - -# _COLUMNS: tomogram table headings -_COLUMNS: list[str] = ['Name', 'Shape (zyx)', 'Pixel size (A)', 'Denoised', 'Status'] - -# _ParamsForm: a form for a flat pydantic model of numbers and number pairs, labelled from the field titles -class _ParamsForm(QWidget): - # __init__: one row per field, "Title (units) (?): [a] - [b]" - def __init__(self, params: BaseModel) -> None: - super().__init__() - self._cls = type(params) - self._boxes: dict[str, list[QSpinBox | QDoubleSpinBox]] = {} - self.labels: dict[str, QLabel] = {} - layout = QFormLayout(self) - for name, value in params: - info = type(params).model_fields[name] - values = value if isinstance(value, tuple) else (value,) - boxes = [_spin(v) for v in values] - self._boxes[name] = boxes - row = QHBoxLayout() - for i, box in enumerate(boxes): - if i: - row.addWidget(QLabel('-')) - row.addWidget(box) - row.addStretch() - # "Title (units) (?):" in the label column, then the values - head = form_label(info.title or name, info.description or '') - self.labels[name] = head.findChild(QLabel) - layout.addRow(head, row) - - # set: show the values of a model - def set(self, params: BaseModel) -> None: - for name, value in params: - for box, v in zip(self._boxes[name], value if isinstance(value, tuple) else (value,), strict=True): - box.setValue(v) - - # get: a model of the shown values, validated by pydantic - def get(self) -> BaseModel: - return self._cls(**{n: tuple(b.value() for b in boxes) if len(boxes) > 1 else boxes[0].value() for n, boxes in self._boxes.items()}) - -# ProjectTab: project open/create, tomogram table and settings -class ProjectTab(QWidget): - # project_changed: a project was opened or created - project_changed = Signal(object) - # config_saved: settings of the open project were written to config.toml - config_saved = Signal() - # project_closed: the open project was closed - project_closed = Signal() - - # __init__: build the layout - def __init__(self) -> None: - super().__init__() - self.project: Project | None = None - self.path_label = QLabel() - self.open_button = QPushButton('Open project...') - self.new_button = QPushButton('New project...') - self.reload_button = QPushButton('Reload config') - self.close_button = QPushButton('Close project') - self.status = QLabel() - self.summary = QLabel() - self.table = QTableWidget(0, len(_COLUMNS)) - self.table.setHorizontalHeaderLabels(_COLUMNS) - self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.table.horizontalHeader().setStretchLastSection(True) - self.source_box = QComboBox() - self.source_box.addItems(['denoised', 'raw']) - self.ring_form = _ParamsForm(RingParams()) - self.save_button = QPushButton('Save settings') - general = QGroupBox('General') - source = QFormLayout(general) - source.addRow('Volume source', self.source_box) - estimators = QGroupBox('Estimators') - estimator_box = QVBoxLayout(estimators) - self.ring_section = Collapsible('Ring', self.ring_form) - estimator_box.addWidget(self.ring_section) - self.settings = QWidget() - self.settings.setMinimumWidth(self.ring_form.sizeHint().width() + 48) - form = QVBoxLayout(self.settings) - form.setContentsMargins(0, 0, 0, 0) - form.addWidget(heading('Settings')) - form.addWidget(general) - form.addWidget(estimators) - form.addWidget(self.save_button) - form.addStretch() - start = QWidget() - start_layout = QVBoxLayout(start) - start_layout.addStretch() - start_layout.addWidget(heading('No project open'), alignment=Qt.AlignmentFlag.AlignHCenter) - for button in (self.new_button, self.open_button): - button.setMinimumWidth(220) - start_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignHCenter) - start_layout.addStretch() - # page 1: the open project - top = QHBoxLayout() - top.addWidget(self.path_label, 1) - top.addWidget(self.reload_button) - top.addWidget(self.close_button) - left = QVBoxLayout() - left.addWidget(self.summary) - left.addWidget(self.table) - body = QHBoxLayout() - body.addLayout(left, 3) - body.addWidget(self.settings, 1) - opened = QWidget() - opened_layout = QVBoxLayout(opened) - opened_layout.setContentsMargins(0, 0, 0, 0) - opened_layout.addLayout(top) - opened_layout.addLayout(body) - self.stack = QStackedWidget() - self.stack.addWidget(start) - self.stack.addWidget(opened) - layout = QVBoxLayout(self) - tidy(layout) - layout.addWidget(self.stack, 1) - layout.addWidget(self.status) - self.open_button.clicked.connect(self._open_dialog) - self.new_button.clicked.connect(self._new_dialog) - self.reload_button.clicked.connect(self._reload) - self.close_button.clicked.connect(self.close_project) - self.save_button.clicked.connect(self.save_settings) - - # open_config: open a config.toml in the background - def open_config(self, path: Path) -> None: - self._busy(f'Scanning tomograms for {str(path)!r}...') - run_in_background(lambda: open_project(path), self.set_project, self._failed) - - # set_project: show a project and announce it - def set_project(self, project: Project) -> None: - self.project = project - self.path_label.setText(str(project.path)) - self._fill_table(project) - self.source_box.setCurrentText(project.config.source) - self.ring_form.set(project.config.ring) - self.stack.setCurrentIndex(1) - self._idle('') - self.project_changed.emit(project) - - # close_project: close the open project and go back to the start page - def close_project(self) -> None: - self.project = None - self.stack.setCurrentIndex(0) - self.status.setText('') - self.project_closed.emit() - - # save_settings: write source and ring parameters to config.toml - def save_settings(self) -> None: - if self.project is None: - return - try: - self.project.update(source=self.source_box.currentText(), ring=self.ring_form.get()) - except ValidationError as e: - self.status.setText(f'Not saved: {e.errors()[0]["msg"]}') - return - self.status.setText('Saved') - self.config_saved.emit() - - # _fill_table: one row per star row, rejected ones shaded with the reason - def _fill_table(self, project: Project) -> None: - self.table.setRowCount(len(project.names)) - for r, name in enumerate(project.names): - if name in project.tomos: - t = project.tomos[name] - cells = [name, str(t.shape), f'{t.pixel_size:.2f}', 'yes' if t.denoised_path else 'no', 'ok'] - tip = '' - else: - e = project.issues[name] - cells = [ - name, '-' if e.shape is None else str(e.shape), '-' if e.pixel_size is None else f'{e.pixel_size:.2f}', - '-' if e.denoised is None else 'yes' if e.denoised else 'no', f'rejected: {e.reason}', - ] - tip = str(e) - for c, text in enumerate(cells): - item = QTableWidgetItem(text) - item.setToolTip(tip) - if tip: - item.setBackground(QColor(255, 220, 220)) - self.table.setItem(r, c, item) - with_denoised = sum(t.denoised_path is not None for t in project.tomos.values()) - self.summary.setText(f'{len(project.tomos)} usable ({with_denoised} with denoised), {len(project.issues)} rejected') - - # _open_dialog: choose a config.toml - def _open_dialog(self) -> None: - path, _ = QFileDialog.getOpenFileName(self, 'Open config', '', 'TOML (*.toml)') - if path: - self.open_config(Path(path)) - - # _reload: reopen the current config.toml, picking up hand edits and rescanning tomograms - def _reload(self) -> None: - if self.project is not None: - self.open_config(self.project.path) - - # _new_dialog: collect runs, merge them, write the config and open it - def _new_dialog(self) -> None: - dialog = NewProjectDialog(self) - if not dialog.exec(): - return - values = dialog.values() - self._busy('Merging runs and scanning tomograms...') - run_in_background(lambda: _create_and_open(values), self._created, self._failed) - - # _created: show the new project and say what the merge did - def _created(self, made: tuple[Project, MergeResult]) -> None: - project, result = made - self.set_project(project) - self.status.setText(f'Created with {result.count} tomograms, {len(result.skipped)} duplicates skipped') - - # _busy: block the open, create, reload and close buttons while work runs - def _busy(self, message: str) -> None: - for button in (self.open_button, self.new_button, self.reload_button, self.close_button): - button.setEnabled(False) - self.status.setText(message) - - # _idle: unblock the buttons - def _idle(self, message: str) -> None: - for button in (self.open_button, self.new_button, self.reload_button, self.close_button): - button.setEnabled(True) - self.status.setText(message) - - # _failed: show an error from background work - def _failed(self, message: str) -> None: - self._idle(message) - -# _spin: an integer or float spin box for a value -def _spin(value: float) -> QSpinBox | QDoubleSpinBox: - if isinstance(value, int): - box: QSpinBox | QDoubleSpinBox = QSpinBox() - box.setRange(0, 1_000_000) - else: - box = QDoubleSpinBox() - box.setRange(0, 1_000_000) - box.setDecimals(2) - box.setValue(value) - return box - -# _create_and_open: create the project on disk, then open it -def _create_and_open(values: dict[str, object]) -> tuple[Project, MergeResult]: - path, result = create_project(**values) - return open_project(path), result diff --git a/src/pickmix/gui/review_tab.py b/src/pickmix/gui/review_tab.py deleted file mode 100644 index 43ef43d..0000000 --- a/src/pickmix/gui/review_tab.py +++ /dev/null @@ -1,378 +0,0 @@ -''' -pickmix: Review tab, every pick with filters, a preview of the selected pick, and export and extraction per kind -''' - -# Import external dependencies -import numpy as np -from collections.abc import Callable -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QDoubleSpinBox, QGroupBox, QHBoxLayout, QLabel, QMessageBox, - QPlainTextEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, -) - -# Import internal pickmix objects -from pickmix.estimate.registry import Fit -from pickmix.estimates import EstimateService -from pickmix.export import Item, export_kind, recommend_box -from pickmix.extract import extract_kind -from pickmix.geometry import Parent, match_parents -from pickmix.gui.images import to_pixmap -from pickmix.gui.style import tidy -from pickmix.gui.workers import post, run_in_background -from pickmix.loader import Loader -from pickmix.model import Pick, Session - -# _COLUMNS: pick table headings -_COLUMNS: list[str] = ['Tomogram', 'Kind', 'x', 'y', 'z', 'SNR', 'Flag', 'Estimate'] - -# _KIND_COLUMNS: per-kind export table headings -_KIND_COLUMNS: list[str] = ['Kind', 'Picks', 'Estimated', 'Flagged', 'Box (A)', 'Box (px)', 'Extract A/px', 'Min SNR', 'Drop flagged'] - -# _CROP: half-width in voxels of the preview crops -_CROP: int = 64 - -# _PREVIEW: edge in pixels of the preview images -_PREVIEW: int = 256 - -# _ANY_SNR: minimum of the SNR filter, shown as "any" -_ANY_SNR: float = -1000.0 - -# ReviewTab: pick table and filters above, preview beside, export and extraction below -class ReviewTab(QWidget): - # open_pick: a row was double-clicked, tomogram name and pick id - open_pick = Signal(str, int) - - # __init__: build the layout - def __init__(self) -> None: - super().__init__() - self.session: Session | None = None - self.loader: Loader | None = None - self.service: EstimateService | None = None - self._shown: list[Pick] = [] - self._token = 0 - self._timer = QTimer(self) - self._timer.setSingleShot(True) - self._timer.setInterval(200) - self._timer.timeout.connect(self.refresh) - self.kind_filter = QComboBox() - self.tomo_filter = QComboBox() - self.flagged_box = QCheckBox('Flagged only') - self.snr_spin = QDoubleSpinBox() - self.snr_spin.setRange(_ANY_SNR, 1000.0) - self.snr_spin.setValue(_ANY_SNR) - self.snr_spin.setSpecialValueText('any') - self.count_label = QLabel() - self.delete_button = QPushButton('Delete shown flagged') - self.reestimate_button = QPushButton('Re-estimate all') - self.table = QTableWidget(0, len(_COLUMNS)) - self.table.setHorizontalHeaderLabels(_COLUMNS) - self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self.xy_label = QLabel('xy') - self.xz_label = QLabel('xz') - for label in (self.xy_label, self.xz_label): - label.setFixedSize(_PREVIEW, _PREVIEW) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - label.setStyleSheet('background: #141414; color: #888') - self.kinds_table = QTableWidget(0, len(_KIND_COLUMNS)) - self.kinds_table.setHorizontalHeaderLabels(_KIND_COLUMNS) - self.kinds_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.kinds_table.setMaximumHeight(160) - self._spins: dict[str, QDoubleSpinBox] = {} - self._drops: dict[str, QCheckBox] = {} - self.export_button = QPushButton('Export star files') - self.extract_button = QPushButton('Extract boxes') - self.log = QPlainTextEdit() - self.log.setReadOnly(True) - self.log.setMaximumHeight(110) - filters = QHBoxLayout() - for text, widget in (('Kind', self.kind_filter), ('Tomogram', self.tomo_filter), ('Min SNR', self.snr_spin)): - filters.addWidget(QLabel(text)) - filters.addWidget(widget) - filters.addWidget(self.flagged_box) - filters.addWidget(self.count_label, 1) - filters.addWidget(self.delete_button) - filters.addWidget(self.reestimate_button) - previews = QVBoxLayout() - previews.addWidget(self.xy_label) - previews.addWidget(self.xz_label) - previews.addStretch() - body = QHBoxLayout() - body.addWidget(self.table, 1) - body.addLayout(previews) - buttons = QHBoxLayout() - buttons.addWidget(self.export_button) - buttons.addWidget(self.extract_button) - buttons.addStretch() - export = QGroupBox('Export and extract') - box = QVBoxLayout(export) - box.addWidget(self.kinds_table) - box.addLayout(buttons) - box.addWidget(self.log) - layout = QVBoxLayout(self) - tidy(layout) - layout.addLayout(filters) - layout.addLayout(body, 1) - layout.addWidget(export) - for combo in (self.kind_filter, self.tomo_filter): - combo.currentIndexChanged.connect(self.refresh) - self.flagged_box.toggled.connect(self.refresh) - self.snr_spin.valueChanged.connect(self.refresh) - self.table.itemSelectionChanged.connect(self._preview) - self.table.cellDoubleClicked.connect(self._double_clicked) - self.delete_button.clicked.connect(self._ask_delete_flagged) - self.reestimate_button.clicked.connect(self._reestimate) - self.export_button.clicked.connect(lambda: self._run(self.export_all)) - self.extract_button.clicked.connect(lambda: self._run(self.extract_all)) - - # set_session: show a session and follow its picks and estimates - def set_session(self, session: Session, loader: Loader, service: EstimateService) -> None: - self.session, self.loader, self.service = session, loader, service - session.store.listeners.append(self._schedule) - service.store.listeners.append(self._schedule) - self.refresh_kinds() - - # refresh_kinds: rebuild the kind filter and the export table from the config - def refresh_kinds(self) -> None: - if self.session is None: - return - kinds = self.session.project.config.kinds - for combo, names in ((self.kind_filter, list(kinds)), (self.tomo_filter, self.session.names)): - current = combo.currentText() - combo.blockSignals(True) - combo.clear() - combo.addItems(['(all)', *names]) - combo.setCurrentText(current if current in names else '(all)') - combo.blockSignals(False) - self._spins, self._drops = {}, {} - self.kinds_table.setRowCount(len(kinds)) - for r, (name, kind) in enumerate(kinds.items()): - for c in range(len(_KIND_COLUMNS)): - self.kinds_table.setItem(r, c, QTableWidgetItem()) - self.kinds_table.item(r, 0).setText(name) - self.kinds_table.item(r, 6).setText(f'{kind.extract_angpix:g}') - spin = QDoubleSpinBox() - spin.setRange(_ANY_SNR, 1000.0) - spin.setValue(_ANY_SNR) - spin.setSpecialValueText('any') - drop = QCheckBox() - self.kinds_table.setCellWidget(r, 7, spin) - self.kinds_table.setCellWidget(r, 8, drop) - self._spins[name], self._drops[name] = spin, drop - self.refresh() - - # refresh: rebuild the pick table with the filters applied, and the per-kind numbers - def refresh(self) -> None: - if self.session is None or self.service is None: - return - selected = self._shown[self.table.currentRow()].id if 0 <= self.table.currentRow() < len(self._shown) else None - rows = self._filtered() - self._shown = [p for p, _ in rows] - self.table.blockSignals(True) - self.table.setRowCount(len(rows)) - for r, (pick, fit) in enumerate(rows): - state = self.service.store.state(pick) - cells = [ - pick.tomogram, pick.kind, f'{pick.x:.1f}', f'{pick.y:.1f}', f'{pick.z:.1f}', - '' if fit is None else f'{fit.snr:.2f}', '' if fit is None else fit.flag, state, - ] - for c, text in enumerate(cells): - self.table.setItem(r, c, QTableWidgetItem(text)) - if selected in [p.id for p in self._shown]: - self.table.selectRow([p.id for p in self._shown].index(selected)) - self.table.blockSignals(False) - self.count_label.setText(f'{len(rows)} shown of {self.session.store.total()}') - for r, (name, kind) in enumerate(self.session.project.config.kinds.items()): - items = self._items(name) - box_a, box_px = recommend_box(kind, items) - for c, text in ((1, str(self.session.store.total(name))), (2, str(len(items))), (3, str(sum(bool(f.flag) for _, f in items))), - (4, f'{box_a:.0f}'), (5, str(box_px))): - self.kinds_table.item(r, c).setText(text) - - # delete_flagged: remove the flagged picks currently shown - def delete_flagged(self) -> int: - assert self.session is not None and self.service is not None - doomed = [p.id for p, f in self._filtered() if f is not None and f.flag] - if doomed: - self.session.store.remove(*doomed) - return len(doomed) - - # export_all: write star files for every kind with current estimates; returns log lines. Runs on a worker thread, so the widget settings are passed in - def export_all(self, settings: dict[str, tuple[float, bool]] | None = None) -> list[str]: - assert self.session is not None - settings = settings or self._settings() - lines = self._preflight() - config = self.session.project.config - for name, kind in config.kinds.items(): - items = self._selected(name, settings) - if not items: - lines.append(f'{name}: nothing to export') - continue - try: - s = export_kind(name, kind, items, self.session.project.tomos, config.out_dir, min_snr=settings[name][0], drop_flagged=settings[name][1], parents=self._matched(name)) - except ValueError as e: - lines.append(str(e)) - continue - lines.append( - f'{name}: wrote {s.written} particles ({s.below_snr} below min SNR, {s.dropped_flagged} flagged dropped), ' - f'box {s.box_a:.0f} A = {s.box_px} px at {kind.extract_angpix:g} A/px, in {str(s.folder)!r}' - ) - return lines - - # extract_all: cut boxes for every kind with current estimates; returns log lines. Runs on a worker thread - def extract_all(self, settings: dict[str, tuple[float, bool]] | None = None) -> list[str]: - assert self.session is not None - settings = settings or self._settings() - lines = self._preflight() - config = self.session.project.config - for name, kind in config.kinds.items(): - items = self._selected(name, settings) - if not items: - lines.append(f'{name}: nothing to extract') - continue - try: - s = extract_kind(name, kind, items, self.session.project.tomos, config, log=lines.append) - except ValueError as e: - lines.append(str(e)) - continue - lines.append(f'{name}: wrote {s.written} boxes of {s.box_px} px at {s.pixel_size:.2f} A/px, in {str(s.folder)!r}') - return lines - - # _schedule: coalesce bursts of changes, from any thread, into one refresh - def _schedule(self) -> None: - post(lambda: self._timer.start()) - - # _items: picks of a kind that have a current fit - def _items(self, kind: str) -> list[Item]: - assert self.session is not None and self.service is not None - matched = self._matched(kind) - out = [] - for pick in self.session.store.all(kind): - fit = self.service.store.get(pick) - if fit is not None: - out.append((pick, self._flagged(kind, pick, fit, matched))) - return out - - # _matched: parent geometry for every pick of a kind, empty for a kind with no parent - def _matched(self, kind: str) -> dict[int, Parent]: - assert self.session is not None and self.service is not None - return match_parents(kind, self.session.project.config, self.session.store, self.service.store, self.session.project.tomos) - - # _flagged: the fit with noparent added to its flags when the pick's kind has a parent and the pick found none - def _flagged(self, kind: str, pick: Pick, fit: Fit, matched: dict[int, Parent]) -> Fit: - if self.session.project.config.kinds[kind].parent is None or pick.id in matched: - return fit - return fit.model_copy(update={'flag': ','.join(filter(None, [fit.flag, 'noparent']))}) - - # _settings: minimum SNR and drop-flagged choice per kind from the export table; read on the GUI thread - def _settings(self) -> dict[str, tuple[float, bool]]: - return {n: (-np.inf if s.value() <= _ANY_SNR else s.value(), self._drops[n].isChecked()) for n, s in self._spins.items()} - - # _selected: current fits of a kind that pass its SNR and flag settings - def _selected(self, kind: str, settings: dict[str, tuple[float, bool]]) -> list[Item]: - min_snr, drop = settings[kind] - return [(p, f) for p, f in self._items(kind) if f.snr >= min_snr and not (drop and f.flag)] - - # _preflight: warnings about picks the export will skip - def _preflight(self) -> list[str]: - assert self.session is not None - lines = [] - for name, kind in self.session.project.config.kinds.items(): - items = self._items(name) - missing = self.session.store.total(name) - len(items) - if missing: - lines.append(f'{name}: {missing} picks have no current estimate and are skipped (wait for estimating, or Re-estimate all)') - orphans = sum('noparent' in f.flag for _, f in items) - if orphans: - lines.append(f'{name}: {orphans} picks have no {kind.parent} within {kind.parent_tolerance_a:g} A and are skipped') - return lines - - # _filtered: picks of known kinds passing the filters, in tomogram then kind order, with their current fit - def _filtered(self) -> list[tuple[Pick, Fit | None]]: - assert self.session is not None and self.service is not None - kind = self.kind_filter.currentText() - tomo = self.tomo_filter.currentText() - min_snr = self.snr_spin.value() - order = {n: i for i, n in enumerate(self.session.names)} - kinds = {k: i for i, k in enumerate(self.session.project.config.kinds)} - matched = {k: self._matched(k) for k in kinds} - out = [] - for pick in self.session.store.all(): - if pick.kind not in kinds or (kind != '(all)' and pick.kind != kind) or (tomo != '(all)' and pick.tomogram != tomo): - continue - fit = self.service.store.get(pick) - if fit is not None: - fit = self._flagged(pick.kind, pick, fit, matched[pick.kind]) - flagged = fit is not None and bool(fit.flag) - if (self.flagged_box.isChecked() and not flagged) or (min_snr > _ANY_SNR and (fit is None or fit.snr < min_snr)): - continue - out.append((pick, fit)) - out.sort(key=lambda pf: (order.get(pf[0].tomogram, len(order)), kinds[pf[0].kind])) - return out - - # _ask_delete_flagged: confirm, then delete the flagged picks shown - def _ask_delete_flagged(self) -> None: - n = sum(1 for _, f in self._filtered() if f is not None and f.flag) - if n and QMessageBox.question(self, 'Delete picks', f'Delete {n} flagged picks?') == QMessageBox.StandardButton.Yes: - self.delete_flagged() - - # _reestimate: queue estimates for everything that needs one - def _reestimate(self) -> None: - if self.service is not None: - self.service.request_all() - - # _run: run an export or extraction job in the background, logging its lines - def _run(self, job: Callable[[dict[str, tuple[float, bool]]], list[str]]) -> None: - self.export_button.setEnabled(False) - self.extract_button.setEnabled(False) - settings = self._settings() - run_in_background(lambda: job(settings), self._logged, lambda message: self._logged([message])) - - # _logged: show job output and unblock the buttons - def _logged(self, lines: list[str]) -> None: - self.log.appendPlainText('\n'.join(lines)) - self.export_button.setEnabled(True) - self.extract_button.setEnabled(True) - - # _double_clicked: open the pick in the Pick tab - def _double_clicked(self, row: int, _col: int) -> None: - if 0 <= row < len(self._shown): - self.open_pick.emit(self._shown[row].tomogram, self._shown[row].id) - - # _preview: crops of the selected pick, read off the GUI thread - def _preview(self) -> None: - row = self.table.currentRow() - if self.loader is None or self.service is None or not 0 <= row < len(self._shown): - return - pick = self._shown[row] - fit = self.service.store.get(pick) - centre = (fit.x, fit.y, fit.z) if fit else (pick.x, pick.y, pick.z) - self._token += 1 - token = self._token - run_in_background( - lambda: self.crops(pick.tomogram, centre), - lambda crops: self._show_preview(token, crops), - lambda message: self.xy_label.setText(message), - ) - - # crops: (xy, xz) arrays around a centre: the slice through it, and the plane through it along z - def crops(self, tomogram: str, centre: tuple[float, float, float]) -> tuple[np.ndarray, np.ndarray]: - assert self.loader is not None - vol = self.loader.open(tomogram).result().vol - x, y, z = (round(v) for v in centre) - z = min(max(z, 0), vol.shape[0] - 1) - xs = slice(max(x - _CROP, 0), x + _CROP) - ys = slice(max(y - _CROP, 0), y + _CROP) - zs = slice(max(z - _CROP, 0), z + _CROP) - xy = self.loader.slice(tomogram, z)[ys, xs] - xz = np.asarray(vol[zs, min(max(y, 0), vol.shape[1] - 1), xs], dtype=np.float32) - return xy, xz - - # _show_preview: draw the crops unless a newer selection has been made - def _show_preview(self, token: int, crops: tuple[np.ndarray, np.ndarray]) -> None: - if token != self._token: - return - self.xy_label.setPixmap(to_pixmap(crops[0], _PREVIEW)) - self.xz_label.setPixmap(to_pixmap(crops[1], _PREVIEW)) diff --git a/src/pickmix/gui/style.py b/src/pickmix/gui/style.py index 00ff1e7..14aba65 100644 --- a/src/pickmix/gui/style.py +++ b/src/pickmix/gui/style.py @@ -3,23 +3,55 @@ ''' # Import external dependencies -from pathlib import Path -from PySide6.QtCore import Qt -from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QLayout, QWidget +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QColor, QIcon +from PySide6.QtWidgets import QAbstractItemView, QApplication, QHBoxLayout, QLabel, QLayout, QPushButton, QScrollArea, QTableWidget, QToolButton, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.gui.components.icons import icon, tinted # MARGIN, SPACING: outer margin and gap between widgets, pixels MARGIN: int = 8 SPACING: int = 6 +# CELL_PADDING: horizontal padding in pixels inside every table cell and heading +CELL_PADDING: int = 14 + +# RADIUS: corner radius of rounded buttons, pixels +RADIUS: int = 16 + +# ICON_COLOUR: colour of every action icon +ICON_COLOUR: str = '#ffffff' + +# MEDIUM_PAD: pixels added to a medium button's icon edge to get its square size +MEDIUM_PAD: int = 16 + +# BIG_ICON, BIG_GAP: edge in pixels of the icon on a big button, and the pixels between it and the text +BIG_ICON: int = 48 +BIG_GAP: int = 8 + +# OK, WARN, ERROR, RUNNING: state colours, shared by status icons, table marks and anything else that shows a state +OK: str = '#90a959' +WARN: str = '#e9b872' +ERROR: str = '#a63d40' +RUNNING: str = '#6494aa' + +# MUTED: grey of hints and of anything dimmed or unavailable +MUTED: str = '#808080' + +# LABEL_BG, OVERLAY_TEXT: translucent dark box and white text drawn over images +LABEL_BG: QColor = QColor(0, 0, 0, 150) +OVERLAY_TEXT: QColor = QColor('white') + +# WELL_BG, WELL_TEXT: dark backdrop and dim placeholder text of the frames that hold images +WELL_BG: str = '#141414' +WELL_TEXT: str = '#888' + # TITLE_STEP, HEADING_STEP, HINT_STEP: point-size offsets from the application font for each text level TITLE_STEP: float = 4.0 HEADING_STEP: float = 1.0 HINT_STEP: float = -1.0 -# ICON_DIR: bundled icon files -ICON_DIR: Path = Path(__file__).parent / 'icons' - # _SMALL_NATIVE: widget classes whose native font is smaller than a label's _SMALL_NATIVE: tuple[str, ...] = ('QGroupBox', 'QToolButton', 'QHeaderView') @@ -31,11 +63,6 @@ def body_pt() -> float: def standardise(app: QApplication) -> None: app.setStyleSheet(f'{", ".join(_SMALL_NATIVE)} {{ font-size: {body_pt()}pt; }}') -# icon: a bundled icon by file stem, or None when its file is not there so the caller can fall back to text -def icon(stem: str) -> QIcon | None: - path = ICON_DIR / f'{stem}.svg' - return QIcon(str(path)) if path.exists() else None - # help_marker: the question mark icon, or (?) without its file, explaining itself on hover def help_marker(text: str) -> QLabel: label = QLabel() @@ -56,6 +83,22 @@ def eye_toggle(button: QPushButton, shown: bool) -> None: else: button.setIcon(eye) +# view_toggle: a checkable toggle named after what it shows, its eye icon open when shown and crossed when hidden +def view_toggle(name: str) -> QToolButton: + button = QToolButton() + button.setText(name) + button.setCheckable(True) + button.setChecked(True) + button.setAutoRaise(True) + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setStyleSheet('QToolButton { border: none; background: transparent; }') + button.setToolTip(f'Show or hide {name.lower()}') + button.toggled.connect(lambda shown, b=button, n=name: _view_look(b, n, shown)) + _view_look(button, name, True) + return button + # form_label: "Title (?):" for the label column of a form, the marker explaining the field on hover def form_label(title: str, tip: str) -> QWidget: label = QLabel(title) @@ -86,9 +129,13 @@ def heading(text: str = '') -> QLabel: # hint: small grey label, for help text def hint(text: str = '') -> QLabel: label = _styled(text, HINT_STEP, bold=False) - label.setStyleSheet('color: gray') + label.setStyleSheet(f'color: {MUTED}') return label +# error_look: the error colour for a label that shows a problem +def error_look(label: QLabel) -> None: + label.setStyleSheet(f'color: {ERROR}') + # bold_safe: style a checkable text button so it is bold when checked without changing its width def bold_safe(button: QPushButton) -> None: plain = 'QPushButton { border: none; background: transparent; padding: 2px 4px; }' @@ -98,6 +145,94 @@ def bold_safe(button: QPushButton) -> None: button.setMinimumWidth(button.sizeHint().width()) button.setStyleSheet(plain + 'QPushButton:checked { font-weight: bold; }') +# rounded: give a button rounded corners in the theme colours; a stylesheet is needed for any radius +def rounded(button: QPushButton) -> None: + button.setStyleSheet( + f'QPushButton {{ background: palette(button); border: 1px solid palette(mid); border-radius: {RADIUS}px; }}' + 'QPushButton:hover { background: palette(midlight); }' + 'QPushButton:pressed { background: palette(mid); }' + ) + +# toast_look: dark translucent rounded box with white text +def toast_look(label: QLabel) -> None: + label.setStyleSheet('background: rgba(40, 40, 40, 230); color: white; border-radius: 6px; padding: 8px 12px;') + +# MEDIUM_ICON, MEDIUM_SIZE: icon edge and (width, height) of a medium icon-over-text button, pixels +MEDIUM_ICON: int = 28 +MEDIUM_SIZE: tuple[int, int] = (170, 90) + +# big_button: icon over text with a fixed gap when the icon file exists, text alone otherwise +def big_button(text: str, stem: str) -> QPushButton: + return _icon_over_text(text, stem, BIG_ICON, (200, 150), minimum=True) + +# medium_button: the same look at a smaller, fixed size, for actions inside a page +def medium_button(text: str, stem: str) -> QPushButton: + return _icon_over_text(text, stem, MEDIUM_ICON, MEDIUM_SIZE, minimum=False) + +# _icon_over_text: a rounded button holding a white icon over a heading; minimum sets a minimum size, otherwise the size is fixed +def _icon_over_text(text: str, stem: str, icon_px: int, size: tuple[int, int], *, minimum: bool) -> QPushButton: + button = QPushButton() + (button.setMinimumSize if minimum else button.setFixedSize)(*size) + rounded(button) + inner = QVBoxLayout(button) + inner.setSpacing(BIG_GAP) + inner.setAlignment(Qt.AlignmentFlag.AlignCenter) + found = tinted(stem, colour='#ffffff', size=icon_px) + if found is not None: + picture = QLabel() + picture.setPixmap(found.pixmap(QSize(icon_px, icon_px))) + picture.setAlignment(Qt.AlignmentFlag.AlignCenter) + inner.addWidget(picture) + label = heading(text) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + inner.addWidget(label) + # clicks fall through to the button + for child in button.findChildren(QLabel): + child.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + return button + +# white: a bundled icon redrawn in the action colour, or None when its file is not there +def white(stem: str, size: int = 16) -> QIcon | None: + return tinted(stem, colour=ICON_COLOUR, size=size) + +# icon_button: an icon-only white button with a tooltip, or a text button when the icon file is missing; size makes it a rounded square of that icon edge plus padding +def icon_button(stem: str, text: str, tip: str, *, size: int | None = None) -> QPushButton: + found = white(stem, size or 16) + button = QPushButton() if found is not None else QPushButton(text) + if found is not None: + button.setIcon(found) + if size is not None: + button.setIconSize(QSize(size, size)) + button.setFixedSize(size + MEDIUM_PAD, size + MEDIUM_PAD) + rounded(button) + button.setToolTip(tip) + return button + +# image_well: a fixed square frame for an image, dark with its placeholder text centred +def image_well(label: QLabel, size: int) -> None: + label.setFixedSize(size, size) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet(f'background: {WELL_BG}; color: {WELL_TEXT}') + +# pad_cells: the quiet table look, with padded cells and headings, no heading borders and left-aligned heading text +def pad_cells(table: QTableWidget) -> None: + table.setStyleSheet(f'QTableWidget::item {{ padding: 0 {CELL_PADDING}px; }} QHeaderView::section {{ padding: 4px {CELL_PADDING}px; border: none; text-align: left; }}') + +# quiet_table: the Project > Tomograms look for any table: no grid, no row numbers, whole-row selection, padded cells, left-aligned headings +def quiet_table(table: QTableWidget, *, editable: bool = False) -> None: + table.verticalHeader().hide() + table.setShowGrid(False) + table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + table.setEditTriggers(QAbstractItemView.EditTrigger.AllEditTriggers if editable else QAbstractItemView.EditTrigger.NoEditTriggers) + table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + pad_cells(table) + +# transparent_scroll: a scroll area that shows what is behind it instead of painting the window colour; call after setWidget +def transparent_scroll(scroll: QScrollArea) -> None: + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + scroll.viewport().setAutoFillBackground(False) + scroll.widget().setAutoFillBackground(False) + # _styled: a label in the application font shifted by step points def _styled(text: str, step: float, *, bold: bool) -> QLabel: label = QLabel(text) @@ -106,3 +241,11 @@ def _styled(text: str, step: float, *, bold: bool) -> QLabel: font.setBold(bold) label.setFont(font) return label + +# _view_look: the eye icon for a toggle's state, or the word hidden after the name without the icon files +def _view_look(button: QToolButton, name: str, shown: bool) -> None: + eye = icon('visibility' if shown else 'visibility_off') + if eye is None: + button.setText(name if shown else f'{name} (hidden)') + else: + button.setIcon(eye) diff --git a/src/pickmix/gui/tabs/export_tab.py b/src/pickmix/gui/tabs/export_tab.py new file mode 100644 index 0000000..2de7575 --- /dev/null +++ b/src/pickmix/gui/tabs/export_tab.py @@ -0,0 +1,307 @@ +''' +pickmix: Export tab, averages of each kind and RELION export and box extraction per kind +''' + +# Import external dependencies +import numpy as np +from collections.abc import Callable +from PySide6.QtCore import QEvent, QObject, Qt, QTimer +from PySide6.QtGui import QColor, QShowEvent +from PySide6.QtWidgets import ( + QCheckBox, QDoubleSpinBox, QGridLayout, QGroupBox, QHBoxLayout, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.estimates import EstimateService +from pickmix.export import Item, export_csv, export_kind, recommend_box +from pickmix.extract import extract_kind +from pickmix.geometry import Parent +from pickmix.gui.components.icons import icon +from pickmix.gui.components.status import StatusBadge +from pickmix.gui.components.toast import notify +from pickmix.gui.panes.average_pane import AveragePane +from pickmix.gui.style import MUTED, help_marker, medium_button, quiet_table, tidy +from pickmix.gui.workers import post, run_in_background +from pickmix.items import items, matched +from pickmix.loader import Loader +from pickmix.model import Session + +# _KIND_COLUMNS: per-kind export table headings +_KIND_COLUMNS: list[str] = ['Kind', 'Picks', 'Estimated', 'Flagged', 'Box (A)', 'Box (px)', 'Extract A/px', 'Min SNR', 'Include flagged'] + +# _EDITABLE: the columns you can change; the rest are read-only +_EDITABLE: tuple[int, ...] = (7, 8) + +# _ANY_SNR: minimum of the SNR filter, shown as "any" +_ANY_SNR: float = -1000.0 + +# _STAR_TIP, _CSV_TIP: what each export format writes, shown on the box and on its (?) marker +_STAR_TIP: str = 'RELION particles.star and one coordinates file per tomogram, in out/relion/' +_CSV_TIP: str = 'One picks and fits table per kind, in out/csv' + +# Settings: per kind, the minimum SNR and whether flagged picks are included +type Settings = dict[str, tuple[float, bool]] + +# ExportTab: the Average pane above the per-kind export and extraction table +class ExportTab(QWidget): + # __init__: build the layout + def __init__(self) -> None: + super().__init__() + self.session: Session | None = None + self.loader: Loader | None = None + self.service: EstimateService | None = None + self._running = False + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(200) + self._timer.timeout.connect(self.refresh) + self.average = AveragePane() + self.kinds_table = QTableWidget(0, len(_KIND_COLUMNS)) + self.kinds_table.setHorizontalHeaderLabels(_KIND_COLUMNS) + quiet_table(self.kinds_table) + self.kinds_table.setMinimumHeight(120) + # columns are as wide as their heading and contents need, plus an equal share of any spare width (see _fit_columns) + self.kinds_table.viewport().installEventFilter(self) + # editable columns say so in their headers; read-only ones are greyed below + pencil = icon('edit') + for c in _EDITABLE: + header = self.kinds_table.horizontalHeaderItem(c) + header.setToolTip('Editable: this setting applies to the export and extraction') + if pencil is not None: + header.setIcon(pencil) + self._spins: dict[str, QDoubleSpinBox] = {} + self._includes: dict[str, QCheckBox] = {} + self.star_box = QCheckBox('.star') + self.csv_box = QCheckBox('.csv') + for box in (self.star_box, self.csv_box): + box.setChecked(True) + self.star_box.setToolTip(_STAR_TIP) + self.csv_box.setToolTip(_CSV_TIP) + self.export_button = medium_button('Export', 'download') + self.extract_button = medium_button('Extract boxes', 'crop') + self.extract_button.setToolTip('Cut the boxes as MRC files, in out/particles/') + self.spinner = StatusBadge() + # formats above Export; the two buttons share a row so they line up + formats = QHBoxLayout() + formats.addWidget(self.star_box) + formats.addWidget(help_marker(_STAR_TIP)) + formats.addSpacing(12) + formats.addWidget(self.csv_box) + formats.addWidget(help_marker(_CSV_TIP)) + formats.addStretch() + grid = QGridLayout() + grid.setHorizontalSpacing(24) + grid.addLayout(formats, 0, 0) + grid.addWidget(self.export_button, 1, 0) + grid.addWidget(self.extract_button, 1, 1) + grid.addWidget(self.spinner, 1, 2, Qt.AlignmentFlag.AlignVCenter) + buttons = QHBoxLayout() + buttons.addStretch() + buttons.addLayout(grid) + buttons.addStretch() + export = QGroupBox('Export and extract') + box = QVBoxLayout(export) + box.addWidget(self.kinds_table, 1) + box.addLayout(buttons) + layout = QVBoxLayout(self) + tidy(layout) + # both groups take spare height; inside Export only the table grows, the formats and buttons keep their size + layout.addWidget(self.average, 3) + layout.addWidget(export, 2) + self.export_button.clicked.connect(self._export) + self.extract_button.clicked.connect(lambda: self._run(self.extract_all, 'Extracting boxes...')) + self.star_box.toggled.connect(self._sync_buttons) + self.csv_box.toggled.connect(self._sync_buttons) + + # showEvent: headings are only measured right once the table is styled, so the columns are fitted as the tab appears + def showEvent(self, event: QShowEvent) -> None: + self.kinds_table.ensurePolished() + self._fit_columns() + super().showEvent(event) + + # eventFilter: fit the columns again when the table is resized + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched is self.kinds_table.viewport() and event.type() == QEvent.Type.Resize: + self._fit_columns() + return super().eventFilter(watched, event) + + # set_session: show a session and follow its picks and estimates + def set_session(self, session: Session, loader: Loader, service: EstimateService) -> None: + self.session, self.loader, self.service = session, loader, service + session.store.listeners.append(self._schedule) + service.store.listeners.append(self._schedule) + self.average.set_session(session, service) + self.refresh_kinds() + + # clear: no project or no session, so nothing to show + def clear(self) -> None: + self.session = self.loader = self.service = None + self.average.set_session(None, None) + self.kinds_table.setRowCount(0) + self._spins, self._includes = {}, {} + + # refresh_kinds: rebuild the export table and the average kind list from the config + def refresh_kinds(self) -> None: + if self.session is None: + return + kinds = self.session.project.config.kinds + self._spins, self._includes = {}, {} + self.kinds_table.setRowCount(len(kinds)) + for r, (name, kind) in enumerate(kinds.items()): + for c in range(len(_KIND_COLUMNS)): + item = QTableWidgetItem() + if c not in _EDITABLE: + item.setForeground(QColor(MUTED)) + self.kinds_table.setItem(r, c, item) + self.kinds_table.item(r, 0).setText(name) + self.kinds_table.item(r, 6).setText(f'{kind.extract_angpix:g}') + spin = QDoubleSpinBox() + spin.setRange(_ANY_SNR, 1000.0) + spin.setValue(_ANY_SNR) + spin.setSpecialValueText('any') + include = QCheckBox() + include.setChecked(True) + self.kinds_table.setCellWidget(r, 7, spin) + self.kinds_table.setCellWidget(r, 8, include) + self._spins[name], self._includes[name] = spin, include + self.average.refresh_kinds() + self.refresh() + self._sync_buttons() + self._fit_columns() + + # refresh: the per-kind numbers + def refresh(self) -> None: + if self.session is None or self.service is None: + return + for r, (name, kind) in enumerate(self.session.project.config.kinds.items()): + kind_items = self._items(name) + box_a, box_px = recommend_box(kind, kind_items) + for c, text in ((1, str(self.session.store.total(name))), (2, str(len(kind_items))), (3, str(sum(bool(f.flag) for _, f in kind_items))), + (4, f'{box_a:.0f}'), (5, str(box_px))): + self.kinds_table.item(r, c).setText(text) + + # export_all: write the ticked formats for every kind with current estimates; returns message lines. Runs on a worker thread, so the widget settings are passed in + def export_all(self, settings: Settings | None = None, formats: frozenset[str] | None = None) -> list[str]: + assert self.session is not None + settings = settings or self._settings() + formats = self._formats() if formats is None else formats + lines = self._preflight() + config = self.session.project.config + for name, kind in config.kinds.items(): + selected = self._selected(name, settings) + if not selected: + lines.append(f'{name}: nothing to export') + continue + min_snr, include = settings[name] + if 'star' in formats: + try: + s = export_kind(name, kind, selected, self.session.project.tomos, config.out_dir, min_snr=min_snr, drop_flagged=not include, parents=self._matched(name)) + except ValueError as e: + lines.append(str(e)) + else: + lines.append( + f'{name}: wrote {s.written} particles ({s.below_snr} below min SNR, {s.dropped_flagged} flagged dropped), ' + f'box {s.box_a:.0f} A = {s.box_px} px at {kind.extract_angpix:g} A/px, in {str(s.folder)!r}' + ) + if 'csv' in formats: + try: + c = export_csv(name, selected, config.out_dir, min_snr=min_snr, include_flagged=include) + except ValueError as e: + lines.append(str(e)) + else: + lines.append(f'{name}: wrote {c.written} picks to {str(c.path)!r}') + return lines + + # extract_all: cut boxes for every kind with current estimates; returns message lines. Runs on a worker thread + def extract_all(self, settings: Settings | None = None) -> list[str]: + assert self.session is not None + settings = settings or self._settings() + lines = self._preflight() + config = self.session.project.config + for name, kind in config.kinds.items(): + selected = self._selected(name, settings) + if not selected: + lines.append(f'{name}: nothing to extract') + continue + try: + s = extract_kind(name, kind, selected, self.session.project.tomos, config, log=lines.append) + except ValueError as e: + lines.append(str(e)) + continue + lines.append(f'{name}: wrote {s.written} boxes of {s.box_px} px at {s.pixel_size:.2f} A/px, in {str(s.folder)!r}') + return lines + + # _fit_columns: every column as wide as its heading and contents need, with any spare width shared equally so the table fills its width + def _fit_columns(self) -> None: + table, header = self.kinds_table, self.kinds_table.horizontalHeader() + needs = [max(header.sectionSizeHint(c), table.sizeHintForColumn(c)) for c in range(header.count())] + spare = max(0, (table.viewport().width() - sum(needs)) // max(len(needs), 1)) + for c, need in enumerate(needs): + header.resizeSection(c, need + spare) + + # _schedule: coalesce bursts of changes, from any thread, into one refresh + def _schedule(self) -> None: + post(lambda: self._timer.start()) + + # _items: picks of a kind that have a current fit + def _items(self, kind: str) -> list[Item]: + assert self.session is not None and self.service is not None + return items(self.session, self.service, kind) + + # _matched: parent geometry for every pick of a kind, empty for a kind with no parent + def _matched(self, kind: str) -> dict[int, Parent]: + assert self.session is not None and self.service is not None + return matched(self.session, self.service, kind) + + # _settings: minimum SNR and include-flagged choice per kind from the export table; read on the GUI thread + def _settings(self) -> Settings: + return {n: (-np.inf if s.value() <= _ANY_SNR else s.value(), self._includes[n].isChecked()) for n, s in self._spins.items()} + + # _formats: the export formats that are ticked; read on the GUI thread + def _formats(self) -> frozenset[str]: + return frozenset(f for f, box in (('star', self.star_box), ('csv', self.csv_box)) if box.isChecked()) + + # _selected: current fits of a kind that pass its SNR and flag settings + def _selected(self, kind: str, settings: Settings) -> list[Item]: + min_snr, include = settings[kind] + return [(p, f) for p, f in self._items(kind) if f.snr >= min_snr and (include or not f.flag)] + + # _preflight: warnings about picks the export will skip + def _preflight(self) -> list[str]: + assert self.session is not None + lines = [] + for name, kind in self.session.project.config.kinds.items(): + kind_items = self._items(name) + missing = self.session.store.total(name) - len(kind_items) + if missing: + lines.append(f'{name}: {missing} picks have no current estimate and are skipped (wait for estimating, or Re-estimate all on the Review tab)') + orphans = sum('noparent' in f.flag for _, f in kind_items) + if orphans: + lines.append(f'{name}: {orphans} picks have no {kind.parent} within {kind.parent_tolerance_a:g} A and are skipped') + return lines + + # _export: export in the ticked formats, in the background + def _export(self) -> None: + formats = self._formats() + self._run(lambda settings: self.export_all(settings, formats), 'Exporting...') + + # _run: run an export or extraction job in the background; its lines go to stdout and the toast + def _run(self, job: Callable[[Settings], list[str]], message: str) -> None: + self._running = True + self._sync_buttons() + self.spinner.set_status('running', message) + settings = self._settings() + run_in_background(lambda: job(settings), self._logged, lambda error: self._logged([error], saved=False)) + + # _logged: report job output and unblock the buttons; saved says the job finished rather than failed + def _logged(self, lines: list[str], *, saved: bool = True) -> None: + self._running = False + self.spinner.set_status(None) + self._sync_buttons() + notify('\n'.join(lines), saved=saved) + + # _sync_buttons: nothing runs twice at once, and Export needs a format ticked + def _sync_buttons(self) -> None: + self.export_button.setEnabled(not self._running and bool(self._formats())) + self.extract_button.setEnabled(not self._running) diff --git a/src/pickmix/gui/pick_tab.py b/src/pickmix/gui/tabs/pick_tab.py similarity index 70% rename from src/pickmix/gui/pick_tab.py rename to src/pickmix/gui/tabs/pick_tab.py index 664b45f..be1602d 100644 --- a/src/pickmix/gui/pick_tab.py +++ b/src/pickmix/gui/tabs/pick_tab.py @@ -6,18 +6,20 @@ import numpy as np from concurrent.futures import Future from PySide6.QtCore import Qt -from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( - QButtonGroup, QCheckBox, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, - QScrollBar, QSlider, QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, + QButtonGroup, QCheckBox, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QScrollBar, QSlider, QSplitter, QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, ) # Import internal pickmix objects from pickmix.estimates import EstimateService from pickmix.geometry import Parent, match_parents -from pickmix.gui.canvas import FitView, Marker, SliceCanvas +from pickmix.gui.components.canvas import FitView, Marker, SliceCanvas +from pickmix.gui.components.range_slider import RangeSlider +from pickmix.gui.components.status import StatusBadge, fit_status +from pickmix.gui.panes.tomo_pane import TomoPane from pickmix.gui.workers import post -from pickmix.gui.style import bold_safe, eye_toggle, heading, hint, tidy, title +from pickmix.gui.style import bold_safe, eye_toggle, heading, hint, icon_button, quiet_table, tidy, title, view_toggle, white from pickmix.loader import Loader, Opened from pickmix.model import Session from pickmix.timing import Timer @@ -46,58 +48,73 @@ def __init__(self) -> None: self.title = title() self.help = hint('Enter: next tomogram. Shift+Enter: previous tomogram.') self.status = QLabel() - self.kind_row = QHBoxLayout() + self.tomo = TomoPane() + self.tomo.setMinimumWidth(200) + self.tomo_toggle = icon_button('left_panel_close', '<', 'Hide the tomogram list', size=20) + self.tomo_toggle.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.tomo_toggle.clicked.connect(lambda: self.set_tomo_visible(self.tomo.isHidden())) + self.kind_box = QVBoxLayout() self.kind_buttons: dict[str, QPushButton] = {} self.kind_toggles: dict[str, QPushButton] = {} self.group = QButtonGroup(self) self.z_slider = QScrollBar(Qt.Orientation.Vertical) self.z_slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.z_label = QLabel() - self.lo_slider = QSlider(Qt.Orientation.Horizontal) - self.hi_slider = QSlider(Qt.Orientation.Horizontal) - for slider in (self.lo_slider, self.hi_slider): - slider.setRange(0, _STEPS) - self.rings_box = QCheckBox('Rings') - self.boxes_box = QCheckBox('Boxes') - self.normals_box = QCheckBox('Normals') + self.contrast = RangeSlider(0, _STEPS) + self.brightness = QSlider(Qt.Orientation.Horizontal) + self.brightness.setRange(-100, 100) + self.auto_box = QCheckBox('Auto') + self.auto_box.setChecked(True) + self.rings_box = view_toggle('Rings') + self.boxes_box = view_toggle('Boxes') + self.normals_box = view_toggle('Normals') for box in (self.rings_box, self.boxes_box, self.normals_box): box.setChecked(True) box.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.invert_box = QCheckBox('Invert') - self.auto_button = QPushButton('Auto') self.fit_button = QPushButton('Fit') - self.table = QTableWidget(0, 6) - self.table.setHorizontalHeaderLabels(['Kind', 'x', 'y', 'z', 'SNR', 'Flag']) - self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.fill_button = QPushButton('Fill') + self.table = QTableWidget(0, 7) + self.table.setHorizontalHeaderLabels(['', 'Kind', 'x', 'y', 'z', 'SNR', 'Flag']) + quiet_table(self.table) self.table.setMaximumHeight(170) + # status marker first and fixed so kinds always line up; Kind fits its names, the number columns share the rest header = self.table.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - self.delete_button = QPushButton('Delete selected') - for button in (self.auto_button, self.fit_button, self.delete_button): + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.table.setColumnWidth(0, 28) + self.delete_button = icon_button('delete', 'Delete', 'Delete the selected pick (Delete)') + self.delete_button.setEnabled(False) + for button in (self.fit_button, self.fill_button, self.delete_button): button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._row_ids: list[int] = [] - # settings pane on the right: image controls, then overlays + # settings pane on the right: image controls, what to show, then the kinds image = QGroupBox('Image') image_form = QFormLayout(image) - image_form.addRow('Contrast low', self.lo_slider) - image_form.addRow('Contrast high', self.hi_slider) - image_form.addRow(self.invert_box) - buttons = QHBoxLayout() - buttons.addWidget(self.auto_button) - buttons.addWidget(self.fit_button) - image_form.addRow(buttons) - overlay = QGroupBox('Overlay') - overlay_box = QVBoxLayout(overlay) - for box in (self.rings_box, self.boxes_box, self.normals_box): - overlay_box.addWidget(box) + image_form.addRow('Contrast', self.contrast) + image_form.addRow('Brightness', self.brightness) + checks = QHBoxLayout() + checks.addWidget(self.auto_box) + checks.addWidget(self.invert_box) + image_form.addRow(checks) + fit_row = QHBoxLayout() + fit_row.addWidget(self.fit_button) + fit_row.addWidget(self.fill_button) + image_form.addRow(fit_row) + view = QGroupBox('View') + view_grid = QGridLayout(view) + for i, box in enumerate((self.rings_box, self.boxes_box, self.normals_box)): + view_grid.addWidget(box, i // 2, i % 2) + kinds = QGroupBox('Kinds') + kinds.setLayout(self.kind_box) self.settings = QWidget() - self.settings.setFixedWidth(260) + self.settings.setFixedWidth(220) settings_layout = QVBoxLayout(self.settings) settings_layout.setContentsMargins(0, 0, 0, 0) settings_layout.addWidget(image) - settings_layout.addWidget(overlay) + settings_layout.addWidget(view) + settings_layout.addWidget(kinds) settings_layout.addStretch() # vertical Z scrollbar between the canvas and the settings z_column = QVBoxLayout() @@ -116,36 +133,50 @@ def __init__(self) -> None: bottom = QVBoxLayout() bottom.addLayout(picks_head) bottom.addWidget(self.table) + # picking area: kinds, canvas and settings, picks table + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addLayout(middle, 1) + content_layout.addLayout(bottom) + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.splitter.addWidget(self.tomo) + self.splitter.addWidget(content) + self.splitter.setChildrenCollapsible(False) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + self.splitter.setSizes([260, 1000]) + names = QVBoxLayout() + names.setSpacing(0) + names.addWidget(self.title) + names.addWidget(self.help) + head = QHBoxLayout() + head.addWidget(self.tomo_toggle, alignment=Qt.AlignmentFlag.AlignTop) + head.addLayout(names, 1) layout = QVBoxLayout(self) tidy(layout) - layout.addWidget(self.title) - layout.addWidget(self.help) - layout.addSpacing(8) - layout.addLayout(self.kind_row) - layout.addLayout(middle, 1) - layout.addLayout(bottom) + layout.addLayout(head) + layout.addWidget(self.splitter, 1) layout.addWidget(self.status) + self.set_tomo_visible(True) self.z_slider.valueChanged.connect(self._show_z) - self.lo_slider.valueChanged.connect(self._on_window) - self.hi_slider.valueChanged.connect(self._on_window) + self.contrast.valueChanged.connect(self._edited) + self.brightness.valueChanged.connect(self._edited) self.invert_box.toggled.connect(self._on_window) + self.auto_box.toggled.connect(self._auto_toggled) self.rings_box.toggled.connect(self._on_overlay) self.boxes_box.toggled.connect(self._on_overlay) self.normals_box.toggled.connect(self._on_overlay) - self.auto_button.clicked.connect(self._auto) self.fit_button.clicked.connect(self.canvas.fit) + self.fit_button.setToolTip('Zoom to show entire tomogram') + self.fill_button.clicked.connect(self.canvas.fill) + self.fill_button.setToolTip('Zoom tomogram to fill') self.delete_button.clicked.connect(self.delete_selected) self.canvas.add_requested.connect(self._add) self.canvas.move_requested.connect(self._move) self.canvas.marker_clicked.connect(self._clicked) self.canvas.z_scrolled.connect(lambda steps: self.z_slider.setValue(self.z_slider.value() + steps)) self.table.cellClicked.connect(self._row_clicked) - for i in range(1, 10): - QShortcut(QKeySequence(str(i)), self, context=Qt.ShortcutContext.WidgetWithChildrenShortcut).activated.connect( - lambda i=i: self._pick_kind(i - 1) - ) - for key in ('Delete', 'Backspace'): - QShortcut(QKeySequence(key), self, context=Qt.ShortcutContext.WidgetWithChildrenShortcut).activated.connect(self.delete_selected) # set_session: show a session and follow its picks and navigation def set_session(self, session: Session, loader: Loader, service: EstimateService) -> None: @@ -154,6 +185,7 @@ def set_session(self, session: Session, loader: Loader, service: EstimateService # estimates arrive on the service's thread service.store.listeners.append(lambda: post(self._on_picks)) session.goto_listeners.append(self._on_goto) + self.tomo.set_session(session) self.refresh_kinds() self._on_goto() @@ -163,8 +195,8 @@ def refresh_kinds(self) -> None: return for button in self.kind_buttons.values(): self.group.removeButton(button) - while self.kind_row.count(): - widget = self.kind_row.takeAt(0).widget() + while self.kind_box.count(): + widget = self.kind_box.takeAt(0).widget() if widget is not None: widget.deleteLater() self.kind_buttons, self.kind_toggles = {}, {} @@ -172,7 +204,6 @@ def refresh_kinds(self) -> None: self.hidden &= set(kinds) # key number of each kind that has a hotkey keys = {name: i + 1 for i, name in enumerate(self.session.project.config.hotkeys) if name} - self.kind_row.addWidget(hint('Hotkeys:')) for name, kind in kinds.items(): # first button selects kind key = QPushButton(f'({keys[name]}) {name}' if name in keys else name) @@ -204,14 +235,14 @@ def refresh_kinds(self) -> None: pair_row.setSpacing(0) pair_row.addWidget(key) pair_row.addWidget(toggle) - self.kind_row.addWidget(pair) - self.kind_row.addStretch() + self.kind_box.addWidget(pair) # no kinds leaves nothing to pick as if kinds: self._set_active(self.active if self.active in kinds else next(iter(kinds))) else: self.active = None self._on_picks() + self.tomo.refresh() # show_pick: go to a pick, in whichever tomogram it is, and select it def show_pick(self, pick_id: int) -> None: @@ -224,6 +255,16 @@ def show_pick(self, pick_id: int) -> None: if pick.tomogram != self.session.current: self.session.go(self.session.names.index(pick.tomogram)) + # set_tomo_visible: show or hide the tomogram pane; the icon is the action the button takes + def set_tomo_visible(self, shown: bool) -> None: + self.tomo.setVisible(shown) + panel = white('left_panel_close' if shown else 'left_panel_open', 20) + if panel is None: + self.tomo_toggle.setText('<' if shown else '>') + else: + self.tomo_toggle.setIcon(panel) + self.tomo_toggle.setToolTip('Hide the tomogram list' if shown else 'Show the tomogram list') + # delete_selected: remove the selected pick def delete_selected(self) -> None: if self.session is not None and self.canvas.selected_id is not None: @@ -234,8 +275,8 @@ def _set_active(self, name: str) -> None: self.active = name self.kind_buttons[name].setChecked(True) - # _pick_kind: choose the kind on the i-th hotkey - def _pick_kind(self, i: int) -> None: + # pick_kind: choose the kind on the i-th hotkey + def pick_kind(self, i: int) -> None: name = self.session.project.config.hotkeys[i] if self.session is not None else '' if name in self.kind_buttons: self._set_active(name) @@ -272,12 +313,16 @@ def _open_done(self, name: str, future: Future[Opened]) -> None: return if self._timer: self._timer.lap('opened and contrast') + # Auto takes the tomogram's own limits; with Auto off the window you set is kept in pixel values + keep = None if self.auto_box.isChecked() else self._raw_window() lo, hi = self.opened.limits span = (hi - lo) or 1.0 self._range = (lo - 2 * span, hi + 2 * span) depth = self.opened.vol.shape[0] - self._quiet(self.lo_slider, self._slider_pos(lo)) - self._quiet(self.hi_slider, self._slider_pos(hi)) + low, high = self.opened.limits if keep is None else keep + self.contrast.set_values(self._slider_pos(low), self._slider_pos(high)) + if keep is None: + self._quiet(self.brightness, 0) self.z_slider.blockSignals(True) self.z_slider.setRange(0, depth - 1) self.z_slider.setValue(depth // 2) @@ -334,25 +379,45 @@ def _render(self, arr: np.ndarray) -> None: self._timer.lap('first slice shown') self.loader.warm_around(self.opened.name, self.z) - # _window: contrast limits from the sliders + # _raw_window: contrast limits from the range slider, before brightness + def _raw_window(self) -> tuple[float, float]: + low, high = self.contrast.values() + return self._slider_value(low), self._slider_value(high) + + # _window: the window the canvas uses: the contrast limits moved down by the brightness, a percentage of their width def _window(self) -> tuple[float, float]: - lo, hi = (self._slider_value(s.value()) for s in (self.lo_slider, self.hi_slider)) + lo, hi = self._raw_window() + shift = self.brightness.value() / 100 * (hi - lo) + lo, hi = lo - shift, hi - shift return (lo, hi) if hi > lo else (lo, lo + 1e-6) # _on_overlay: fit rings or boxes switched def _on_overlay(self) -> None: self.canvas.set_overlay(rings=self.rings_box.isChecked(), boxes=self.boxes_box.isChecked(), normals=self.normals_box.isChecked()) - # _on_window: contrast or invert changed - def _on_window(self) -> None: + # _on_window: contrast, brightness or invert changed + def _on_window(self, *_args: object) -> None: lo, hi = self._window() self.canvas.set_window(lo, hi, invert=self.invert_box.isChecked()) - # _auto: contrast back to the limits found when the tomogram opened + # _edited: contrast or brightness changed by hand, so Auto goes off + def _edited(self, *_args: object) -> None: + if self.auto_box.isChecked(): + self.auto_box.blockSignals(True) + self.auto_box.setChecked(False) + self.auto_box.blockSignals(False) + self._on_window() + + # _auto_toggled: ticking Auto goes back to the limits found when the tomogram opened + def _auto_toggled(self, on: bool) -> None: + if on: + self._auto() + + # _auto: contrast back to the limits found when the tomogram opened, brightness back to zero def _auto(self) -> None: if self.opened is not None: - self._quiet(self.lo_slider, self._slider_pos(self.opened.limits[0])) - self._quiet(self.hi_slider, self._slider_pos(self.opened.limits[1])) + self.contrast.set_values(self._slider_pos(self.opened.limits[0]), self._slider_pos(self.opened.limits[1])) + self._quiet(self.brightness, 0) self._on_window() # _slider_pos: slider position for a pixel value @@ -378,7 +443,8 @@ def _on_picks(self) -> None: self._fill_table() if self.opened is not None: self.canvas.set_markers(self._markers()) - self.status.setText(f'Estimating {self.service.pending}...' if self.service and self.service.pending else '') + # the canvas drops a selection whose pick is gone + self._sync_delete() # _match: parent geometry for the picks of the current tomogram, per kind that has a parent def _match(self) -> dict[str, dict[int, Parent]]: @@ -412,7 +478,7 @@ def _markers(self) -> list[Marker]: out.append(Marker(p.id, p.x, p.y, p.z, kind.marker_a / 2 / px, kind.colour, bool(fit and fit.flag) or unmatched, view, normal)) return out - # _fill_table: the current tomogram's picks of known kinds, with the selected one highlighted + # _fill_table: the current tomogram's picks of known kinds, each with its estimate status, and the selected one highlighted def _fill_table(self) -> None: assert self.session is not None name = self.session.current @@ -426,7 +492,10 @@ def _fill_table(self) -> None: snr, flag = ('', '') if fit is None else (f'{fit.snr:.2f}', fit.flag) if fit is not None and kinds[p.kind].parent is not None and p.id not in self._matched.get(p.kind, {}): flag = ','.join(filter(None, [flag, 'noparent'])) - for c, text in enumerate((p.kind, f'{p.x:.1f}', f'{p.y:.1f}', f'{p.z:.1f}', snr, flag)): + status = fit_status(self.service.store.state(p) if self.service else 'ok', flag) + tip = {'running': 'Estimating...', 'ok': 'Estimate is fine'}.get(status, flag) + self.table.setCellWidget(r, 0, StatusBadge(status, tip)) + for c, text in enumerate((p.kind, f'{p.x:.1f}', f'{p.y:.1f}', f'{p.z:.1f}', snr, flag), start=1): self.table.setItem(r, c, QTableWidgetItem(text)) self._select(self.canvas.selected_id) @@ -437,6 +506,11 @@ def _select(self, pick_id: int | None) -> None: self.table.selectRow(self._row_ids.index(pick_id)) else: self.table.clearSelection() + self._sync_delete() + + # _sync_delete: Delete is only live while a pick is selected + def _sync_delete(self) -> None: + self.delete_button.setEnabled(self.canvas.selected_id is not None) # _add: new pick of the active kind on the current slice def _add(self, x: float, y: float) -> None: diff --git a/src/pickmix/gui/tabs/project_tab.py b/src/pickmix/gui/tabs/project_tab.py new file mode 100644 index 0000000..385adfd --- /dev/null +++ b/src/pickmix/gui/tabs/project_tab.py @@ -0,0 +1,160 @@ +''' +pickmix: 'Project' tab for creating/opening projects and showing tomograms, kinds and settings +''' + +# Import external dependencies +from pathlib import Path +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.gui.dialogs.new_project_dialog import NewProjectDialog +from pickmix.gui.panes.kinds_pane import KindsPane +from pickmix.gui.panes.settings_pane import SettingsPane +from pickmix.gui.panes.tomograms_pane import TomogramsPane +from pickmix.gui.panes.welcome_pane import WelcomePane +from pickmix.gui.style import hint, icon_button, tidy, title, transparent_scroll +from pickmix.gui.workers import run_in_background +from pickmix.project import Project, create_project, open_project +from pickmix.utils.star import MergeResult + +# ProjectTab: the welcome page when no project is open, else a top bar over tomograms, kinds and settings +class ProjectTab(QWidget): + # project_changed: a project was opened or created + project_changed = Signal(object) + # config_saved: settings of the open project were written to config.toml + config_saved = Signal() + # project_closed: the open project was closed + project_closed = Signal() + # kinds_saved: the kinds or hotkeys were written to config.toml + kinds_saved = Signal() + + # __init__: build the layout + def __init__(self) -> None: + super().__init__() + self.project: Project | None = None + self.welcome = WelcomePane() + self.open_button = self.welcome.open_button + self.new_button = self.welcome.new_button + self.reload_button = icon_button('refresh', 'Reload', 'Reload config.toml and rescan the tomograms', size=28) + self.close_button = icon_button('logout', 'Close', 'Close the project', size=28) + self.title_label = title() + self.path_label = hint() + self.status = QLabel() + self.tomograms = TomogramsPane() + self.kinds_pane = KindsPane() + self.settings_pane = SettingsPane() + settings_scroll = QScrollArea() + settings_scroll.setWidgetResizable(True) + settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + settings_scroll.setWidget(self.settings_pane) + transparent_scroll(settings_scroll) + settings_scroll.setMinimumWidth(self.settings_pane.minimumWidth() + 24) + # top bar: name and path on the left, reload and close on the right + names = QVBoxLayout() + names.setSpacing(0) + names.addWidget(self.title_label) + names.addWidget(self.path_label) + top = QHBoxLayout() + top.addLayout(names, 1) + top.addWidget(self.reload_button, alignment=Qt.AlignmentFlag.AlignTop) + top.addWidget(self.close_button, alignment=Qt.AlignmentFlag.AlignTop) + columns = QHBoxLayout() + columns.addWidget(self.tomograms, 7) + columns.addWidget(self.kinds_pane, 2) + columns.addWidget(settings_scroll, 0) + opened = QWidget() + opened_layout = QVBoxLayout(opened) + opened_layout.setContentsMargins(0, 0, 0, 0) + opened_layout.addLayout(top) + opened_layout.addLayout(columns, 1) + self.stack = QStackedWidget() + self.stack.addWidget(self.welcome) + self.stack.addWidget(opened) + layout = QVBoxLayout(self) + tidy(layout) + layout.addWidget(self.stack, 1) + layout.addWidget(self.status) + self.open_button.clicked.connect(self._open_dialog) + self.new_button.clicked.connect(self._new_dialog) + self.reload_button.clicked.connect(self._reload) + self.close_button.clicked.connect(self.close_project) + self.settings_pane.config_saved.connect(self.config_saved) + self.settings_pane.hotkeys_saved.connect(self.kinds_saved) + self.kinds_pane.config_saved.connect(self._kinds_saved) + + # open_config: open a config.toml in the background + def open_config(self, path: Path) -> None: + self._busy(f'Scanning tomograms for {str(path)!r}...') + run_in_background(lambda: open_project(path), self.set_project, self._failed) + + # set_project: show a project and announce it + def set_project(self, project: Project) -> None: + self.project = project + self.title_label.setText(project.display_name) + self.path_label.setText(str(project.path)) + self.tomograms.set_project(project) + self.kinds_pane.set_project(project) + self.settings_pane.set_project(project) + self.stack.setCurrentIndex(1) + self._idle('') + self.project_changed.emit(project) + + # close_project: close the open project and go back to the welcome page + def close_project(self) -> None: + self.project = None + self.stack.setCurrentIndex(0) + self.status.setText('') + self.project_closed.emit() + + # _kinds_saved: kinds changed, so the hotkey dropdowns list the new ones, then tell the other tabs + def _kinds_saved(self) -> None: + self.settings_pane.refresh_hotkeys() + self.kinds_saved.emit() + + # _open_dialog: choose a config.toml + def _open_dialog(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, 'Open config', '', 'TOML (*.toml)') + if path: + self.open_config(Path(path)) + + # _reload: reopen the current config.toml, picking up hand edits and rescanning tomograms + def _reload(self) -> None: + if self.project is not None: + self.open_config(self.project.path) + + # _new_dialog: collect runs, merge them, write the config and open it + def _new_dialog(self) -> None: + dialog = NewProjectDialog(self) + if not dialog.exec(): + return + values = dialog.values() + self._busy('Merging runs and scanning tomograms...') + run_in_background(lambda: _create_and_open(values), self._created, self._failed) + + # _created: show the new project and say what the merge did + def _created(self, made: tuple[Project, MergeResult]) -> None: + project, result = made + self.set_project(project) + self.status.setText(f'Created with {result.count} tomograms, {len(result.skipped)} duplicates skipped') + + # _busy: block the open, create, reload and close buttons while work runs + def _busy(self, message: str) -> None: + for button in (self.open_button, self.new_button, self.reload_button, self.close_button): + button.setEnabled(False) + self.status.setText(message) + + # _idle: unblock the buttons + def _idle(self, message: str) -> None: + for button in (self.open_button, self.new_button, self.reload_button, self.close_button): + button.setEnabled(True) + self.status.setText(message) + + # _failed: show an error from background work + def _failed(self, message: str) -> None: + self._idle(message) + +# _create_and_open: create the project on disk, then open it +def _create_and_open(values: dict[str, object]) -> tuple[Project, MergeResult]: + path, result = create_project(**values) + return open_project(path), result diff --git a/src/pickmix/gui/tabs/review_tab.py b/src/pickmix/gui/tabs/review_tab.py new file mode 100644 index 0000000..ac23383 --- /dev/null +++ b/src/pickmix/gui/tabs/review_tab.py @@ -0,0 +1,295 @@ +''' +pickmix: Review tab, every pick with filters and a preview of the selected pick +''' + +# Import external dependencies +import numpy as np +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QComboBox, QDoubleSpinBox, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QMessageBox, + QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.estimate.registry import Fit +from pickmix.estimates import EstimateService +from pickmix.export import Item +from pickmix.geometry import Parent +from pickmix.gui.components.images import label_image, to_pixmap +from pickmix.gui.components.status import fit_status, status_item +from pickmix.gui.style import image_well, quiet_table, tidy, white +from pickmix.gui.workers import post, run_in_background +from pickmix.items import flagged, items, matched +from pickmix.loader import Loader +from pickmix.model import Pick, Session + +# _COLUMNS: pick table headings +_COLUMNS: list[str] = ['Tomogram', 'Kind', 'x', 'y', 'z', 'SNR', 'Flag', 'Estimate'] + +# _CROP: half-width in voxels of the preview crops +_CROP: int = 64 + +# _PREVIEW: edge in pixels of the preview images +_PREVIEW: int = 220 + +# _COLUMN_GAP: pixels between the filters, the buttons and the count +_COLUMN_GAP: int = 40 + +# _FLAG_CHOICES: the flags filter, in drop-down order +_FLAG_CHOICES: list[str] = ['(all)', 'Flagged only', 'Non-flagged only'] + +# _ANY_SNR: minimum of the SNR filter, shown as "any" +_ANY_SNR: float = -1000.0 + +# ReviewTab: pick table and filters above, preview beside +class ReviewTab(QWidget): + # open_pick: a row was double-clicked, tomogram name and pick id + open_pick = Signal(str, int) + + # __init__: build the layout + def __init__(self) -> None: + super().__init__() + self.session: Session | None = None + self.loader: Loader | None = None + self.service: EstimateService | None = None + self._shown: list[Pick] = [] + self._token = 0 + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(200) + self._timer.timeout.connect(self.refresh) + self.kind_filter = QComboBox() + self.tomo_filter = QComboBox() + self.flag_filter = QComboBox() + self.flag_filter.addItems(_FLAG_CHOICES) + self.snr_spin = QDoubleSpinBox() + self.snr_spin.setRange(_ANY_SNR, 1000.0) + self.snr_spin.setValue(_ANY_SNR) + self.snr_spin.setSpecialValueText('any') + self.count_label = QLabel() + self.delete_selected_button = QPushButton('Delete selected') + self.delete_selected_button.setToolTip('Delete the selected pick') + self.delete_shown_button = QPushButton('Delete shown') + self.delete_shown_button.setToolTip('Delete every pick the filters show') + self.reestimate_button = QPushButton('Re-estimate all') + self.reestimate_button.setToolTip('Fit every pick again') + reload_icon = white('cached') + if reload_icon is not None: + self.reestimate_button.setIcon(reload_icon) + self.table = QTableWidget(0, len(_COLUMNS)) + self.table.setHorizontalHeaderLabels(_COLUMNS) + quiet_table(self.table) + self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + # tomogram and kind take what they need, the other columns share the rest + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.xy_label = QLabel() + self.xz_label = QLabel() + self.yz_label = QLabel() + self.xy_label.setToolTip('The slice through the pick') + self.xz_label.setToolTip('The plane through the pick along z, at its y position') + self.yz_label.setToolTip('The plane through the pick along z, at its x position') + for label in (self.xy_label, self.xz_label, self.yz_label): + image_well(label, _PREVIEW) + # controls: filters over two rows, then buttons, then the shown count, all centred on the same line + filters = QGridLayout() + for row, col, text, widget in ( + (0, 0, 'Kind', self.kind_filter), (0, 3, 'Tomogram', self.tomo_filter), + (1, 0, 'Min SNR', self.snr_spin), (1, 3, 'Flags', self.flag_filter), + ): + filters.addWidget(QLabel(text), row, col) + filters.addWidget(widget, row, col + 1) + filters.setColumnMinimumWidth(2, 24) + buttons = QGridLayout() + buttons.addWidget(self.delete_selected_button, 0, 0) + buttons.addWidget(self.delete_shown_button, 0, 1) + buttons.addWidget(self.reestimate_button, 1, 0, 1, 2) + controls = QHBoxLayout() + controls.addLayout(filters) + controls.addSpacing(_COLUMN_GAP) + controls.addLayout(buttons) + controls.addSpacing(_COLUMN_GAP) + controls.addWidget(self.count_label, 1, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + controls.setAlignment(filters, Qt.AlignmentFlag.AlignVCenter) + controls.setAlignment(buttons, Qt.AlignmentFlag.AlignVCenter) + previews = QVBoxLayout() + previews.addWidget(self.xy_label) + previews.addWidget(self.xz_label) + previews.addWidget(self.yz_label) + previews.addStretch() + body = QHBoxLayout() + body.addWidget(self.table, 1) + body.addLayout(previews) + layout = QVBoxLayout(self) + tidy(layout) + layout.addLayout(controls) + layout.addLayout(body, 1) + for combo in (self.kind_filter, self.tomo_filter): + combo.currentIndexChanged.connect(self.refresh) + self.flag_filter.currentIndexChanged.connect(self.refresh) + self.snr_spin.valueChanged.connect(self.refresh) + self.table.itemSelectionChanged.connect(self._preview) + self.table.cellDoubleClicked.connect(self._double_clicked) + self.delete_selected_button.clicked.connect(self.delete_selected) + self.delete_shown_button.clicked.connect(self._ask_delete_shown) + self.reestimate_button.clicked.connect(self._reestimate) + + # set_session: show a session and follow its picks and estimates + def set_session(self, session: Session, loader: Loader, service: EstimateService) -> None: + self.session, self.loader, self.service = session, loader, service + session.store.listeners.append(self._schedule) + service.store.listeners.append(self._schedule) + self.refresh_kinds() + + # refresh_kinds: rebuild the kind and tomogram filters from the config + def refresh_kinds(self) -> None: + if self.session is None: + return + kinds = self.session.project.config.kinds + for combo, names in ((self.kind_filter, list(kinds)), (self.tomo_filter, self.session.names)): + current = combo.currentText() + combo.blockSignals(True) + combo.clear() + combo.addItems(['(all)', *names]) + combo.setCurrentText(current if current in names else '(all)') + combo.blockSignals(False) + self.refresh() + + # refresh: rebuild the pick table with the filters applied + def refresh(self) -> None: + if self.session is None or self.service is None: + return + selected = self._shown[self.table.currentRow()].id if 0 <= self.table.currentRow() < len(self._shown) else None + rows = self._filtered() + self._shown = [p for p, _ in rows] + self.table.blockSignals(True) + self.table.setRowCount(len(rows)) + for r, (pick, fit) in enumerate(rows): + state = self.service.store.state(pick) + flag = '' if fit is None else fit.flag + cells = [pick.tomogram, pick.kind, f'{pick.x:.1f}', f'{pick.y:.1f}', f'{pick.z:.1f}', '' if fit is None else f'{fit.snr:.2f}', flag] + for c, text in enumerate(cells): + self.table.setItem(r, c, QTableWidgetItem(text)) + status = fit_status(state, flag) + tip = {'running': f'Estimate {state}', 'ok': 'Estimate is fine'}.get(status, flag) + self.table.setItem(r, 7, status_item(status, tip)) + if selected in [p.id for p in self._shown]: + self.table.selectRow([p.id for p in self._shown].index(selected)) + self.table.blockSignals(False) + self.count_label.setText(f'{len(rows)} shown of {self.session.store.total()}') + + # delete_selected: remove the selected pick; returns how many went + def delete_selected(self) -> int: + row = self.table.currentRow() + if self.session is None or not 0 <= row < len(self._shown): + return 0 + self.session.store.remove(self._shown[row].id) + return 1 + + # delete_shown: remove every pick the filters currently show; returns how many went + def delete_shown(self) -> int: + assert self.session is not None and self.service is not None + doomed = [p.id for p, _ in self._filtered()] + if doomed: + self.session.store.remove(*doomed) + return len(doomed) + + # _schedule: coalesce bursts of changes, from any thread, into one refresh + def _schedule(self) -> None: + post(lambda: self._timer.start()) + + # _items: picks of a kind that have a current fit + def _items(self, kind: str) -> list[Item]: + assert self.session is not None and self.service is not None + return items(self.session, self.service, kind) + + # _matched: parent geometry for every pick of a kind, empty for a kind with no parent + def _matched(self, kind: str) -> dict[int, Parent]: + assert self.session is not None and self.service is not None + return matched(self.session, self.service, kind) + + # _flagged: the fit with noparent added to its flags when the pick's kind has a parent and the pick found none + def _flagged(self, kind: str, pick: Pick, fit: Fit, parents: dict[int, Parent]) -> Fit: + assert self.session is not None + return flagged(self.session, kind, pick, fit, parents) + + # _filtered: picks of known kinds passing the filters, in tomogram then kind order, with their current fit + def _filtered(self) -> list[tuple[Pick, Fit | None]]: + assert self.session is not None and self.service is not None + kind = self.kind_filter.currentText() + tomo = self.tomo_filter.currentText() + min_snr = self.snr_spin.value() + order = {n: i for i, n in enumerate(self.session.names)} + kinds = {k: i for i, k in enumerate(self.session.project.config.kinds)} + parents = {k: self._matched(k) for k in kinds} + out = [] + for pick in self.session.store.all(): + if pick.kind not in kinds or (kind != '(all)' and pick.kind != kind) or (tomo != '(all)' and pick.tomogram != tomo): + continue + fit = self.service.store.get(pick) + if fit is not None: + fit = self._flagged(pick.kind, pick, fit, parents[pick.kind]) + is_flagged = fit is not None and bool(fit.flag) + mode = self.flag_filter.currentIndex() + if (mode == 1 and not is_flagged) or (mode == 2 and is_flagged) or (min_snr > _ANY_SNR and (fit is None or fit.snr < min_snr)): + continue + out.append((pick, fit)) + out.sort(key=lambda pf: (order.get(pf[0].tomogram, len(order)), kinds[pf[0].kind])) + return out + + # _ask_delete_shown: confirm, then delete every pick shown + def _ask_delete_shown(self) -> None: + if self.session is None or self.service is None: + return + n = len(self._filtered()) + if n and QMessageBox.question(self, 'Delete picks', f'Delete the {n} picks shown?') == QMessageBox.StandardButton.Yes: + self.delete_shown() + + # _reestimate: queue estimates for everything that needs one + def _reestimate(self) -> None: + if self.service is not None: + self.service.request_all() + + # _double_clicked: open the pick in the Pick tab + def _double_clicked(self, row: int, _col: int) -> None: + if 0 <= row < len(self._shown): + self.open_pick.emit(self._shown[row].tomogram, self._shown[row].id) + + # _preview: crops of the selected pick, read off the GUI thread + def _preview(self) -> None: + row = self.table.currentRow() + if self.loader is None or self.service is None or not 0 <= row < len(self._shown): + return + pick = self._shown[row] + fit = self.service.store.get(pick) + centre = (fit.x, fit.y, fit.z) if fit else (pick.x, pick.y, pick.z) + self._token += 1 + token = self._token + run_in_background( + lambda: self.crops(pick.tomogram, centre), + lambda crops: self._show_preview(token, crops), + lambda message: self.xy_label.setText(message), + ) + + # crops: (xy, xz, yz) arrays around a centre: the slice through it, and the planes through it along z at its y and at its x + def crops(self, tomogram: str, centre: tuple[float, float, float]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + assert self.loader is not None + vol = self.loader.open(tomogram).result().vol + x, y, z = (round(v) for v in centre) + z = min(max(z, 0), vol.shape[0] - 1) + xs = slice(max(x - _CROP, 0), x + _CROP) + ys = slice(max(y - _CROP, 0), y + _CROP) + zs = slice(max(z - _CROP, 0), z + _CROP) + xy = self.loader.slice(tomogram, z)[ys, xs] + xz = np.asarray(vol[zs, min(max(y, 0), vol.shape[1] - 1), xs], dtype=np.float32) + yz = np.asarray(vol[zs, ys, min(max(x, 0), vol.shape[2] - 1)], dtype=np.float32) + return xy, xz, yz + + # _show_preview: draw the crops unless a newer selection has been made + def _show_preview(self, token: int, crops: tuple[np.ndarray, np.ndarray, np.ndarray]) -> None: + if token != self._token: + return + for label, crop, name in zip((self.xy_label, self.xz_label, self.yz_label), crops, ('XY', 'XZ', 'YZ'), strict=True): + label.setPixmap(label_image(to_pixmap(crop, _PREVIEW), name)) diff --git a/src/pickmix/items.py b/src/pickmix/items.py new file mode 100644 index 0000000..b64893b --- /dev/null +++ b/src/pickmix/items.py @@ -0,0 +1,30 @@ +''' +pickmix: picks paired with their current fits, flagged when a parent is missing +''' + +# Import internal pickmix objects +from pickmix.estimate.registry import Fit +from pickmix.estimates import EstimateService +from pickmix.export import Item +from pickmix.geometry import Parent, match_parents +from pickmix.model import Pick, Session + +# matched: parent geometry for every pick of a kind, empty for a kind with no parent +def matched(session: Session, service: EstimateService, kind: str) -> dict[int, Parent]: + return match_parents(kind, session.project.config, session.store, service.store, session.project.tomos) + +# flagged: the fit with noparent added to its flags when the pick's kind has a parent and the pick found none +def flagged(session: Session, kind: str, pick: Pick, fit: Fit, parents: dict[int, Parent]) -> Fit: + if session.project.config.kinds[kind].parent is None or pick.id in parents: + return fit + return fit.model_copy(update={'flag': ','.join(filter(None, [fit.flag, 'noparent']))}) + +# items: picks of a kind that have a current fit, each fit flagged where its parent is missing +def items(session: Session, service: EstimateService, kind: str) -> list[Item]: + parents = matched(session, service, kind) + out = [] + for pick in session.store.all(kind): + fit = service.store.get(pick) + if fit is not None: + out.append((pick, flagged(session, kind, pick, fit, parents))) + return out diff --git a/src/pickmix/model.py b/src/pickmix/model.py index 09b5370..898864e 100644 --- a/src/pickmix/model.py +++ b/src/pickmix/model.py @@ -167,16 +167,19 @@ def go(self, i: int) -> None: def step(self, delta: int) -> None: self.go(self.index + delta) - # jump: go to a tomogram by 1-based number or by name (exact match first, else the first containing the text); False if nothing matches - def jump(self, query: str) -> bool: + # find: index of a tomogram by 1-based number or by name (exact match first, else the first containing the text), None if nothing matches + def find(self, query: str) -> int | None: query = query.strip().lower() lowered = [n.lower() for n in self.names] if query.isdigit(): - hit = int(query) - 1 if 0 < int(query) <= len(lowered) else None - elif query: - hit = lowered.index(query) if query in lowered else next((i for i, n in enumerate(lowered) if query in n), None) - else: - hit = None + return int(query) - 1 if 0 < int(query) <= len(lowered) else None + if not query: + return None + return lowered.index(query) if query in lowered else next((i for i, n in enumerate(lowered) if query in n), None) + + # jump: go to the tomogram find returns; False if nothing matches + def jump(self, query: str) -> bool: + hit = self.find(query) if hit is None: return False self.go(hit) diff --git a/src/pickmix/project.py b/src/pickmix/project.py index 61b108d..0aa0c12 100644 --- a/src/pickmix/project.py +++ b/src/pickmix/project.py @@ -27,6 +27,11 @@ def update(self, **changes: object) -> None: save_config(config, self.path) self.config = config + # display_name: the configured name, else the name of the directory holding config.toml + @property + def display_name(self) -> str: + return self.config.name or self.path.parent.name + # open_project: load a config.toml and discover its tomograms def open_project(path: Path) -> Project: config = load_config(path) @@ -38,7 +43,7 @@ def open_project(path: Path) -> Project: # create_project: merge the runs into project_dir and write its config.toml; refuses to overwrite either def create_project( project_dir: Path, runs: Sequence[tuple[str, Path]], tomo_job: str, raw_dir: Path, - *, denoised_dir: Path | None = None, denoised_suffix: str = '', + *, name: str | None = None, denoised_dir: Path | None = None, denoised_suffix: str = '', ) -> tuple[Path, MergeResult]: config_path = project_dir / 'config.toml' for path in (config_path, project_dir / 'tomograms.star'): @@ -46,6 +51,7 @@ def create_project( raise FileExistsError(f'{str(path)!r} already exists') result = merge_tomograms(runs, tomo_job, project_dir) config = Config( + name=name, raw_dir=raw_dir.resolve(), denoised_dir=denoised_dir.resolve() if denoised_dir else None, denoised_suffix=denoised_suffix, diff --git a/tests/gui/test_average_pane.py b/tests/gui/test_average_pane.py deleted file mode 100644 index c6c9e4f..0000000 --- a/tests/gui/test_average_pane.py +++ /dev/null @@ -1,62 +0,0 @@ -''' -pickmix: tests for the Average pane -''' - -# Import external dependencies -import pytest -from collections.abc import Callable, Iterator -from pathlib import Path -from pytestqt.qtbot import QtBot - -# Import internal pickmix objects -from pickmix.config import Kind -from pickmix.estimates import EstimateService -from pickmix.gui.average_pane import AveragePane -from pickmix.loader import Loader -from pickmix.model import Session -from pickmix.project import open_project - -# NAME: the single tomogram of the fixture project -NAME: str = 'Position_1_2' - -# pane: a pane over a session with a passthrough kind, shut down afterwards -@pytest.fixture -def pane(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[tuple[AveragePane, Session, EstimateService]]: - project = open_project(make_config()) - project.update(kinds={'yellow': Kind(marker_a=20, box_a=21.44)}) - session, loader = Session(project), Loader(project) - service = EstimateService(session, loader) - widget = AveragePane() - qtbot.addWidget(widget) - widget.set_session(session, service) - yield widget, session, service - service.shutdown() - loader.shutdown() - -# TestAveragePane: Average pane -class TestAveragePane: - # test_summary: picks, tomograms and picks per tomogram for the chosen kind - def test_summary(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: - widget, session, _ = pane - assert widget.summary.text() == 'No picks of this kind' - session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) - session.store.add(NAME, 'yellow', 5.0, 3.0, 2.0) - widget.refresh_kinds() - assert widget.summary.text() == '2 picks in 1 tomograms (2.0 per tomogram)' - - # test_run_shows_images: running averages the picks and shows the mean and montage - def test_run_shows_images(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> None: - widget, session, service = pane - pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) - qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) - widget.refresh_kinds() - widget.run() - qtbot.waitUntil(lambda: widget.result is not None, timeout=5000) - assert widget.results_list.count() == 2 and not widget.mean_label.pixmap().isNull() and not widget.montage_label.pixmap().isNull() - assert (session.project.config.out_dir / 'average' / 'yellow' / 'overall_mean.png').exists() - - # test_run_without_estimates: nothing to average is said, not raised - def test_run_without_estimates(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: - widget, _, _ = pane - widget.run() - assert 'No picks' in widget.status.text() diff --git a/tests/gui/test_canvas.py b/tests/gui/test_canvas.py deleted file mode 100644 index 1f992d9..0000000 --- a/tests/gui/test_canvas.py +++ /dev/null @@ -1,124 +0,0 @@ -''' -pickmix: tests for the slice canvas -''' - -# Import external dependencies -import numpy as np -from pytestqt.qtbot import QtBot -from PySide6.QtCore import QPoint, QPointF, Qt -from PySide6.QtGui import QWheelEvent -from PySide6.QtTest import QTest - -# Import internal pickmix objects -from pickmix.gui.canvas import FitView, Marker, SliceCanvas - -# _canvas: a shown canvas with a 40 x 30 slice at unit zoom -def _canvas(qtbot: QtBot) -> SliceCanvas: - canvas = SliceCanvas() - qtbot.addWidget(canvas) - canvas.resize(200, 200) - canvas.show() - canvas.set_image(np.zeros((30, 40), dtype=np.float32), 0.0, 1.0) - return canvas - -# _view_pos: viewport position of a scene position -def _view_pos(canvas: SliceCanvas, x: float, y: float) -> QPoint: - return canvas.mapFromScene(QPointF(x + 0.5, y + 0.5)) - -# TestCanvas: slice canvas tests -class TestCanvas: - # test_image: size follows the slice - def test_image(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - assert canvas.has_image and canvas.image_size() == (40, 30) - canvas.clear() - assert not canvas.has_image and canvas.marker_ids() == [] - - # test_marker_opacity: solid near its slice, faint away from it - def test_marker_opacity(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - canvas.set_markers([Marker(1, 10, 10, 50, 3.0, 'yellow')]) - canvas.set_z(50) - assert canvas.opacity(1) == 1.0 - canvas.set_z(10) - assert canvas.opacity(1) < 1.0 - - # test_click_empty_adds: pressing on empty image requests a pick at that voxel - def test_click_empty_adds(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - with qtbot.waitSignal(canvas.add_requested) as blocker: - QTest.mouseClick(canvas.viewport(), Qt.MouseButton.LeftButton, pos=_view_pos(canvas, 20, 15)) - x, y = blocker.args - assert abs(x - 20) < 1.5 and abs(y - 15) < 1.5 - - # test_click_marker_selects: pressing a marker's core selects it and does not add - def test_click_marker_selects(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - canvas.set_markers([Marker(7, 20, 15, 0, 12.0, 'yellow')]) - added: list[tuple[float, float]] = [] - canvas.add_requested.connect(lambda x, y: added.append((x, y))) - with qtbot.waitSignal(canvas.marker_clicked) as blocker: - QTest.mouseClick(canvas.viewport(), Qt.MouseButton.LeftButton, pos=_view_pos(canvas, 20, 15)) - assert blocker.args == [7] and canvas.selected_id == 7 and added == [] - - # test_inside_large_marker_adds: away from the centre, inside the disc, a click still adds - def test_inside_large_marker_adds(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - canvas.set_markers([Marker(7, 20, 15, 0, 12.0, 'yellow')]) - with qtbot.waitSignal(canvas.add_requested): - QTest.mouseClick(canvas.viewport(), Qt.MouseButton.LeftButton, pos=_view_pos(canvas, 28, 15)) - - # test_drag_moves: press, drag and release on a marker requests a move by the dragged distance - def test_drag_moves(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - canvas.set_markers([Marker(3, 20, 15, 0, 4.0, 'yellow')]) - start, end = _view_pos(canvas, 20, 15), _view_pos(canvas, 25, 12) - QTest.mousePress(canvas.viewport(), Qt.MouseButton.LeftButton, pos=start) - QTest.mouseMove(canvas.viewport(), end) - with qtbot.waitSignal(canvas.move_requested) as blocker: - QTest.mouseRelease(canvas.viewport(), Qt.MouseButton.LeftButton, pos=end) - pick_id, x, y = blocker.args - assert pick_id == 3 and abs(x - 25) < 1.5 and abs(y - 12) < 1.5 - - # test_wheel_scrolls_z: a wheel notch emits one z step - def test_wheel_scrolls_z(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - event = QWheelEvent( - QPointF(10, 10), QPointF(10, 10), QPoint(0, 0), QPoint(0, 120), Qt.MouseButton.NoButton, - Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.NoScrollPhase, False, - ) - with qtbot.waitSignal(canvas.z_scrolled) as blocker: - canvas.wheelEvent(event) - assert blocker.args == [1] - - # test_select_unknown: selecting a marker that is not drawn clears the selection - def test_select_unknown(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - canvas.set_markers([Marker(1, 5, 5, 0, 3.0, 'yellow')]) - canvas.select(1) - assert canvas.selected_id == 1 - canvas.select(99) - assert canvas.selected_id is None - - # test_fit_overlay: the ring shows within the fit's slab, the box within half its width of the fit's slice, and both can be switched off - def test_fit_overlay(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - fit = FitView(cx=20, cy=15, cz=50, ring_px=8.0, box_px=12.0, z_lo=45, z_hi=55) - canvas.set_markers([Marker(1, 20, 15, 50, 3.0, 'yellow', fit=fit)]) - canvas.set_z(50) - assert canvas.overlay_visible(1) == (True, True) - canvas.set_z(58) - assert canvas.overlay_visible(1) == (False, False) - canvas.set_z(53) - assert canvas.overlay_visible(1) == (True, True) - canvas.set_overlay(rings=False, boxes=True) - assert canvas.overlay_visible(1) == (False, True) - assert canvas.overlay_visible(99) == (False, False) - - # test_normal_arrow: a marker with a normal gets an arrow that the overlay switch hides - def test_normal_arrow(self, qtbot: QtBot) -> None: - canvas = _canvas(qtbot) - canvas.set_markers([Marker(1, 20, 15, 0, 4.0, 'lime', normal=(1.0, 0.0)), Marker(2, 10, 10, 0, 4.0, 'lime')]) - assert canvas.normal_visible(1) and not canvas.normal_visible(2) - canvas.set_overlay(rings=True, boxes=True, normals=False) - assert not canvas.normal_visible(1) diff --git a/tests/gui/test_components.py b/tests/gui/test_components.py new file mode 100644 index 0000000..1845510 --- /dev/null +++ b/tests/gui/test_components.py @@ -0,0 +1,381 @@ +''' +pickmix: tests for gui components +''' + +# Import external dependencies +import numpy as np, pytest +from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtGui import QColor, QPixmap, QWheelEvent +from PySide6.QtTest import QTest +from pytestqt.qtbot import QtBot + +# Import internal pickmix objects +from pickmix.gui.components.canvas import FitView, Marker, SliceCanvas +from pickmix.gui.components.icons import ICON_DIR, icon, tinted +from pickmix.gui.components.images import FitLabel, label_cells, tile_cells, label_image, to_image, to_pixmap +from pickmix.gui.components.range_slider import RangeSlider +from pickmix.gui.components.status import STATUS_STYLE, StatusBadge, fit_status, status_item +from pickmix.gui.components.toast import notify +from pickmix.gui.style import OK +from pickmix.gui.main_window import MainWindow + +# STEMS: bundled icons +STEMS: list[str] = [ + 'add', 'cached', 'check', 'close', 'create_new_folder', 'crop', 'delete', 'edit', 'error', 'download', 'fit_screen', 'folder_open', 'fullscreen', + 'help', 'left_panel_close', 'left_panel_open', 'logout', 'refresh', 'visibility', 'visibility_off', 'warning', +] + +# _canvas: a shown canvas with a 40 x 30 slice at unit zoom +def _canvas(qtbot: QtBot) -> SliceCanvas: + canvas = SliceCanvas() + qtbot.addWidget(canvas) + canvas.resize(200, 200) + canvas.show() + canvas.set_image(np.zeros((30, 40), dtype=np.float32), 0.0, 1.0) + return canvas + +# _slider: a 200 px wide slider over 0 to 100 at 20 and 80 +def _slider(qtbot: QtBot) -> RangeSlider: + slider = RangeSlider(0, 100) + qtbot.addWidget(slider) + slider.resize(200, 24) + slider.set_values(20, 80) + return slider + +# _handle_colour: colour of the pixel at a handle centre in a rendered slider +def _handle_colour(slider: RangeSlider, value: int) -> QColor: + image = slider.grab().toImage() + return image.pixelColor(round(slider._x(value)), slider.height() // 2) + +# _view_pos: viewport position of a scene position +def _view_pos(canvas: SliceCanvas, x: float, y: float) -> QPoint: + return canvas.mapFromScene(QPointF(x + 0.5, y + 0.5)) + +# TestCanvas: slice canvas tests +class TestCanvas: + # test_image: size follows the slice + def test_image(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + assert canvas.has_image and canvas.image_size() == (40, 30) + canvas.clear() + assert not canvas.has_image and canvas.marker_ids() == [] + + # test_marker_opacity: solid near its slice, faint away from it + def test_marker_opacity(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + canvas.set_markers([Marker(1, 10, 10, 50, 3.0, 'yellow')]) + canvas.set_z(50) + assert canvas.opacity(1) == 1.0 + canvas.set_z(10) + assert canvas.opacity(1) < 1.0 + + # test_click_empty_adds: pressing on empty image requests a pick at that voxel + def test_click_empty_adds(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + with qtbot.waitSignal(canvas.add_requested) as blocker: + QTest.mouseClick(canvas.viewport(), Qt.MouseButton.LeftButton, pos=_view_pos(canvas, 20, 15)) + x, y = blocker.args + assert abs(x - 20) < 1.5 and abs(y - 15) < 1.5 + + # test_click_marker_selects: pressing a marker's core selects it and does not add + def test_click_marker_selects(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + canvas.set_markers([Marker(7, 20, 15, 0, 12.0, 'yellow')]) + added: list[tuple[float, float]] = [] + canvas.add_requested.connect(lambda x, y: added.append((x, y))) + with qtbot.waitSignal(canvas.marker_clicked) as blocker: + QTest.mouseClick(canvas.viewport(), Qt.MouseButton.LeftButton, pos=_view_pos(canvas, 20, 15)) + assert blocker.args == [7] and canvas.selected_id == 7 and added == [] + + # test_inside_large_marker_adds: away from the centre, inside the disc, a click still adds + def test_inside_large_marker_adds(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + canvas.set_markers([Marker(7, 20, 15, 0, 12.0, 'yellow')]) + with qtbot.waitSignal(canvas.add_requested): + QTest.mouseClick(canvas.viewport(), Qt.MouseButton.LeftButton, pos=_view_pos(canvas, 28, 15)) + + # test_drag_moves: press, drag and release on a marker requests a move by the dragged distance + def test_drag_moves(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + canvas.set_markers([Marker(3, 20, 15, 0, 4.0, 'yellow')]) + start, end = _view_pos(canvas, 20, 15), _view_pos(canvas, 25, 12) + QTest.mousePress(canvas.viewport(), Qt.MouseButton.LeftButton, pos=start) + QTest.mouseMove(canvas.viewport(), end) + with qtbot.waitSignal(canvas.move_requested) as blocker: + QTest.mouseRelease(canvas.viewport(), Qt.MouseButton.LeftButton, pos=end) + pick_id, x, y = blocker.args + assert pick_id == 3 and abs(x - 25) < 1.5 and abs(y - 12) < 1.5 + + # test_wheel_scrolls_z: a wheel notch emits one z step + def test_wheel_scrolls_z(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + event = QWheelEvent( + QPointF(10, 10), QPointF(10, 10), QPoint(0, 0), QPoint(0, 120), Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.NoScrollPhase, False, + ) + with qtbot.waitSignal(canvas.z_scrolled) as blocker: + canvas.wheelEvent(event) + assert blocker.args == [1] + + # test_select_unknown: selecting a marker that is not drawn clears the selection + def test_select_unknown(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + canvas.set_markers([Marker(1, 5, 5, 0, 3.0, 'yellow')]) + canvas.select(1) + assert canvas.selected_id == 1 + canvas.select(99) + assert canvas.selected_id is None + + # test_fit_overlay: the ring shows within the fit's slab, the box within half its width of the fit's slice, and both can be switched off + def test_fit_overlay(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + fit = FitView(cx=20, cy=15, cz=50, ring_px=8.0, box_px=12.0, z_lo=45, z_hi=55) + canvas.set_markers([Marker(1, 20, 15, 50, 3.0, 'yellow', fit=fit)]) + canvas.set_z(50) + assert canvas.overlay_visible(1) == (True, True) + canvas.set_z(58) + assert canvas.overlay_visible(1) == (False, False) + canvas.set_z(53) + assert canvas.overlay_visible(1) == (True, True) + canvas.set_overlay(rings=False, boxes=True) + assert canvas.overlay_visible(1) == (False, True) + assert canvas.overlay_visible(99) == (False, False) + + # test_normal_arrow: a marker with a normal gets an arrow that the overlay switch hides + def test_normal_arrow(self, qtbot: QtBot) -> None: + canvas = _canvas(qtbot) + canvas.set_markers([Marker(1, 20, 15, 0, 4.0, 'lime', normal=(1.0, 0.0)), Marker(2, 10, 10, 0, 4.0, 'lime')]) + assert canvas.normal_visible(1) and not canvas.normal_visible(2) + canvas.set_overlay(rings=True, boxes=True, normals=False) + assert not canvas.normal_visible(1) + + # test_fill_covers_canvas: fill zooms in further than fit when the slice and canvas shapes differ + def test_fill_covers_canvas(self, qtbot: QtBot) -> None: + canvas = SliceCanvas() + qtbot.addWidget(canvas) + canvas.resize(400, 200) + canvas.show() + qtbot.waitExposed(canvas) + canvas.set_image(np.zeros((6, 8), dtype=np.float32), 0.0, 1.0) + canvas.fit() + fit_scale = canvas.transform().m11() + canvas.fill() + assert canvas.transform().m11() > fit_scale + +# TestIcons: icon loading and tinting +class TestIcons: + # test_bundled: every icon the GUI uses ships + @pytest.mark.parametrize('stem', STEMS) + def test_bundled(self, qtbot: QtBot, stem: str) -> None: + assert (ICON_DIR / f'{stem}.svg').exists() and icon(stem) is not None + + # test_missing: an icon with no file is None + def test_missing(self) -> None: + assert icon('no_such_icon') is None and tinted('no_such_icon', '#ff0000') is None + + # test_tinted: opaque pixels take the colour asked for, give or take rounding at the antialiased edge + def test_tinted(self, qtbot: QtBot) -> None: + want = QColor(OK).getRgb()[:3] + image = tinted('check', OK).pixmap(16, 16).toImage() + opaque = [image.pixelColor(x, y) for x in range(16) for y in range(16) if image.pixelColor(x, y).alpha() > 200] + assert opaque and all(max(abs(a - b) for a, b in zip(c.getRgb()[:3], want)) <= 4 for c in opaque) + +# TestImages: image helpers +class TestImages: + # test_to_image_stretches: the 1st to 99th percentile fills the 8-bit range + def test_to_image_stretches(self, qtbot: QtBot) -> None: + arr = np.linspace(0.0, 1.0, 64 * 64).reshape(64, 64) + image = to_image(arr) + assert image.width() == 64 and image.height() == 64 and image.pixelColor(0, 0).red() == 0 and image.pixelColor(63, 63).red() == 255 + + # test_to_pixmap_fits: the pixmap fits the square and keeps the aspect + def test_to_pixmap_fits(self, qtbot: QtBot) -> None: + pixmap = to_pixmap(np.random.default_rng(0).random((20, 40)), 100) + assert pixmap.width() == 100 and pixmap.height() == 50 + + # test_label_cells_marks_each_cell: each cell's top-left corner is darkened, the cell centre is untouched + def test_label_cells_marks_each_cell(self, qtbot: QtBot) -> None: + grid, box, pad = 2, 40, 2 + size = grid * (box + pad) + pad + base = to_image(np.full((size, size), 200.0) + np.random.default_rng(1).random((size, size))) + out = label_cells(base, ['1/1', '1/2', '2/1', '2/2'], grid, pad) + assert out.size() == base.size() + for row in range(grid): + for col in range(grid): + x, y = pad + col * (box + pad), pad + row * (box + pad) + assert out.pixelColor(x + 1, y + 1).red() < base.pixelColor(x + 1, y + 1).red() + assert out.pixelColor(x + box - 2, y + box - 2) == base.pixelColor(x + box - 2, y + box - 2).toRgb() + + # test_label_cells_stay_in_their_cell: a long label is clipped inside its own cell, never spilling into the padding or the next cell + def test_label_cells_stay_in_their_cell(self, qtbot: QtBot) -> None: + grid, box, pad = 2, 60, 2 + size = grid * (box + pad) + pad + base = to_image(np.full((size, size), 200.0) + np.random.default_rng(1).random((size, size))) + out = label_cells(base, ['12 (a_very_long_tomogram_name_indeed)'] * 4, grid, pad) + for row in range(grid): + for col in range(grid): + x, y = pad + col * (box + pad), pad + row * (box + pad) + # a label drawn in, then the padding column just right of the cell is untouched + assert out.pixelColor(x + 1, y + 1).red() < base.pixelColor(x + 1, y + 1).red() + for dy in range(0, 10): + assert out.pixelColor(x + box, y + dy) == base.pixelColor(x + box, y + dy).toRgb() + + # test_fit_label_fills: the image is redrawn at the largest square the label has room for, and follows a resize + def test_fit_label_fills(self, qtbot: QtBot) -> None: + label = FitLabel('x', minimum=50) + qtbot.addWidget(label) + label.show() + label.set_render(lambda width, height: QPixmap(width, height)) + label.resize(400, 300) + assert (label.pixmap().width(), label.pixmap().height()) == (400, 300) + label.resize(200, 500) + assert (label.pixmap().width(), label.pixmap().height()) == (200, 500) + label.set_render(None) + label.resize(300, 300) + assert label.pixmap().width() == 200 + + # test_tile_cells_fill_a_wide_pane: a wide pane gets more columns than rows, every cell is drawn and the image is exactly the pane size + def test_tile_cells_fill_a_wide_pane(self, qtbot: QtBot) -> None: + grid, box, pad = 3, 20, 2 + size = grid * (box + pad) + pad + base = to_image(np.full((size, size), 200.0) + np.random.default_rng(1).random((size, size))) + wide = tile_cells(base, ['a'] * 5, grid, pad, 600, 120) + assert (wide.width(), wide.height()) == (600, 120) + # five cells in a 600 x 120 pane sit in one row, so the pane's first and last fifths both hold image, not background + assert wide.pixelColor(60, 60) != QColor('#141414') and wide.pixelColor(540, 60) != QColor('#141414') + tall = tile_cells(base, ['a'] * 5, grid, pad, 120, 600) + assert tall.pixelColor(60, 60) != QColor('#141414') and tall.pixelColor(60, 540) != QColor('#141414') + +# TestRangeSlider: two-handle slider +class TestRangeSlider: + # test_solid_handles: an unfocused handle is filled with the highlight colour, like the span + def test_solid_handles(self, qtbot: QtBot) -> None: + slider = _slider(qtbot) + slider.show() + want = slider.palette().highlight().color() + got = _handle_colour(slider, 20) + assert all(abs(a - b) <= 2 for a, b in zip((got.red(), got.green(), got.blue()), (want.red(), want.green(), want.blue()))) + + # test_clamps: values stay inside the range and low never passes high + def test_clamps(self, qtbot: QtBot) -> None: + slider = _slider(qtbot) + slider.set_values(-5, 500) + assert slider.values() == (0, 100) + slider.set_values(70, 30) + assert slider.values() == (70, 70) + + # test_silent_by_default: set_values only announces when asked + def test_silent_by_default(self, qtbot: QtBot) -> None: + slider = _slider(qtbot) + with qtbot.assertNotEmitted(slider.valueChanged): + slider.set_values(10, 90) + with qtbot.waitSignal(slider.valueChanged) as blocker: + slider.set_values(30, 90, emit=True) + assert blocker.args == [30, 90] + + # test_keys: arrows move the active handle, Up and Down switch handle + def test_keys(self, qtbot: QtBot) -> None: + slider = _slider(qtbot) + QTest.keyClick(slider, Qt.Key.Key_Right) + assert slider.values() == (21, 80) + QTest.keyClick(slider, Qt.Key.Key_Up) + QTest.keyClick(slider, Qt.Key.Key_PageDown) + assert slider.values() == (21, 70) + + # test_mouse_grabs_nearest_handle: pressing near a handle moves that one + def test_mouse_grabs_nearest_handle(self, qtbot: QtBot) -> None: + slider = _slider(qtbot) + QTest.mousePress(slider, Qt.MouseButton.LeftButton, pos=QPoint(round(slider._x(75)), 12)) + QTest.mouseRelease(slider, Qt.MouseButton.LeftButton, pos=QPoint(round(slider._x(75)), 12)) + low, high = slider.values() + assert low == 20 and abs(high - 75) <= 1 + +# TestStatus: status cells and badges +class TestStatus: + # test_item_icons: each status gives a cell with an icon and its tip, None gives an empty cell + @pytest.mark.parametrize('status', list(STATUS_STYLE)) + def test_item_icons(self, qtbot: QtBot, status: str) -> None: + item = status_item(status, 'why') + assert not item.icon().isNull() and item.toolTip() == 'why' and item.text() == '' + blank = status_item(None) + assert blank.icon().isNull() and blank.text() == '' + + # test_item_falls_back: without the icon file the cell shows a glyph + def test_item_falls_back(self, qtbot: QtBot, monkeypatch) -> None: + monkeypatch.setattr('pickmix.gui.components.status.tinted', lambda stem, colour, size=16: None) + assert status_item('ok').text() == '✓' and status_item('error').text() == '✖' + + # test_badge_spins_only_when_running: the timer runs for running and stops for anything else + def test_badge_spins_only_when_running(self, qtbot: QtBot) -> None: + badge = StatusBadge() + qtbot.addWidget(badge) + badge.set_status('running') + assert badge._timer.isActive() + first = badge.pixmap().toImage() + qtbot.waitUntil(lambda: badge.pixmap().toImage() != first, timeout=2000) + badge.set_status('ok', 'fine') + assert not badge._timer.isActive() and badge.toolTip() == 'fine' + badge.set_status(None) + assert badge.pixmap().isNull() and badge.text() == '' + + # test_fit_status: no current fit runs, an error flag errors, any other flag warns, none is ok + def test_fit_status(self) -> None: + assert fit_status('missing', '') == 'running' and fit_status('stale', 'x') == 'running' + assert fit_status('ok', 'error') == 'error' and fit_status('ok', 'noparent,error') == 'error' + assert fit_status('ok', 'noparent') == 'warn' and fit_status('ok', '') == 'ok' + +# TestLabelImage: single image labels +class TestLabelImage: + # test_corner_only: the label changes the top-left corner and leaves the opposite corner alone, whatever its length + def test_corner_only(self, qtbot: QtBot) -> None: + base = QPixmap(100, 100) + base.fill(QColor('white')) + for text in ('XY', 'a very long label that cannot fit in the image at all'): + out = label_image(base, text).toImage() + assert out.size() == base.size() and out.pixelColor(6, 6) != QColor('white') and out.pixelColor(95, 95) == QColor('white') + +# TestToast: toast and notify +class TestToast: + # test_notify_prints_and_shows: the message goes to stdout and the toast + def test_notify_prints_and_shows(self, qtbot: QtBot, capsys) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + notify('Saved 3 files') + assert 'Saved 3 files' in capsys.readouterr().out + assert window.toast.isVisible() and window.toast.text() == 'Saved 3 files' + + # test_long_message_cut: only the first lines show, stdout has them all + def test_long_message_cut(self, qtbot: QtBot, capsys) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + notify('\n'.join(f'line {i}' for i in range(6))) + assert window.toast.text() == 'line 0\nline 1\nline 2\n...' + assert 'line 5' in capsys.readouterr().out + + # test_saved_gets_a_tick: a saved message starts with a green tick and is escaped, a plain one has no tick, and stdout never has it + def test_saved_gets_a_tick(self, qtbot: QtBot, capsys) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + notify('x', saved=True) + assert '✓' in window.toast.text() and '<b>' in window.toast.text() and OK in window.toast.text() + notify('x') + assert window.toast.text() == 'x' + assert '✓' not in capsys.readouterr().out + + # test_hides_itself: the toast goes away after its time + def test_hides_itself(self, qtbot: QtBot) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + window.toast.show_message('bye', ms=50) + qtbot.waitUntil(lambda: not window.toast.isVisible(), timeout=2000) + + # test_without_window: notify only prints when no toast exists + def test_without_window(self, capsys) -> None: + import pickmix.gui.components.toast as toast + toast._toast = None + notify('plain') + assert 'plain' in capsys.readouterr().out diff --git a/tests/gui/test_kind_dialog.py b/tests/gui/test_dialogs.py similarity index 63% rename from tests/gui/test_kind_dialog.py rename to tests/gui/test_dialogs.py index 9b4aa50..4d41555 100644 --- a/tests/gui/test_kind_dialog.py +++ b/tests/gui/test_dialogs.py @@ -1,13 +1,17 @@ ''' -pickmix: tests for the kind dialog +pickmix: tests for gui dialogs ''' # Import external dependencies +import pytest +from pathlib import Path +from PySide6.QtWidgets import QAbstractItemView, QFormLayout, QLabel from pytestqt.qtbot import QtBot # Import internal pickmix objects from pickmix.config import Kind -from pickmix.gui.kind_dialog import DELETED, KindDialog +from pickmix.gui.dialogs.kind_dialog import DELETED, KindDialog +from pickmix.gui.dialogs.new_project_dialog import NewProjectDialog # _ok: a check that accepts anything def _ok(_name: str, _kind: Kind) -> str | None: @@ -98,3 +102,59 @@ def test_new_name_must_be_free(self, qtbot: QtBot) -> None: dialog.name_edit.setText('cage') dialog.accept() assert 'already exists' in dialog.error.text() and dialog.result() == 0 + +# TestNewProjectDialog: project creation dialog +class TestNewProjectDialog: + # test_runs_table_quiet_but_editable: the runs table has the quiet look and its cells can still be typed into + def test_runs_table_quiet_but_editable(self, qtbot: QtBot) -> None: + dlg = NewProjectDialog() + qtbot.addWidget(dlg) + assert dlg.runs.verticalHeader().isHidden() and not dlg.runs.showGrid() + assert dlg.runs.editTriggers() != QAbstractItemView.EditTrigger.NoEditTriggers + + # test_values: filled fields become create_project arguments + def test_values(self, qtbot: QtBot) -> None: + dlg = NewProjectDialog() + qtbot.addWidget(dlg) + dlg.add_run('r1', '/data/p1') + dlg.project_dir.edit.setText('/proj') + dlg.raw_dir.edit.setText('/raw') + values = dlg.values() + assert values['runs'] == [('r1', Path('/data/p1'))] + assert values['project_dir'] == Path('/proj') + assert values['denoised_dir'] is None + assert values['denoised_suffix'] == '.denoised' + assert values['name'] is None + + # test_validation: missing runs and directories give readable errors + def test_validation(self, qtbot: QtBot) -> None: + dlg = NewProjectDialog() + qtbot.addWidget(dlg) + with pytest.raises(ValueError, match='at least one run'): + dlg.values() + dlg.add_run('r1', '/data/p1') + with pytest.raises(ValueError, match='project directory'): + dlg.values() + dlg.accept() + assert 'project directory' in dlg.error.text() + assert dlg.result() == 0 + + # test_name: a typed name is passed on, the placeholder follows the directory + def test_name(self, qtbot: QtBot) -> None: + dlg = NewProjectDialog() + qtbot.addWidget(dlg) + dlg.add_run('r1', '/data/p1') + dlg.project_dir.edit.setText('/work/cages') + dlg.raw_dir.edit.setText('/raw') + assert dlg.name.placeholderText() == 'cages' and dlg.values()['name'] is None + dlg.name.setText(' Cage run ') + assert dlg.values()['name'] == 'Cage run' + + # test_tooltips: every field has a label that explains itself + def test_tooltips(self, qtbot: QtBot) -> None: + dlg = NewProjectDialog() + qtbot.addWidget(dlg) + form = dlg.findChild(QFormLayout) + for row in range(form.rowCount()): + assert form.itemAt(row, QFormLayout.ItemRole.LabelRole).widget().findChildren(QLabel)[1].toolTip() + assert dlg.runs.toolTip() diff --git a/tests/gui/test_export_tab.py b/tests/gui/test_export_tab.py new file mode 100644 index 0000000..789a900 --- /dev/null +++ b/tests/gui/test_export_tab.py @@ -0,0 +1,202 @@ +''' +pickmix: tests for the Export tab +''' + +# Import external dependencies +import csv, pytest +from collections.abc import Callable, Iterator +from pathlib import Path +from PySide6.QtCore import QPoint, QSize, Qt +from pytestqt.qtbot import QtBot + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimate.registry import Fit +from pickmix.estimates import EstimateService +from pickmix.gui.style import MEDIUM_SIZE, MUTED +from pickmix.gui.tabs.export_tab import ExportTab +from pickmix.loader import Loader +from pickmix.model import Session +from pickmix.project import open_project +from pickmix.utils.star import read_star_loop + +# NAME: the single tomogram of the fixture project +NAME: str = 'Position_1_2' + +# Setup: an export tab over a session with a passthrough kind and a failing kind, shut down after the test +@pytest.fixture +def setup(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[Callable[[], tuple[ExportTab, Session, EstimateService]]]: + closers: list[Callable[[], None]] = [] + + # build: the tab and its parts + def build() -> tuple[ExportTab, Session, EstimateService]: + project = open_project(make_config()) + project.update(kinds={'yellow': Kind(marker_a=20, box_a=21.44), 'bad': Kind(marker_a=20, box_a=21.44, estimator='missing')}) + session, loader = Session(project), Loader(project) + service = EstimateService(session, loader) + closers.extend([service.shutdown, loader.shutdown]) + tab = ExportTab() + qtbot.addWidget(tab) + tab.set_session(session, loader, service) + return tab, session, service + + yield build + for close in closers: + close() + +# parent_setup: an export tab over a session with a cage kind and a green kind whose parent is the cage +@pytest.fixture +def parent_setup(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[tuple[ExportTab, Session, EstimateService]]: + project = open_project(make_config()) + project.update(kinds={ + 'cage': Kind(marker_a=600, box_a=700), + 'green': Kind(marker_a=40, box_a=200, parent='cage', tilt_prior_deg=0.0), + }) + session, loader = Session(project), Loader(project) + service = EstimateService(session, loader) + tab = ExportTab() + qtbot.addWidget(tab) + tab.set_session(session, loader, service) + yield tab, session, service + service.shutdown() + loader.shutdown() + +# TestExportTab: Export tab +class TestExportTab: + # test_kind_numbers: per-kind counts and box in the export table + def test_kind_numbers(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, session, service = setup() + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + tab.refresh() + assert [tab.kinds_table.item(0, c).text() for c in range(6)] == ['yellow', '1', '1', '0', '21', '4'] + + # test_average_pane_wired: the Average pane lists the project's kinds + def test_average_pane_wired(self, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + assert tab.average.kind_box.count() == 2 + + # test_buttons_side_by_side: Export and Extract are equal medium buttons on one row, Export on the left, with the formats above Export + def test_buttons_side_by_side(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + tab.show() + qtbot.waitExposed(tab) + export, extract = tab.export_button, tab.extract_button + assert export.size() == extract.size() == QSize(*MEDIUM_SIZE) + assert export.mapTo(tab, QPoint()).y() == extract.mapTo(tab, QPoint()).y() + assert export.mapTo(tab, QPoint()).x() < extract.mapTo(tab, QPoint()).x() + for box in (tab.star_box, tab.csv_box): + assert box.mapTo(tab, QPoint()).y() < export.mapTo(tab, QPoint()).y() + + # test_table_takes_spare_height: a taller window gives the table the room, while the buttons keep their size + def test_table_takes_spare_height(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + tab.show() + tab.resize(1300, 900) + qtbot.waitExposed(tab) + short = tab.kinds_table.height() + tab.resize(1300, 1100) + qtbot.waitUntil(lambda: tab.kinds_table.height() > short, timeout=2000) + assert tab.export_button.size() == tab.extract_button.size() == QSize(*MEDIUM_SIZE) + + # test_headings_fully_shown: every column is at least as wide as its heading and together they fill the table's width + def test_headings_fully_shown(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + tab.resize(1400, 900) + tab.show() + qtbot.waitExposed(tab) + header = tab.kinds_table.horizontalHeader() + widths = [header.sectionSize(c) for c in range(header.count())] + assert all(w >= header.sectionSizeHint(c) for c, w in enumerate(widths)) + assert 0 <= tab.kinds_table.viewport().width() - sum(widths) <= len(widths) + + # test_quiet_table: the kinds table has the shared quiet look + def test_quiet_table(self, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + assert tab.kinds_table.verticalHeader().isHidden() and not tab.kinds_table.showGrid() + + # test_export_and_extract: both write their files and report skipped picks + def test_export_and_extract(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, session, service = setup() + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + out = session.project.config.out_dir + lines = tab.export_all() + assert any(l.startswith('yellow: wrote 1 particles') for l in lines) and 'bad: nothing to export' in lines + assert read_star_loop(out / 'relion' / 'yellow' / 'particles.star')[0]['rlnCenteredCoordinateXAngst'] == '0.00' + lines = tab.extract_all() + assert any(l.startswith('yellow: wrote 1 boxes of 4 px') for l in lines) + assert (out / 'particles' / 'yellow' / NAME / f'{NAME}_000.mrc').exists() + service.shutdown() + session.store.add(NAME, 'yellow', 1.0, 1.0, 1.0) + assert any('no current estimate' in l for l in tab.export_all()) + + # test_unmatched_skipped: a pick off its parent's surface is reported and left out of the star file + def test_unmatched_skipped(self, qtbot: QtBot, parent_setup: tuple[ExportTab, Session, EstimateService]) -> None: + tab, session, service = parent_setup + session.store.add(NAME, 'cage', 10.0, 10.0, 10.0) + session.store.add(NAME, 'green', 10.0 + 56.0, 10.0, 10.0) + session.store.add(NAME, 'green', 10.0 + 20.0, 10.0, 10.0) + qtbot.waitUntil(lambda: all(service.store.state(p) == 'ok' for p in session.store.all()), timeout=5000) + lines = tab.export_all() + assert any('green: 1 picks have no cage within 100 A' in l for l in lines) + [row] = read_star_loop(session.project.config.out_dir / 'relion' / 'green' / 'particles.star') + assert (row['rlnTomoSubtomogramTilt'], row['rlnAngleTiltPrior']) == ('90.0000', '0.0000') + + # test_no_console: the log box is gone and messages go to stdout + def test_no_console(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]], capsys) -> None: + tab, _, _ = setup() + assert not hasattr(tab, 'log') and not hasattr(tab, 'status') + tab._logged(['yellow: wrote 1 particles']) + assert 'yellow: wrote 1 particles' in capsys.readouterr().out and tab.spinner.status is None + + # test_export_formats: star only and csv only write only that format, both write both + def test_export_formats(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, session, service = setup() + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + out = session.project.config.out_dir + tab.export_all(formats=frozenset({'csv'})) + assert (out / 'csv' / 'yellow.csv').exists() and not (out / 'relion' / 'yellow').exists() + tab.export_all(formats=frozenset({'star'})) + assert (out / 'relion' / 'yellow' / 'particles.star').exists() + rows = list(csv.DictReader(open(out / 'csv' / 'yellow.csv'))) + assert len(rows) == 1 and rows[0]['kind'] == 'yellow' + + # test_export_button_needs_a_format: ticking neither format disables Export, and a running job disables both buttons + def test_export_button_needs_a_format(self, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + assert tab.star_box.isChecked() and tab.csv_box.isChecked() and tab.export_button.isEnabled() + tab.star_box.setChecked(False) + assert tab.export_button.isEnabled() + tab.csv_box.setChecked(False) + assert not tab.export_button.isEnabled() and tab.extract_button.isEnabled() + tab.csv_box.setChecked(True) + tab._running = True + tab._sync_buttons() + assert not tab.export_button.isEnabled() and not tab.extract_button.isEnabled() + + # test_include_flagged: flagged picks go in by default and stay out when Include flagged is cleared, in both formats + def test_include_flagged(self, qtbot: QtBot, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, session, service = setup() + assert tab._includes['yellow'].isChecked() and 'Include flagged' in [tab.kinds_table.horizontalHeaderItem(c).text() for c in range(9)] + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + fit = service.store.get(pick) + # the store hands back its row type, put wants a plain fit + flagged = Fit.model_validate({**fit.model_dump(include=set(Fit.model_fields)), 'flag': 'edge'}) + service.store.put(pick, flagged, service.store.digest('yellow')) + out = session.project.config.out_dir + assert any(l.startswith('yellow: wrote 1 particles') for l in tab.export_all()) + tab._includes['yellow'].setChecked(False) + lines = tab.export_all() + assert 'yellow: nothing to export' in lines and not [l for l in lines if l.startswith('yellow: wrote')] + + # test_editable_columns_marked: Min SNR and Include flagged show the edit icon, the others are grey and plain + def test_editable_columns_marked(self, setup: Callable[[], tuple[ExportTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + for c in (7, 8): + assert not tab.kinds_table.horizontalHeaderItem(c).icon().isNull() and tab.kinds_table.horizontalHeaderItem(c).toolTip() + for c in (0, 1, 2, 3, 4, 5, 6): + assert tab.kinds_table.horizontalHeaderItem(c).icon().isNull() + assert tab.kinds_table.item(0, c).foreground().color().name() == MUTED diff --git a/tests/gui/test_kinds_tab.py b/tests/gui/test_kinds_tab.py deleted file mode 100644 index 8bfa41f..0000000 --- a/tests/gui/test_kinds_tab.py +++ /dev/null @@ -1,209 +0,0 @@ -''' -pickmix: tests for the Kinds tab -''' - -# Import external dependencies -from collections.abc import Callable -from pathlib import Path -from pytestqt.qtbot import QtBot - -# Import internal pickmix objects -from pickmix.config import Kind, load_config -from pickmix.gui.kind_dialog import DELETED, KindDialog -from pickmix.gui.kinds_tab import KindsTab -from pickmix.model import PickStore, Session -from pickmix.project import open_project - -# _tab: a Kinds tab showing a fresh project; returns the tab and the config path -def _tab(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[KindsTab, Path]: - path = make_config() - tab = KindsTab() - qtbot.addWidget(tab) - tab.set_project(open_project(path)) - return tab, path - -# _tab_with_picks: a tab with cage, green (child of cage) and yellow kinds, a session, and picks of every kind -def _tab_with_picks(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[KindsTab, Path, Session]: - path = make_config() - project = open_project(path) - project.update(kinds={ - 'cage': Kind(marker_a=600, box_a=700), 'green': Kind(marker_a=40, box_a=200, parent='cage'), 'yellow': Kind(marker_a=100, box_a=100), - }) - session = Session(project) - for kind in ('cage', 'cage', 'green', 'yellow'): - session.store.add('Position_1_2', kind, 1.0, 1.0, 1.0) - tab = KindsTab() - qtbot.addWidget(tab) - tab.set_project(project) - tab.set_session(session, None) - return tab, path, session - -# TestKindsTab: kinds tab -class TestKindsTab: - # test_lists_kinds: each kind is listed with its values as plain text and an edit button - def test_lists_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, _ = _tab(qtbot, make_config) - assert tab.kind_names() == ['cage'] and 'cage' in tab.edit_buttons - text = tab.details['cage'].text() - assert 'Marker: 600 Å' in text and 'Box: from estimator' in text and 'Estimator: ring' in text and 'Parent: none' in text - - # test_hotkey_defaults: keys start on the kinds in order, the rest are unused - def test_hotkey_defaults(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, _ = _tab(qtbot, make_config) - assert [b.currentText() for b in tab.hotkey_boxes] == ['cage', *['(none)'] * 8] - assert tab.hotkey_boxes[1].itemText(0) == '(none)' and tab.hotkey_boxes[1].count() == 2 - - # test_hotkey_change_saves: choosing a kind for a key saves it and moves the kind off its old key - def test_hotkey_change_saves(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path = _tab(qtbot, make_config) - with qtbot.waitSignal(tab.config_saved): - tab.hotkey_boxes[2].setCurrentText('cage') - assert load_config(path).hotkeys == ['', '', 'cage', *[''] * 6] - assert tab.hotkey_boxes[0].currentText() == '(none)' - - # test_add_kind: a new kind is saved after the others and takes the first free key - def test_add_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path = _tab(qtbot, make_config) - with qtbot.waitSignal(tab.config_saved): - tab._apply('yellow', Kind(marker_a=100, box_a=100)) - config = load_config(path) - assert list(config.kinds) == ['cage', 'yellow'] and config.hotkeys[:3] == ['cage', 'yellow', ''] - assert tab.kind_names() == ['cage', 'yellow'] and tab.hotkey_boxes[1].currentText() == 'yellow' - - # test_edit_kind: the edit button opens the dialog on that kind and OK saves the changes - def test_edit_kind(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: - tab, path = _tab(qtbot, make_config) - - # fake_exec: change the marker size and accept - def fake_exec(dialog: KindDialog) -> int: - assert not dialog.adding and dialog.name_edit.text() == 'cage' - dialog.marker_spin.setValue(700) - return 1 - - monkeypatch.setattr(KindDialog, 'exec', fake_exec) - tab.edit_buttons['cage'].click() - assert load_config(path).kinds['cage'].marker_a == 700 - assert 'Marker: 700 Å' in tab.details['cage'].text() - - # test_cancel_changes_nothing: a cancelled dialog leaves the file alone - def test_cancel_changes_nothing(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: - tab, path = _tab(qtbot, make_config) - before = path.read_text() - monkeypatch.setattr(KindDialog, 'exec', lambda dialog: 0) - tab.add_button.click() - assert path.read_text() == before - - # test_dialog_check: the dialog refuses a duplicate name and a passthrough with no box before anything is saved - def test_dialog_check(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, _ = _tab(qtbot, make_config) - assert tab._problem('cage2', Kind(marker_a=10, box_a=0, estimator='none'), None) is not None - assert tab._problem('cage2', Kind(marker_a=10, box_a=10, parent='missing'), None) is not None - assert tab._problem('cage2', Kind(marker_a=10, box_a=10), None) is None - - # test_delete_kind: deleting removes the kind and frees its key - def test_delete_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path = _tab(qtbot, make_config) - tab._apply('yellow', Kind(marker_a=100, box_a=100)) - tab._delete('yellow') - config = load_config(path) - assert list(config.kinds) == ['cage'] and config.hotkeys == ['cage', *[''] * 8] - - # test_delete_blocked: a kind that is another's parent is not deleted, and says why - def test_delete_blocked(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path = _tab(qtbot, make_config) - tab._apply('green', Kind(marker_a=40, box_a=200, parent='cage')) - before = path.read_text() - tab._delete('cage') - assert tab.status.text().startswith('Not deleted') and path.read_text() == before - - # test_delete_only_kind: the last kind can be deleted, leaving an empty list and unused keys - def test_delete_only_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path = _tab(qtbot, make_config) - tab._delete('cage') - config = load_config(path) - assert config.kinds == {} and config.hotkeys == [''] * 9 - assert tab.kind_names() == [] and [b.currentText() for b in tab.hotkey_boxes] == ['(none)'] * 9 - tab._apply('yellow', Kind(marker_a=100, box_a=100)) - assert list(load_config(path).kinds) == ['yellow'] and tab.hotkey_boxes[0].currentText() == 'yellow' - - # test_rename_follows: a rename keeps the kind's place, hotkey, children and picks, in memory and in picks.csv - def test_rename_follows(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path, session = _tab_with_picks(qtbot, make_config) - tab._apply('gold', Kind(marker_a=600, box_a=700), old='cage') - config = load_config(path) - assert list(config.kinds) == ['gold', 'green', 'yellow'] and config.kinds['green'].parent == 'gold' - assert config.hotkeys[:3] == ['gold', 'green', 'yellow'] - assert [p.kind for p in session.store.all()] == ['gold', 'gold', 'green', 'yellow'] - assert sorted(p.kind for p in PickStore(session.project).all()) == ['gold', 'gold', 'green', 'yellow'] - assert tab.kind_names() == ['gold', 'green', 'yellow'] - - # test_edit_without_rename_keeps_picks: editing values only leaves the picks alone - def test_edit_without_rename_keeps_picks(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, path, session = _tab_with_picks(qtbot, make_config) - tab._apply('cage', Kind(marker_a=650, box_a=700), old='cage') - assert load_config(path).kinds['cage'].marker_a == 650 and session.store.total('cage') == 2 - - # test_rename_to_existing_refused: a kind cannot take another kind's name - def test_rename_to_existing_refused(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, _, _ = _tab_with_picks(qtbot, make_config) - assert 'already exists' in tab._problem('green', Kind(marker_a=600, box_a=700), 'cage') - - # test_delete_with_picks_asks: picks of the kind go with it after a yes, and stay with a no - def test_delete_with_picks_asks(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: - tab, path, session = _tab_with_picks(qtbot, make_config) - asked: list[str] = [] - monkeypatch.setattr(KindsTab, '_confirm', lambda self, text: asked.append(text) or False) - tab._delete('yellow') - assert 'yellow' in asked[0] and '1 picks' in asked[0] and 'yellow' in load_config(path).kinds and session.store.total('yellow') == 1 - monkeypatch.setattr(KindsTab, '_confirm', lambda self, text: True) - tab._delete('yellow') - assert 'yellow' not in load_config(path).kinds and session.store.total('yellow') == 0 - assert sorted(p.kind for p in PickStore(session.project).all()) == ['cage', 'cage', 'green'] - - # test_delete_without_picks_does_not_ask: an empty kind is deleted without a question - def test_delete_without_picks_does_not_ask(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: - tab, path, session = _tab_with_picks(qtbot, make_config) - session.store.remove_kind('yellow') - - # fail: asking is a bug here - def fail(self: KindsTab, text: str) -> bool: - raise AssertionError(text) - - monkeypatch.setattr(KindsTab, '_confirm', fail) - tab._delete('yellow') - assert 'yellow' not in load_config(path).kinds - - # test_refused_delete_keeps_picks: a delete the config refuses neither asks nor touches the picks - def test_refused_delete_keeps_picks(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: - tab, path, session = _tab_with_picks(qtbot, make_config) - monkeypatch.setattr(KindsTab, '_confirm', lambda self, text: (_ for _ in ()).throw(AssertionError(text))) - tab._delete('cage') - assert tab.status.text().startswith('Not deleted') and session.store.total('cage') == 2 - - # test_details_are_a_vertical_list_with_bold_keys: one key per line, each key bold - def test_details_are_a_vertical_list_with_bold_keys(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, _ = _tab(qtbot, make_config) - lines = tab.details['cage'].text().split('
') - assert len(lines) == 7 and all(line.startswith('') and ': ' in line for line in lines) - - # test_kinds_flow_into_columns: a wide block gives one column, a narrow one gives several, filling across then down - def test_kinds_flow_into_columns(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: - tab, _, _ = _tab_with_picks(qtbot, make_config) - tab.resize(900, 600) - tab.show() - position = lambda i: tab.grid.getItemPosition(tab.grid.indexOf(tab.cards[i]))[:2] - monkeypatch.setattr('pickmix.gui.kinds_tab._CARD_WIDTH', 10000) - tab._columns = 0 - tab._reflow() - assert tab._columns == 1 and [position(i) for i in range(3)] == [(0, 0), (1, 0), (2, 0)] - monkeypatch.setattr('pickmix.gui.kinds_tab._CARD_WIDTH', 100) - tab._reflow() - assert tab._columns >= 3 and [position(i) for i in range(3)] == [(0, 0), (0, 1), (0, 2)] - - # test_add_button_is_on_the_right: the Add button sits after the heading's stretch - def test_add_button_is_on_the_right(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - tab, _ = _tab(qtbot, make_config) - tab.resize(900, 600) - tab.show() - qtbot.waitUntil(lambda: tab.add_button.isVisible()) - assert tab.add_button.mapTo(tab, tab.add_button.rect().topRight()).x() > tab.scroll.mapTo(tab, tab.scroll.rect().center()).x() diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index c939806..54a6c36 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -5,10 +5,15 @@ # Import external dependencies from collections.abc import Callable from pathlib import Path +from PySide6.QtCore import QAbstractAnimation, QSize, Qt +from PySide6.QtGui import QShortcut +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QLineEdit from pytestqt.qtbot import QtBot # Import internal pickmix objects -from pickmix.gui.main_window import MainWindow +from pickmix.config import Kind +from pickmix.gui.main_window import PROJECT_SIZE, WELCOME_SIZE, MainWindow from pickmix.project import open_project # TestMainWindow: main window @@ -22,12 +27,12 @@ def test_tabs_locked_until_project(self, qtbot: QtBot, make_config: Callable[... assert all(window.tabs.isTabEnabled(i) for i in range(4)) assert window.project is not None - # test_kinds_tab_follows_project: the Kinds tab shows the opened project's kinds - def test_kinds_tab_follows_project(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # test_kinds_pane_follows_project: the kinds pane shows the opened project's kinds + def test_kinds_pane_follows_project(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: window = MainWindow() qtbot.addWidget(window) window.project_tab.set_project(open_project(make_config())) - assert window.kinds_tab.kind_names() == ['cage'] + assert window.kinds_pane.kind_names() == ['cage'] # test_step_only_on_pick_tab: Enter steps tomograms on the Pick tab and nowhere else def test_step_only_on_pick_tab(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: @@ -50,7 +55,9 @@ def test_no_usable_tomograms(self, qtbot: QtBot, make_config: Callable[..., Path qtbot.addWidget(window) window.project_tab.set_project(open_project(make_config(shape=(4, 6, 20)))) assert window.session is None - assert window.tabs.isTabEnabled(1) and not window.tabs.isTabEnabled(2) + assert window.tabs.isTabEnabled(0) and not any(window.tabs.isTabEnabled(i) for i in (1, 2, 3)) + assert window.kinds_pane.kind_names() == ['cage'] + # test_review_tab_wired: the Review tab is built for an opened project and opening a pick switches tabs def test_review_tab_wired(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: @@ -64,26 +71,12 @@ def test_review_tab_wired(self, qtbot: QtBot, make_config: Callable[..., Path]) window.service.shutdown() window.loader.shutdown() - # test_average_pane_wired: the Average pane in the Kinds tab lists the project's kinds + # test_average_pane_wired: the Average pane in the Export tab lists the project's kinds def test_average_pane_wired(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: window = MainWindow() qtbot.addWidget(window) window.project_tab.set_project(open_project(make_config())) - assert window.kinds_tab.average.kind_box.count() == 1 - window.service.shutdown() - window.loader.shutdown() - - # test_dock_only_on_pick_tab: the tomogram dock follows the Pick tab - def test_dock_only_on_pick_tab(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - window = MainWindow() - qtbot.addWidget(window) - assert window.dock_widget.isHidden() - window.project_tab.set_project(open_project(make_config())) - assert window.dock_widget.isHidden() - window.tabs.setCurrentWidget(window.pick_tab) - assert not window.dock_widget.isHidden() - window.tabs.setCurrentWidget(window.review_tab) - assert window.dock_widget.isHidden() + assert window.export_tab.average.kind_box.count() == 1 window.service.shutdown() window.loader.shutdown() @@ -96,6 +89,80 @@ def test_close_project(self, qtbot: QtBot, make_config: Callable[..., Path]) -> assert window.project is None and window.session is None assert [window.tabs.isTabEnabled(i) for i in range(4)] == [True, False, False, False] + # test_window_size_follows_page: small on the welcome page, default size once a project opens, a hand size survives a reload, small again on close + def test_window_size_follows_page(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + size = lambda: (window.width(), window.height()) + assert size() == WELCOME_SIZE + project = open_project(make_config()) + window.project_tab.set_project(project) + # the platform may hold the window inside a small screen, so it only has to have grown and settled + qtbot.waitUntil(lambda: window._resizing.state() == window._resizing.State.Stopped, timeout=3000) + assert window.width() > WELCOME_SIZE[0] and window.height() > WELCOME_SIZE[1] + window.resize(1300, 950) + window.project_tab.set_project(project) + assert size() == (1300, 950) + window.project_tab.close_button.click() + qtbot.waitUntil(lambda: size() == WELCOME_SIZE, timeout=3000) + + # test_resize_keeps_centre_on_screen: the window grows and shrinks about its centre and stays inside its screen + def test_resize_keeps_centre_on_screen(self, qtbot: QtBot) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + area = window.screen().availableGeometry() + frame = window.frameGeometry() + frame.moveCenter(area.center()) + window.move(frame.topLeft()) + centre = window.frameGeometry().center() + window._resize_about_centre((520, 300)) + qtbot.waitUntil(lambda: window.width() == 520, timeout=3000) + assert (window.frameGeometry().center() - centre).manhattanLength() <= 2 + window.move(area.right() - 100, area.bottom() - 100) + window._resize_about_centre(PROJECT_SIZE) + qtbot.wait(400) + assert window.frameGeometry().left() >= area.left() and window.frameGeometry().top() >= area.top() + assert window.frameGeometry().right() <= area.right() or window.frameGeometry().width() > area.width() + + # test_welcome_has_no_tab_bar: the tab bar only shows while a project is open + def test_welcome_has_no_tab_bar(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.show() + assert not window.tabs.tabBar().isVisible() + window.project_tab.set_project(open_project(make_config())) + assert window.tabs.tabBar().isVisible() + window.project_tab.close_button.click() + assert not window.tabs.tabBar().isVisible() + + # test_open_grows_about_centre: opening a project from the welcome page keeps the window's centre, even though the tabs' minimum size is bigger than the welcome page + def test_open_grows_about_centre(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + monkeypatch.setattr('pickmix.gui.main_window.PROJECT_SIZE', (620, 360)) + window = MainWindow() + qtbot.addWidget(window) + window.show() + window.setMinimumSize(0, 0) + window.resize(520, 300) + area = window.screen().availableGeometry() + frame = window.frameGeometry() + frame.moveCenter(area.center()) + window.move(frame.topLeft()) + centre = window.frameGeometry().center() + monkeypatch.setattr(window.tabs, 'minimumSizeHint', lambda: QSize(600, 350)) + window.project_tab.set_project(open_project(make_config())) + qtbot.waitUntil(lambda: window._resizing.state() == QAbstractAnimation.State.Stopped, timeout=3000) + assert window.width() >= 620 and (window.frameGeometry().center() - centre).manhattanLength() <= 3 + assert window.minimumSize() == QSize(600, 350) + + # test_resize_is_instant_before_show: a window not yet shown takes its size at once + def test_resize_is_instant_before_show(self, qtbot: QtBot) -> None: + window = MainWindow() + qtbot.addWidget(window) + window._resize_about_centre((600, 350)) + assert (window.width(), window.height()) == (600, 350) + # test_no_kinds: a project with every kind deleted still opens, and every tab copes def test_no_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: path = make_config() @@ -104,8 +171,91 @@ def test_no_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: window = MainWindow() qtbot.addWidget(window) window.project_tab.set_project(open_project(path)) - for tab in (window.pick_tab, window.review_tab, window.kinds_tab): + for tab in (window.pick_tab, window.review_tab, window.export_tab): window.tabs.setCurrentWidget(tab) - assert window.kinds_tab.kind_names() == [] and window.pick_tab.active is None + assert window.kinds_pane.kind_names() == [] and window.pick_tab.active is None window.service.shutdown() window.loader.shutdown() + + # test_window_title: the title carries the project name while one is open + def test_window_title(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + path = make_config() + project = open_project(path) + project.update(name='Cages') + window = MainWindow() + qtbot.addWidget(window) + assert window.windowTitle() == 'pickmix' + window.project_tab.set_project(open_project(path)) + assert window.windowTitle() == 'pickmix • Cages' + window.project_tab.close_button.click() + assert window.windowTitle() == 'pickmix' + + # test_settings_restart_is_debounced: a settings change rebuilds the session once after the changes stop + def test_settings_restart_is_debounced(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(make_config())) + old = window.session + window._on_settings() + window._on_settings() + assert window.session is old + qtbot.waitUntil(lambda: window.session is not old, timeout=3000) + window.service.shutdown() + window.loader.shutdown() + + # test_pick_keys_are_window_wide: number and delete keys are window shortcuts, so they do not depend on canvas focus + def test_pick_keys_are_window_wide(self, qtbot: QtBot) -> None: + window = MainWindow() + qtbot.addWidget(window) + keys = {s.key().toString(): s for s in window.findChildren(QShortcut)} + assert all(str(i) in keys for i in range(1, 10)) and 'Del' in keys and 'Backspace' in keys + assert all(s.context() == Qt.ShortcutContext.WindowShortcut for s in keys.values()) + + # test_hotkey_and_delete_from_other_focus: with focus off the canvas, a hotkey still picks the kind and delete still removes the pick + def test_hotkey_and_delete_from_other_focus(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + path = make_config() + project = open_project(path) + project.update(kinds={'cage': Kind(marker_a=600, box_a=700), 'yellow': Kind(marker_a=100, box_a=100)}) + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(path)) + window.tabs.setCurrentWidget(window.pick_tab) + qtbot.waitUntil(lambda: window.pick_tab.canvas.has_image, timeout=5000) + window.tabs.tabBar().setFocus() + window._hotkey(1) + assert window.pick_tab.active == 'yellow' + window.pick_tab.canvas.add_requested.emit(3.0, 2.0) + window._delete_pick() + assert window.session.store.total() == 0 + window.service.shutdown() + window.loader.shutdown() + + # test_pick_keys_ignored_elsewhere: not on another tab and not while typing in a text box + def test_pick_keys_ignored_elsewhere(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + path = make_config() + project = open_project(path) + project.update(kinds={'cage': Kind(marker_a=600, box_a=700), 'yellow': Kind(marker_a=100, box_a=100)}) + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(path)) + qtbot.waitUntil(lambda: window.pick_tab.canvas.has_image, timeout=5000) + window._hotkey(1) + assert window.pick_tab.active == 'cage' + window.tabs.setCurrentWidget(window.pick_tab) + monkeypatch.setattr(QApplication, 'focusWidget', staticmethod(lambda: QLineEdit())) + window._hotkey(1) + assert window.pick_tab.active == 'cage' + window.service.shutdown() + window.loader.shutdown() + + # test_return_steps_from_the_window: Enter and Shift+Enter reaching the window step tomograms on the Pick tab + def test_return_steps_from_the_window(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(make_config(names=('A_1', 'B_2', 'C_3')))) + window.tabs.setCurrentWidget(window.pick_tab) + QTest.keyClick(window, Qt.Key.Key_Return) + assert window.session.index == 1 + QTest.keyClick(window, Qt.Key.Key_Return, Qt.KeyboardModifier.ShiftModifier) + assert window.session.index == 0 + window.loader.shutdown() diff --git a/tests/gui/test_new_project_dialog.py b/tests/gui/test_new_project_dialog.py deleted file mode 100644 index 4ff520d..0000000 --- a/tests/gui/test_new_project_dialog.py +++ /dev/null @@ -1,39 +0,0 @@ -''' -pickmix: tests for the new project dialog -''' - -# Import external dependencies -import pytest -from pathlib import Path -from pytestqt.qtbot import QtBot - -# Import internal pickmix objects -from pickmix.gui.new_project_dialog import NewProjectDialog - -# TestNewProjectDialog: project creation dialog -class TestNewProjectDialog: - # test_values: filled fields become create_project arguments - def test_values(self, qtbot: QtBot) -> None: - dlg = NewProjectDialog() - qtbot.addWidget(dlg) - dlg.add_run('r1', '/data/p1') - dlg.project_dir.edit.setText('/proj') - dlg.raw_dir.edit.setText('/raw') - values = dlg.values() - assert values['runs'] == [('r1', Path('/data/p1'))] - assert values['project_dir'] == Path('/proj') - assert values['denoised_dir'] is None - assert values['denoised_suffix'] == '.denoised' - - # test_validation: missing runs and directories give readable errors - def test_validation(self, qtbot: QtBot) -> None: - dlg = NewProjectDialog() - qtbot.addWidget(dlg) - with pytest.raises(ValueError, match='at least one run'): - dlg.values() - dlg.add_run('r1', '/data/p1') - with pytest.raises(ValueError, match='project directory'): - dlg.values() - dlg.accept() - assert 'project directory' in dlg.error.text() - assert dlg.result() == 0 diff --git a/tests/gui/test_panes.py b/tests/gui/test_panes.py new file mode 100644 index 0000000..3fc28f9 --- /dev/null +++ b/tests/gui/test_panes.py @@ -0,0 +1,541 @@ +''' +pickmix: tests for gui panes +''' + +# Import external dependencies +import numpy as np, pytest +from collections.abc import Callable, Iterator +from pathlib import Path +from PySide6.QtGui import QImage, QShortcut +from PySide6.QtWidgets import QLabel +from pytestqt.qtbot import QtBot + +# Import internal pickmix objects +from pickmix import __version__ +from pickmix.config import Kind, load_config +from pickmix.estimates import EstimateService +from pickmix.gui.dialogs.kind_dialog import DELETED, KindDialog +from pickmix.gui.panes.average_pane import AveragePane +from pickmix.gui.panes.kinds_pane import KindsPane +from pickmix.gui.panes.settings_pane import NO_KIND, SettingsPane +from pickmix.gui.panes.tomo_pane import COLOUR_CURRENT, COLOUR_PENDING, COLOUR_VISITED, TomoPane +from pickmix.gui.panes.tomograms_pane import TomogramsPane, tomogram_status +from pickmix.gui.panes.welcome_pane import WelcomePane +from pickmix.loader import Loader +from pickmix.model import PickStore, Session +from pickmix.project import open_project + +# NAME: the single tomogram of the fixture project +NAME: str = 'Position_1_2' + +# THREE: tomograms for the dock tests +THREE: tuple[str, ...] = ('Position_1_2', 'Position_1_3', 'Other_9') + +# _kinds_pane: a Kinds pane showing a fresh project; returns the pane and the config path +def _kinds_pane(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[KindsPane, Path]: + path = make_config() + pane = KindsPane() + qtbot.addWidget(pane) + pane.set_project(open_project(path)) + return pane, path + +# _kinds_pane_with_picks: a pane with cage, green (child of cage) and yellow kinds, a session, and picks of every kind +def _kinds_pane_with_picks(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[KindsPane, Path, Session]: + path = make_config() + project = open_project(path) + project.update(kinds={ + 'cage': Kind(marker_a=600, box_a=700), 'green': Kind(marker_a=40, box_a=200, parent='cage'), 'yellow': Kind(marker_a=100, box_a=100), + }) + session = Session(project) + for kind in ('cage', 'cage', 'green', 'yellow'): + session.store.add('Position_1_2', kind, 1.0, 1.0, 1.0) + pane = KindsPane() + qtbot.addWidget(pane) + pane.set_project(project) + pane.set_session(session, None) + return pane, path, session + +# _settings_pane: a settings pane showing a fresh project; returns the pane and the config path +def _settings_pane(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[SettingsPane, Path]: + path = make_config() + pane = SettingsPane() + qtbot.addWidget(pane) + pane.set_project(open_project(path)) + return pane, path + +# _tomo_pane: a pane over a three-tomogram session +def _tomo_pane(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[TomoPane, Session]: + session = Session(open_project(make_config(names=THREE))) + pane = TomoPane() + qtbot.addWidget(pane) + pane.set_session(session) + return pane, session + +# pane: a pane over a session with a passthrough kind, shut down afterwards +@pytest.fixture +def pane(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[tuple[AveragePane, Session, EstimateService]]: + project = open_project(make_config()) + project.update(kinds={'yellow': Kind(marker_a=20, box_a=21.44)}) + session, loader = Session(project), Loader(project) + service = EstimateService(session, loader) + widget = AveragePane() + qtbot.addWidget(widget) + widget.set_session(session, service) + yield widget, session, service + service.shutdown() + loader.shutdown() + +# TestAveragePane: Average pane +class TestAveragePane: + # test_summary: picks, tomograms and picks per tomogram for the chosen kind + def test_summary(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget, session, _ = pane + assert widget.summary.text() == 'No picks of this kind' + session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + session.store.add(NAME, 'yellow', 5.0, 3.0, 2.0) + widget.refresh_kinds() + assert widget.summary.text() == '2 picks in 1 tomograms (2.0 per tomogram)' + + # test_run_shows_images: running averages the picks and shows the labelled montage and the mean + def test_run_shows_images(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget, session, service = pane + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + widget.refresh_kinds() + widget.run() + assert widget.spinner.status == 'running' and not widget.run_button.isEnabled() + qtbot.waitUntil(lambda: widget.result is not None, timeout=5000) + assert widget.results_table.rowCount() == 2 and not widget.mean_label.pixmap().isNull() and not widget.montage_label.pixmap().isNull() + assert widget.spinner.status is None and widget.run_button.isEnabled() and widget.save_button.isEnabled() + # averaging alone writes nothing + assert not (session.project.config.out_dir / 'average' / 'yellow' / 'overall_mean.png').exists() + + # test_run_without_estimates: nothing to average is said in a message, not raised + def test_run_without_estimates(self, pane: tuple[AveragePane, Session, EstimateService], capsys) -> None: + widget, _, _ = pane + widget.run() + assert 'No picks' in capsys.readouterr().out and widget.spinner.status is None + + # _ran: a pane that has averaged one pick + def _ran(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> AveragePane: + widget, session, service = pane + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + widget.refresh_kinds() + widget.run() + qtbot.waitUntil(lambda: widget.result is not None, timeout=5000) + return widget + + # test_list_shows_counts: row 0 is the overall count, each other row a tomogram with its own count, and choosing a row changes the mean + def test_list_shows_counts(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget = self._ran(qtbot, pane) + r = widget.result + assert widget.results_table.item(0, 0).text() == 'overall' and widget.results_table.item(0, 1).text() == str(r.count) + for row, name in enumerate(r.per_tomogram, start=1): + assert widget.results_table.item(row, 0).text() == name and widget.results_table.item(row, 1).text() == str(r.counts[name]) + overall = widget.mean_label.pixmap().toImage() + widget.results_table.selectRow(1) + assert not widget.mean_label.pixmap().isNull() + assert overall.size() == widget.mean_label.pixmap().toImage().size() + + # test_save_writes_on_request: nothing on disk until Save, then the mean, the montage and the labelled montage, per-tomogram means only when ticked + def test_save_writes_on_request(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget = self._ran(qtbot, pane) + folder = widget.result.folder + assert not folder.exists() + paths = widget.save() + assert sorted(p.name for p in folder.glob('*.png')) == ['overall_mean.png', 'overall_montage.png', 'overall_montage_labelled.png'] and len(paths) == 3 + plain, labelled = QImage(str(folder / 'overall_montage.png')), QImage(str(folder / 'overall_montage_labelled.png')) + assert not plain.isNull() and plain.size() == labelled.size() and plain != labelled + # saved as the square montage, whatever shape the pane shows it in + assert plain.width() == plain.height() + widget.per_tomogram_box.setChecked(True) + widget.save() + assert any(p.name.endswith('_mean.png') and p.name != 'overall_mean.png' for p in folder.glob('*.png')) + + # test_save_needs_a_result: Save is greyed and writes nothing before a run, and again after the kind changes + def test_save_needs_a_result(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget, _, _ = pane + assert not widget.save_button.isEnabled() and widget.save() == [] + widget = self._ran(qtbot, pane) + assert widget.save_button.isEnabled() + widget.kind_box.addItem('other') + widget.kind_box.setCurrentText('other') + assert not widget.save_button.isEnabled() and widget.results_table.rowCount() == 0 and widget.result is None + + # test_layout: Mean and Montage have their own headings and the list sits beside the mean + def test_layout(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget, _, _ = pane + headings = {label.text() for label in widget.findChildren(QLabel) if label.font().bold()} + assert {'Mean', 'Montage'} <= headings + assert not hasattr(widget, 'status') + +# TestKindsPane: kinds pane +class TestKindsPane: + # test_lists_kinds: each kind is listed with its values as plain text and an edit button + def test_lists_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _ = _kinds_pane(qtbot, make_config) + assert pane.kind_names() == ['cage'] and 'cage' in pane.edit_buttons + text = pane.details['cage'].text() + assert 'Marker: 600 Å' in text and 'Box: from estimator' in text and 'Estimator: ring' in text and 'Parent: none' in text + + # test_add_kind: a new kind is saved after the others and takes the first free key + def test_add_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _kinds_pane(qtbot, make_config) + with qtbot.waitSignal(pane.config_saved): + pane._apply('yellow', Kind(marker_a=100, box_a=100)) + config = load_config(path) + assert list(config.kinds) == ['cage', 'yellow'] and config.hotkeys[:3] == ['cage', 'yellow', ''] + assert pane.kind_names() == ['cage', 'yellow'] + + # test_edit_kind: the edit button opens the dialog on that kind and OK saves the changes + def test_edit_kind(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + pane, path = _kinds_pane(qtbot, make_config) + # fake_exec: change the marker size and accept + def fake_exec(dialog: KindDialog) -> int: + assert not dialog.adding and dialog.name_edit.text() == 'cage' + dialog.marker_spin.setValue(700) + return 1 + monkeypatch.setattr(KindDialog, 'exec', fake_exec) + pane.edit_buttons['cage'].click() + assert load_config(path).kinds['cage'].marker_a == 700 + assert 'Marker: 700 Å' in pane.details['cage'].text() + + # test_cancel_changes_nothing: a cancelled dialog leaves the file alone + def test_cancel_changes_nothing(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + pane, path = _kinds_pane(qtbot, make_config) + before = path.read_text() + monkeypatch.setattr(KindDialog, 'exec', lambda dialog: 0) + pane.add_button.click() + assert path.read_text() == before + + # test_dialog_check: the dialog refuses a duplicate name and a passthrough with no box before anything is saved + def test_dialog_check(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _ = _kinds_pane(qtbot, make_config) + assert pane._problem('cage2', Kind(marker_a=10, box_a=0, estimator='none'), None) is not None + assert pane._problem('cage2', Kind(marker_a=10, box_a=10, parent='missing'), None) is not None + assert pane._problem('cage2', Kind(marker_a=10, box_a=10), None) is None + + # test_delete_kind: deleting removes the kind and frees its key + def test_delete_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _kinds_pane(qtbot, make_config) + pane._apply('yellow', Kind(marker_a=100, box_a=100)) + pane._delete('yellow') + config = load_config(path) + assert list(config.kinds) == ['cage'] and config.hotkeys == ['cage', *[''] * 8] + + # test_delete_blocked: a kind that is another's parent is not deleted, and says why + def test_delete_blocked(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _kinds_pane(qtbot, make_config) + pane._apply('green', Kind(marker_a=40, box_a=200, parent='cage')) + before = path.read_text() + pane._delete('cage') + assert pane.status.text().startswith('Not deleted') and path.read_text() == before + + # test_delete_only_kind: the last kind can be deleted, leaving an empty list and unused keys + def test_delete_only_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _kinds_pane(qtbot, make_config) + pane._delete('cage') + config = load_config(path) + assert config.kinds == {} and config.hotkeys == [''] * 9 + assert pane.kind_names() == [] + pane._apply('yellow', Kind(marker_a=100, box_a=100)) + assert list(load_config(path).kinds) == ['yellow'] and load_config(path).hotkeys[0] == 'yellow' + + # test_rename_follows: a rename keeps the kind's place, hotkey, children and picks, in memory and in picks.csv + def test_rename_follows(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path, session = _kinds_pane_with_picks(qtbot, make_config) + pane._apply('gold', Kind(marker_a=600, box_a=700), old='cage') + config = load_config(path) + assert list(config.kinds) == ['gold', 'green', 'yellow'] and config.kinds['green'].parent == 'gold' + assert config.hotkeys[:3] == ['gold', 'green', 'yellow'] + assert [p.kind for p in session.store.all()] == ['gold', 'gold', 'green', 'yellow'] + assert sorted(p.kind for p in PickStore(session.project).all()) == ['gold', 'gold', 'green', 'yellow'] + assert pane.kind_names() == ['gold', 'green', 'yellow'] + + # test_edit_without_rename_keeps_picks: editing values only leaves the picks alone + def test_edit_without_rename_keeps_picks(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path, session = _kinds_pane_with_picks(qtbot, make_config) + pane._apply('cage', Kind(marker_a=650, box_a=700), old='cage') + assert load_config(path).kinds['cage'].marker_a == 650 and session.store.total('cage') == 2 + + # test_rename_to_existing_refused: a kind cannot take another kind's name + def test_rename_to_existing_refused(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _, _ = _kinds_pane_with_picks(qtbot, make_config) + assert 'already exists' in pane._problem('green', Kind(marker_a=600, box_a=700), 'cage') + + # test_delete_with_picks_asks: picks of the kind go with it after a yes, and stay with a no + def test_delete_with_picks_asks(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + pane, path, session = _kinds_pane_with_picks(qtbot, make_config) + asked: list[str] = [] + monkeypatch.setattr(KindsPane, '_confirm', lambda self, text: asked.append(text) or False) + pane._delete('yellow') + assert 'yellow' in asked[0] and '1 picks' in asked[0] and 'yellow' in load_config(path).kinds and session.store.total('yellow') == 1 + monkeypatch.setattr(KindsPane, '_confirm', lambda self, text: True) + pane._delete('yellow') + assert 'yellow' not in load_config(path).kinds and session.store.total('yellow') == 0 + assert sorted(p.kind for p in PickStore(session.project).all()) == ['cage', 'cage', 'green'] + + # test_delete_without_picks_does_not_ask: an empty kind is deleted without a question + def test_delete_without_picks_does_not_ask(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + pane, path, session = _kinds_pane_with_picks(qtbot, make_config) + session.store.remove_kind('yellow') + # fail: asking is a bug here + def fail(self: KindsPane, text: str) -> bool: + raise AssertionError(text) + monkeypatch.setattr(KindsPane, '_confirm', fail) + pane._delete('yellow') + assert 'yellow' not in load_config(path).kinds + + # test_refused_delete_keeps_picks: a delete the config refuses neither asks nor touches the picks + def test_refused_delete_keeps_picks(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + pane, path, session = _kinds_pane_with_picks(qtbot, make_config) + monkeypatch.setattr(KindsPane, '_confirm', lambda self, text: (_ for _ in ()).throw(AssertionError(text))) + pane._delete('cage') + assert pane.status.text().startswith('Not deleted') and session.store.total('cage') == 2 + + # test_details_are_a_vertical_list_with_bold_keys: one key per line, each key bold + def test_details_are_a_vertical_list_with_bold_keys(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _ = _kinds_pane(qtbot, make_config) + lines = pane.details['cage'].text().split('
') + assert len(lines) == 7 and all(line.startswith('') and ': ' in line for line in lines) + + # test_kinds_flow_into_columns: a wide block gives one column, a narrow one gives several, filling across then down + def test_kinds_flow_into_columns(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + pane, _, _ = _kinds_pane_with_picks(qtbot, make_config) + pane.resize(900, 600) + pane.show() + position = lambda i: pane.grid.getItemPosition(pane.grid.indexOf(pane.cards[i]))[:2] + monkeypatch.setattr('pickmix.gui.panes.kinds_pane._CARD_WIDTH', 10000) + pane._columns = 0 + pane._reflow() + assert pane._columns == 1 and [position(i) for i in range(3)] == [(0, 0), (1, 0), (2, 0)] + monkeypatch.setattr('pickmix.gui.panes.kinds_pane._CARD_WIDTH', 100) + pane._reflow() + assert pane._columns >= 3 and [position(i) for i in range(3)] == [(0, 0), (0, 1), (0, 2)] + + # test_add_button_is_on_the_right: the Add button sits after the heading's stretch + def test_add_button_is_on_the_right(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _ = _kinds_pane(qtbot, make_config) + pane.resize(900, 600) + pane.show() + qtbot.waitUntil(lambda: pane.add_button.isVisible()) + assert pane.add_button.mapTo(pane, pane.add_button.rect().topRight()).x() > pane.scroll.mapTo(pane, pane.scroll.rect().center()).x() + +# TestSettingsPane: settings pane +class TestSettingsPane: + # test_shows_project: source and ring values come from the config, and showing them saves nothing + def test_shows_project(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + path = make_config() + before = path.read_text() + pane = SettingsPane() + qtbot.addWidget(pane) + with qtbot.assertNotEmitted(pane.config_saved): + pane.set_project(open_project(path)) + assert pane.source_box.currentText() == 'denoised' and pane.ring_form.get().radius_step_a == 25.0 + assert path.read_text() == before + + # test_source_autosaves: choosing a source writes config.toml and announces it + def test_source_autosaves(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _settings_pane(qtbot, make_config) + with qtbot.waitSignal(pane.config_saved): + pane.source_box.setCurrentText('raw') + assert load_config(path).source == 'raw' + + # test_ring_value_autosaves: a committed ring value is written, the others are kept + def test_ring_value_autosaves(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _settings_pane(qtbot, make_config) + with qtbot.waitSignal(pane.config_saved): + pane.ring_form._boxes['radius_step_a'][0].setValue(50.0) + config = load_config(path) + assert config.ring.radius_step_a == 50.0 and config.ring.radius_range_a == (150.0, 500.0) + + # test_no_save_button: there is nothing to press + def test_no_save_button(self, qtbot: QtBot) -> None: + pane = SettingsPane() + qtbot.addWidget(pane) + assert not hasattr(pane, 'save_button') + + # test_hotkey_defaults: keys start on the kinds in order, the rest are unused + def test_hotkey_defaults(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _ = _settings_pane(qtbot, make_config) + assert [b.currentText() for b in pane.hotkey_boxes] == ['cage', *[NO_KIND] * 8] + assert pane.hotkey_boxes[1].itemText(0) == NO_KIND and pane.hotkey_boxes[1].count() == 2 + + # test_hotkey_change_saves: choosing a kind for a key saves it and moves the kind off its old key + def test_hotkey_change_saves(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, path = _settings_pane(qtbot, make_config) + with qtbot.waitSignal(pane.hotkeys_saved): + pane.hotkey_boxes[2].setCurrentText('cage') + assert load_config(path).hotkeys == ['', '', 'cage', *[''] * 6] + assert pane.hotkey_boxes[0].currentText() == NO_KIND + + # test_refresh_hotkeys: new kinds appear in the dropdowns after the project changes + def test_refresh_hotkeys(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, _ = _settings_pane(qtbot, make_config) + pane.project.update(kinds={'cage': Kind(marker_a=600, box_a=700), 'yellow': Kind(marker_a=100, box_a=100)}, hotkeys=['cage', 'yellow']) + pane.refresh_hotkeys() + assert pane.hotkey_boxes[1].currentText() == 'yellow' and pane.hotkey_boxes[5].count() == 3 + + # test_form_labels: parameters show their title and explain themselves on hover + def test_form_labels(self, qtbot: QtBot) -> None: + pane = SettingsPane() + qtbot.addWidget(pane) + label = pane.ring_form.labels['radius_range_a'] + assert label.text() == 'Radius range (Å)' and label.toolTip() + assert len(pane.ring_form._boxes['radius_range_a']) == 2 + + # test_ring_section_collapsed: ring parameters are hidden until the section is opened + def test_ring_section_collapsed(self, qtbot: QtBot) -> None: + pane = SettingsPane() + qtbot.addWidget(pane) + assert pane.ring_form.isHidden() + pane.ring_section.toggle.setChecked(True) + assert not pane.ring_form.isHidden() + + # test_width_fixed: opening the ring section does not change the pane's minimum width + def test_width_fixed(self, qtbot: QtBot) -> None: + pane = SettingsPane() + qtbot.addWidget(pane) + closed = pane.minimumSizeHint().width() + pane.ring_section.toggle.setChecked(True) + assert pane.minimumSizeHint().width() == closed + +# TestTomoPane: tomogram dock +class TestTomoPane: + # test_lines: number, name and picks, no markers + def test_lines(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) + assert pane.list.item(0).text().split() == ['1', 'Position_1_2', '1'] + assert pane.list.item(1).text().split() == ['2', 'Position_1_3', '0'] + + # test_state_colours: current blue and bold, opened green, not yet opened orange + def test_state_colours(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + assert pane.list.item(0).foreground().color() == COLOUR_CURRENT and pane.list.item(0).font().bold() + assert pane.list.item(1).foreground().color() == COLOUR_PENDING and not pane.list.item(1).font().bold() + pane._clicked(pane.list.item(2)) + assert session.current == 'Other_9' + assert pane.list.item(2).foreground().color() == COLOUR_CURRENT + assert pane.list.item(0).foreground().color() == COLOUR_VISITED and not pane.list.item(0).font().bold() + assert pane.list.item(1).foreground().color() == COLOUR_PENDING + + # test_visited_label: visited count sits above the search box, with no per-kind totals + def test_visited_label(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + assert pane.visited.text() == 'Visited 1/3' + session.go(1) + assert pane.visited.text() == 'Visited 2/3' + assert not hasattr(pane, 'totals') + + # test_enter_does_nothing: Enter in the search box changes nothing, matching is live only + def test_enter_does_nothing(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + before = session.current + pane.jump_edit.setText('other') + pane._live.stop() + pane.jump_edit.returnPressed.emit() + assert session.current == before and pane.jump_edit.text() == 'other' + +# TestWelcomePane: welcome pane +class TestWelcomePane: + # test_version_line: the dimmed line names the installed version + def test_version_line(self, qtbot: QtBot) -> None: + pane = WelcomePane() + qtbot.addWidget(pane) + assert pane.version.text() == f'pickmix v{__version__}' + + # test_buttons_have_icons: icon over text, both buttons + def test_buttons_have_icons(self, qtbot: QtBot) -> None: + pane = WelcomePane() + qtbot.addWidget(pane) + for button in (pane.open_button, pane.new_button): + labels = button.findChildren(QLabel) + assert len(labels) == 2 and not labels[0].pixmap().isNull() and labels[1].text() + + # test_text_fallback: without icon files the buttons are text only + def test_text_fallback(self, qtbot: QtBot, monkeypatch) -> None: + monkeypatch.setattr('pickmix.gui.style.tinted', lambda stem, **kwargs: None) + pane = WelcomePane() + qtbot.addWidget(pane) + assert [label.text() for label in pane.open_button.findChildren(QLabel)] == ['Open project'] + + # test_shortcuts: Ctrl+O and Ctrl+N press the buttons + def test_shortcuts(self, qtbot: QtBot) -> None: + pane = WelcomePane() + qtbot.addWidget(pane) + by_key = {s.key().toString(): s for s in pane.findChildren(QShortcut)} + assert set(by_key) == {'Ctrl+O', 'Ctrl+N'} + with qtbot.waitSignal(pane.open_button.clicked): + by_key['Ctrl+O'].activated.emit() + with qtbot.waitSignal(pane.new_button.clicked): + by_key['Ctrl+N'].activated.emit() + + # test_shortcut_respects_disabled: a button disabled during loading ignores its shortcut + def test_shortcut_respects_disabled(self, qtbot: QtBot) -> None: + pane = WelcomePane() + qtbot.addWidget(pane) + pane.open_button.setEnabled(False) + shortcut = next(s for s in pane.findChildren(QShortcut) if s.key().toString() == 'Ctrl+O') + with qtbot.assertNotEmitted(pane.open_button.clicked): + shortcut.activated.emit() + +# TestTomogramsPane: tomograms pane +class TestTomogramsPane: + # test_rows_and_summary: usable tomograms are listed, counted and unmarked + def test_rows_and_summary(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane = TomogramsPane() + qtbot.addWidget(pane) + pane.set_project(open_project(make_config())) + assert pane.table.rowCount() == 1 and pane.table.item(0, 1).text() == 'Position_1_2' + assert pane.table.item(0, 0).icon().isNull() and pane.table.item(0, 0).text() == '' + assert not pane.table.item(0, 4).icon().isNull() + assert pane.summary.text() == '1 total (1 denoised), 0 rejected' + + # test_rejected_row: a rejected tomogram gets an error marker with its reason and greyed text + def test_rejected_row(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane = TomogramsPane() + qtbot.addWidget(pane) + pane.set_project(open_project(make_config(shape=(4, 6, 20)))) + assert not pane.table.item(0, 0).icon().isNull() and 'shape mismatch' in pane.table.item(0, 0).toolTip() + assert pane.table.item(0, 2).text() == '(4, 6, 20)' + assert pane.summary.text() == '1 total (0 denoised), 1 rejected' + + # test_warning_without_denoised: a usable tomogram with no denoised pair is marked + def test_warning_without_denoised(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config(names=('A_1', 'B_2'))) + (project.config.denoised_dir / 'nt_raw4_B_2_stack_Vol.denoised.mrc').unlink() + project = open_project(project.path) + assert tomogram_status(project, 'A_1') == (None, '') + status, tip = tomogram_status(project, 'B_2') + assert status == 'warn' and 'denoised' in tip + + # test_marker_column_fixed: the marker column has no heading and a fixed width, so names align + def test_marker_column_fixed(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane = TomogramsPane() + qtbot.addWidget(pane) + pane.set_project(open_project(make_config(names=('A_1', 'B_2')))) + assert pane.table.horizontalHeaderItem(0).text() == '' and pane.table.columnWidth(0) == 28 + assert not pane.table.verticalHeader().isVisible() and not pane.table.showGrid() + + # test_live_search: typing a name shows that tomogram without pressing Enter, and keeps the text + def test_live_search(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + pane.jump_edit.setText('other') + qtbot.waitUntil(lambda: session.current == 'Other_9', timeout=2000) + assert pane.jump_edit.text() == 'other' + + # test_no_selection_highlight: clicking jumps but nothing is highlighted over the colours + def test_no_selection_highlight(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + assert pane.list.selectionMode() == pane.list.SelectionMode.NoSelection + pane._clicked(pane.list.item(1)) + assert session.current == 'Position_1_3' and not pane.list.selectedItems() + + # test_number_left_picks_right: the number column is left-aligned and the picks column right-aligned + def test_number_left_picks_right(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + pane, session = _tomo_pane(qtbot, make_config) + session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) + assert pane.header.text().startswith('No.') + assert pane.list.item(0).text().startswith('1 ') and pane.list.item(0).text().endswith(' 1') diff --git a/tests/gui/test_pick_tab.py b/tests/gui/test_pick_tab.py index e0d7bcc..a947c0f 100644 --- a/tests/gui/test_pick_tab.py +++ b/tests/gui/test_pick_tab.py @@ -13,7 +13,7 @@ # Import internal pickmix objects from pickmix.config import Kind from pickmix.estimates import EstimateService -from pickmix.gui.pick_tab import PickTab +from pickmix.gui.tabs.pick_tab import PickTab from pickmix.loader import Loader from pickmix.model import Session from pickmix.project import open_project @@ -54,6 +54,11 @@ def test_opens_middle_slice(self, setup: Callable[..., tuple[PickTab, Session]]) assert tab.z == 2 and tab.canvas.image_size() == (8, 6) assert tab.z_label.text() == '3 / 4' + # test_quiet_table: the pick table has the shared quiet look + def test_quiet_table(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert tab.table.verticalHeader().isHidden() and not tab.table.showGrid() + # test_add_pick: a click adds a pick of the active kind on the current slice def test_add_pick(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, session = setup() @@ -61,16 +66,16 @@ def test_add_pick(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: [pick] = session.store.all() assert (pick.kind, pick.x, pick.y, pick.z) == ('cage', 3.0, 2.0, 2.0) assert tab.canvas.marker_ids() == [pick.id] and tab.canvas.selected_id == pick.id - assert tab.table.rowCount() == 1 and tab.table.item(0, 0).text() == 'cage' + assert tab.table.rowCount() == 1 and tab.table.item(0, 1).text() == 'cage' # test_kind_switch: number keys choose the kind that new picks get def test_kind_switch(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, session = setup() - tab._pick_kind(1) + tab.pick_kind(1) assert tab.active == 'yellow' and tab.kind_buttons['yellow'].isChecked() tab.canvas.add_requested.emit(1.0, 1.0) assert session.store.all()[0].kind == 'yellow' - tab._pick_kind(8) + tab.pick_kind(8) assert tab.active == 'yellow' # test_move_and_delete: moving keeps z, deleting removes the marker @@ -98,6 +103,15 @@ def test_scroll_z(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab.canvas.z_scrolled.emit(-9) assert tab.z == 0 + # test_delete_needs_selection: Delete is greyed until a pick is selected and again once it is gone + def test_delete_needs_selection(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert not tab.delete_button.isEnabled() + tab.canvas.add_requested.emit(3.0, 2.0) + assert tab.delete_button.isEnabled() + tab.delete_selected() + assert not tab.delete_button.isEnabled() + # test_hide_kind: a hidden kind's markers go but its picks stay in the list def test_hide_kind(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, session = setup() @@ -129,8 +143,8 @@ def test_estimate_overlay(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab tab, session = setup() pick = session.store.add('Position_1_2', 'yellow', 3.0, 2.0, 2.0) qtbot.waitUntil(lambda: tab.canvas.overlay_visible(pick.id)[1], timeout=5000) - qtbot.waitUntil(lambda: tab.table.item(0, 4) is not None and tab.table.item(0, 4).text() == '0.00', timeout=5000) - assert tab.table.item(0, 5).text() == '' + qtbot.waitUntil(lambda: tab.table.item(0, 5) is not None and tab.table.item(0, 5).text() == '0.00', timeout=5000) + assert tab.table.item(0, 6).text() == '' # test_show_pick: a pick in another tomogram opens that tomogram and selects the pick def test_show_pick(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: @@ -148,19 +162,19 @@ def test_normals_and_noparent(self, qtbot: QtBot, setup: Callable[..., tuple[Pic near = session.store.add('Position_1_2', 'green', 3.0 + 56.0, 2.0, 2.0) session.store.add('Position_1_2', 'green', 3.0 + 20.0, 2.0, 2.0) qtbot.waitUntil(lambda: tab.canvas.normal_visible(near.id), timeout=5000) - qtbot.waitUntil(lambda: 'noparent' in {tab.table.item(r, 5).text() for r in range(tab.table.rowCount())}, timeout=5000) - assert [tab.table.item(r, 5).text() for r in range(3)] == ['', '', 'noparent'] + qtbot.waitUntil(lambda: 'noparent' in {tab.table.item(r, 6).text() for r in range(tab.table.rowCount())}, timeout=5000) + assert [tab.table.item(r, 6).text() for r in range(3)] == ['', '', 'noparent'] # test_hotkeys_follow_config: keys choose the kind the config puts on them, and buttons show their number def test_hotkeys_follow_config(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, session = setup() session.project.update(hotkeys=['', 'green', 'cage']) tab.refresh_kinds() - tab._pick_kind(0) + tab.pick_kind(0) assert tab.active == 'cage' - tab._pick_kind(1) + tab.pick_kind(1) assert tab.active == 'green' - tab._pick_kind(2) + tab.pick_kind(2) assert tab.active == 'cage' assert tab.kind_buttons['green'].text() == '(2) green' and tab.kind_buttons['yellow'].text() == 'yellow' @@ -172,7 +186,7 @@ def test_no_kinds(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: assert tab.active is None and tab.kind_buttons == {} tab.canvas.add_requested.emit(1.0, 1.0) assert session.store.all() == [] - tab._pick_kind(0) + tab.pick_kind(0) assert tab.active is None # test_title_is_name_only: the title is the tomogram name, with the navigation help kept below @@ -189,15 +203,94 @@ def test_hotkeys_row(self, setup: Callable[..., tuple[PickTab, Session]]) -> Non assert tab.kind_buttons['yellow'].minimumWidth() > 0 tab.kind_toggles['yellow'].click() assert 'yellow' in tab.hidden and not tab.kind_toggles['yellow'].isChecked() - tab._pick_kind(1) + tab.pick_kind(1) assert tab.active == 'yellow' + # test_auto_and_manual: Auto starts ticked, a manual change unticks it and ticking it restores the tomogram's limits + def test_auto_and_manual(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert tab.auto_box.isChecked() and tab.invert_box.isChecked() is False + auto = tab.contrast.values() + tab.contrast.set_values(auto[0] + 10, auto[1] - 10, emit=True) + assert not tab.auto_box.isChecked() and tab.contrast.values() != auto + tab.auto_box.setChecked(True) + assert tab.contrast.values() == auto and tab.brightness.value() == 0 + + # test_brightness_shifts_window: positive brightness lowers the window by that share of its width + def test_brightness_shifts_window(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + lo, hi = tab._window() + tab.brightness.setValue(10) + new_lo, new_hi = tab._window() + assert not tab.auto_box.isChecked() + assert abs((lo - new_lo) - 0.1 * (hi - lo)) < 1e-6 and abs((new_hi - new_lo) - (hi - lo)) < 1e-6 + + # test_manual_window_kept_across_tomograms: with Auto off, the next tomogram opens with the same pixel-value window + def test_manual_window_kept_across_tomograms(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup(THREE) + low, high = tab.contrast.values() + tab.contrast.set_values(low + 50, high - 50, emit=True) + raw = tab._raw_window() + session.step(1) + qtbot.waitUntil(lambda: tab.opened is not None and tab.opened.name == 'Position_1_3', timeout=5000) + new = tab._raw_window() + assert abs(new[0] - raw[0]) < 2 and abs(new[1] - raw[1]) < 2 + # test_layout: Z is a vertical scrollbar and image and overlay controls sit in the settings pane def test_layout(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, _ = setup() - assert tab.z_slider.orientation() == Qt.Orientation.Vertical - for widget in (tab.lo_slider, tab.hi_slider, tab.invert_box, tab.auto_button, tab.fit_button, tab.rings_box, tab.boxes_box, tab.normals_box): + assert tab.z_slider.orientation() == Qt.Orientation.Vertical and tab.settings.width() <= 220 + for widget in (tab.contrast, tab.brightness, tab.auto_box, tab.invert_box, tab.fit_button, tab.fill_button, tab.rings_box, tab.boxes_box, tab.normals_box, *tab.kind_buttons.values()): assert tab.settings.isAncestorOf(widget) assert not tab.settings.isAncestorOf(tab.table) and not tab.settings.isAncestorOf(tab.z_slider) header = tab.table.horizontalHeader() - assert header.sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents and header.sectionResizeMode(1) == QHeaderView.ResizeMode.Stretch + assert header.sectionResizeMode(0) == QHeaderView.ResizeMode.Fixed and header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents + + # test_tomo_pane_toggle: the tomogram pane sits in the tab and its button hides and shows it + def test_tomo_pane_toggle(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup(THREE) + assert tab.splitter.indexOf(tab.tomo) == 0 and not tab.tomo.isHidden() + assert tab.tomo.list.count() == 3 + tab.tomo_toggle.click() + assert tab.tomo.isHidden() + tab.tomo_toggle.click() + assert not tab.tomo.isHidden() + assert not tab.tomo_toggle.icon().isNull() + + # test_tomo_pane_navigates: choosing a tomogram in the pane changes the tomogram shown + def test_tomo_pane_navigates(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup(THREE) + tab.tomo._clicked(tab.tomo.list.item(2)) + assert session.current == 'Other_9' and tab.title.text() == 'Other_9' + + # test_status_badges: each row has a badge, spinning until the estimate arrives and ok after + def test_status_badges(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + session.store.add('Position_1_2', 'yellow', 3.0, 2.0, 2.0) + assert tab.table.horizontalHeaderItem(0).text() == '' and tab.table.cellWidget(0, 0) is not None + qtbot.waitUntil(lambda: tab.table.cellWidget(0, 0).status == 'ok', timeout=5000) + assert tab.status.text() == '' + + # test_view_toggles: each overlay toggle starts shown, hides its overlay and shows a different eye when hidden + def test_view_toggles(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert all(b.isChecked() for b in (tab.rings_box, tab.boxes_box, tab.normals_box)) + shown_icon = tab.rings_box.icon().cacheKey() + tab.rings_box.click() + assert not tab.rings_box.isChecked() and tab.rings_box.icon().cacheKey() != shown_icon + assert tab.rings_box.text() == 'Rings' + + # test_delete_icon: delete is an icon button with a tooltip + def test_delete_icon(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert not tab.delete_button.icon().isNull() and tab.delete_button.toolTip() + + # test_fit_and_fill_buttons: both buttons act on the canvas view + def test_fit_and_fill_buttons(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + tab.resize(900, 600) + tab.show() + tab.fit_button.click() + fit_scale = tab.canvas.transform().m11() + tab.fill_button.click() + assert tab.canvas.transform().m11() >= fit_scale diff --git a/tests/gui/test_project_tab.py b/tests/gui/test_project_tab.py index e6b8e5a..c209c26 100644 --- a/tests/gui/test_project_tab.py +++ b/tests/gui/test_project_tab.py @@ -9,28 +9,46 @@ # Import internal pickmix objects from pickmix.config import load_config -from pickmix.gui.project_tab import ProjectTab +from pickmix.gui.style import MEDIUM_PAD +from pickmix.gui.tabs.project_tab import ProjectTab from pickmix.project import open_project # TestProjectTab: project tab class TestProjectTab: + # test_top_bar: the project name and config path sit on the left, reload and close on the right + def test_top_bar(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + path = make_config() + tab = ProjectTab() + qtbot.addWidget(tab) + tab.set_project(open_project(path)) + assert tab.title_label.text() == path.parent.name and tab.path_label.text() == str(path.resolve()) + assert tab.title_label.font().bold() and tab.reload_button.toolTip() and tab.close_button.toolTip() + + # test_button_icons: reload and close are medium icon buttons, and Add has an icon + def test_button_icons(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab = ProjectTab() + qtbot.addWidget(tab) + tab.set_project(open_project(make_config())) + for button in (tab.reload_button, tab.close_button): + assert not button.icon().isNull() and button.width() == button.height() == 28 + MEDIUM_PAD + assert not tab.kinds_pane.add_button.icon().isNull() + # test_table: usable tomograms are listed and counted def test_table(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab = ProjectTab() qtbot.addWidget(tab) tab.set_project(open_project(make_config())) - assert tab.table.rowCount() == 1 - assert tab.table.item(0, 0).text() == 'Position_1_2' - assert tab.table.item(0, 4).text() == 'ok' - assert tab.summary.text() == '1 usable (1 with denoised), 0 rejected' + assert tab.tomograms.table.rowCount() == 1 + assert tab.tomograms.table.item(0, 1).text() == 'Position_1_2' + assert tab.tomograms.summary.text() == '1 total (1 denoised), 0 rejected' # test_rejected_row: a rejected tomogram shows its reason def test_rejected_row(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab = ProjectTab() qtbot.addWidget(tab) tab.set_project(open_project(make_config(shape=(4, 6, 20)))) - assert tab.table.item(0, 4).text() == 'rejected: shape mismatch' - assert tab.table.item(0, 1).text() == '(4, 6, 20)' + assert 'shape mismatch' in tab.tomograms.table.item(0, 0).toolTip() + assert tab.tomograms.table.item(0, 2).text() == '(4, 6, 20)' # test_open_config: opening in the background fills the tab and announces the project def test_open_config(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: @@ -38,48 +56,45 @@ def test_open_config(self, qtbot: QtBot, make_config: Callable[..., Path]) -> No qtbot.addWidget(tab) with qtbot.waitSignal(tab.project_changed): tab.open_config(make_config()) - assert tab.table.rowCount() == 1 + assert tab.tomograms.table.rowCount() == 1 - # test_save_settings: source and a ring parameter reach config.toml - def test_save_settings(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # test_settings_autosave: changing source in the tab reaches config.toml and announces it, with no save button + def test_settings_autosave(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: path = make_config() tab = ProjectTab() qtbot.addWidget(tab) tab.set_project(open_project(path)) - tab.source_box.setCurrentText('raw') - tab.ring_form._boxes['radius_step_a'][0].setValue(50.0) with qtbot.waitSignal(tab.config_saved): - tab.save_button.click() - config = load_config(path) - assert config.source == 'raw' - assert config.ring.radius_step_a == 50.0 - assert config.ring.radius_range_a == (150.0, 500.0) + tab.settings_pane.source_box.setCurrentText('raw') + assert load_config(path).source == 'raw' + assert not hasattr(tab, 'save_button') - # test_form_labels: parameters show their title and explain themselves on hover - def test_form_labels(self, qtbot: QtBot) -> None: + # test_kinds_pane_shows_kinds: the kinds of the opened project are listed in their column + def test_kinds_pane_shows_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab = ProjectTab() qtbot.addWidget(tab) - label = tab.ring_form.labels['radius_range_a'] - assert label.text() == 'Radius range (Å)' and label.toolTip() - assert len(tab.ring_form._boxes['radius_range_a']) == 2 + tab.set_project(open_project(make_config())) + assert tab.kinds_pane.kind_names() == ['cage'] - # test_ring_section_collapsed: ring parameters are hidden until the section is opened - def test_ring_section_collapsed(self, qtbot: QtBot) -> None: + # test_hotkey_change_announces_kinds: a hotkey saved in the settings column tells the other tabs + def test_hotkey_change_announces_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab = ProjectTab() qtbot.addWidget(tab) - assert tab.ring_form.isHidden() - tab.ring_section.toggle.setChecked(True) - assert not tab.ring_form.isHidden() + tab.set_project(open_project(make_config())) + with qtbot.waitSignal(tab.kinds_saved): + tab.settings_pane.hotkey_boxes[2].setCurrentText('cage') - # test_settings_width_fixed: opening the ring section does not change settings pane width - def test_settings_width_fixed(self, qtbot: QtBot) -> None: + # test_new_kind_reaches_hotkeys: a kind added in the kinds column is offered in the settings dropdowns + def test_new_kind_reaches_hotkeys(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + from pickmix.config import Kind tab = ProjectTab() qtbot.addWidget(tab) - closed = tab.settings.minimumSizeHint().width() - tab.ring_section.toggle.setChecked(True) - assert tab.settings.minimumSizeHint().width() == closed + tab.set_project(open_project(make_config())) + with qtbot.waitSignal(tab.kinds_saved): + tab.kinds_pane._apply('yellow', Kind(marker_a=100, box_a=100)) + assert tab.settings_pane.hotkey_boxes[1].currentText() == 'yellow' - # test_start_page_and_close: start page shows until a project opens, and closing returns to it + # test_start_page_and_close: the welcome page shows until a project opens, and closing returns to it def test_start_page_and_close(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab = ProjectTab() qtbot.addWidget(tab) diff --git a/tests/gui/test_review_tab.py b/tests/gui/test_review_tab.py index a2013fb..3cf9730 100644 --- a/tests/gui/test_review_tab.py +++ b/tests/gui/test_review_tab.py @@ -7,16 +7,16 @@ from collections.abc import Callable, Iterator from pathlib import Path import pytest +from PySide6.QtWidgets import QHeaderView from pytestqt.qtbot import QtBot # Import internal pickmix objects from pickmix.config import Kind from pickmix.estimates import EstimateService -from pickmix.gui.review_tab import ReviewTab +from pickmix.gui.tabs.review_tab import ReviewTab from pickmix.loader import Loader from pickmix.model import Session from pickmix.project import open_project -from pickmix.utils.star import read_star_loop # NAME: the single tomogram of the fixture project NAME: str = 'Position_1_2' @@ -61,6 +61,11 @@ def parent_setup(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[tup # TestReviewTab: Review tab class TestReviewTab: + # test_quiet_table: the pick table has the shared quiet look + def test_quiet_table(self, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + assert tab.table.verticalHeader().isHidden() and not tab.table.showGrid() + # test_table_and_filters: rows follow the filters def test_table_and_filters(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: tab, session, service = setup() @@ -72,49 +77,82 @@ def test_table_and_filters(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewT tab.kind_filter.setCurrentText('bad') assert tab.table.rowCount() == 1 and tab.table.item(0, 6).text() == 'error' tab.kind_filter.setCurrentText('(all)') - tab.flagged_box.setChecked(True) + tab.flag_filter.setCurrentText('Flagged only') assert [tab.table.item(r, 1).text() for r in range(tab.table.rowCount())] == ['bad'] - # test_delete_flagged: only flagged picks shown are removed - def test_delete_flagged(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + # test_delete_shown: only the picks the filters show are removed, here the flagged ones + def test_delete_shown(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: tab, session, service = setup() good = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) session.store.add(NAME, 'bad', 1.0, 1.0, 1.0) qtbot.waitUntil(lambda: all(service.store.state(p) == 'ok' for p in session.store.all()), timeout=5000) - tab.refresh() - assert tab.delete_flagged() == 1 + tab.flag_filter.setCurrentText('Flagged only') + assert tab.delete_shown() == 1 assert [p.id for p in session.store.all()] == [good.id] - # test_kind_numbers: per-kind counts and box in the export table - def test_kind_numbers(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + # test_delete_selected: the selected pick goes, others stay, and nothing selected deletes nothing + def test_delete_selected(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: tab, session, service = setup() - pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) - qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) + session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + second = session.store.add(NAME, 'yellow', 5.0, 3.0, 2.0) tab.refresh() - assert [tab.kinds_table.item(0, c).text() for c in range(6)] == ['yellow', '1', '1', '0', '21', '4'] + tab.table.clearSelection() + tab.table.setCurrentCell(-1, -1) + assert tab.delete_selected() == 0 + tab.table.selectRow(0) + assert tab.delete_selected() == 1 + assert [p.id for p in session.store.all()] == [second.id] - # test_export_and_extract: both write their files and report skipped picks - def test_export_and_extract(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + # test_flag_filter_modes: all, flagged only and non-flagged only partition the picks + def test_flag_filter_modes(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: tab, session, service = setup() - pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) - qtbot.waitUntil(lambda: service.store.state(pick) == 'ok', timeout=5000) - out = session.project.config.out_dir - lines = tab.export_all() - assert any(l.startswith('yellow: wrote 1 particles') for l in lines) and 'bad: nothing to export' in lines - assert read_star_loop(out / 'relion' / 'yellow' / 'particles.star')[0]['rlnCenteredCoordinateXAngst'] == '0.00' - lines = tab.extract_all() - assert any(l.startswith('yellow: wrote 1 boxes of 4 px') for l in lines) - assert (out / 'particles' / 'yellow' / NAME / f'{NAME}_000.mrc').exists() - service.shutdown() - session.store.add(NAME, 'yellow', 1.0, 1.0, 1.0) - assert any('no current estimate' in l for l in tab.export_all()) + session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + session.store.add(NAME, 'bad', 1.0, 1.0, 1.0) + qtbot.waitUntil(lambda: all(service.store.state(p) == 'ok' for p in session.store.all()), timeout=5000) + tab.refresh() + counts = [] + for choice in ('(all)', 'Flagged only', 'Non-flagged only'): + tab.flag_filter.setCurrentText(choice) + counts.append(tab.table.rowCount()) + assert counts == [2, 1, 1] + + # test_controls_layout: filters, buttons and the count are three separate columns of one row + def test_controls_layout(self, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + tab, _, _ = setup() + assert tab.flag_filter.itemText(0) == '(all)' and tab.reestimate_button.text() == 'Re-estimate all' + assert not tab.reestimate_button.icon().isNull() + assert not hasattr(tab, 'delete_button') and not hasattr(tab, 'flagged_box') + + # test_status_icons_and_columns: the estimate column is an icon, and only tomogram and kind size to their contents + def test_status_icons_and_columns(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + tab, session, service = setup() + session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + session.store.add(NAME, 'bad', 1.0, 1.0, 1.0) + qtbot.waitUntil(lambda: all(service.store.state(p) == 'ok' for p in session.store.all()), timeout=5000) + tab.refresh() + rows = {tab.table.item(r, 1).text(): tab.table.item(r, 7) for r in range(tab.table.rowCount())} + assert rows['yellow'].text() == '' and not rows['yellow'].icon().isNull() and rows['yellow'].toolTip() == 'Estimate is fine' + assert not rows['bad'].icon().isNull() and 'error' in rows['bad'].toolTip() + header = tab.table.horizontalHeader() + modes = [header.sectionResizeMode(c) for c in range(8)] + assert modes[:2] == [QHeaderView.ResizeMode.ResizeToContents] * 2 and set(modes[2:]) == {QHeaderView.ResizeMode.Stretch} + + # test_preview_labels: the headings are gone and each image is labelled in its corner instead + def test_preview_labels(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: + tab, session, _ = setup() + assert not hasattr(tab, 'xy_heading') and not hasattr(tab, 'xz_heading') + session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + tab.refresh() + tab.table.selectRow(0) + qtbot.waitUntil(lambda: all(not label.pixmap().isNull() for label in (tab.xy_label, tab.xz_label, tab.yz_label)), timeout=5000) + assert tab.yz_label.toolTip() and not tab.reestimate_button.icon().isNull() # test_crops: preview crops are cut around the pick def test_crops(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: tab, _, _ = setup() - xy, xz = tab.crops(NAME, (4.0, 3.0, 2.0)) + xy, xz, yz = tab.crops(NAME, (4.0, 3.0, 2.0)) vol = np.arange(4 * 6 * 8, dtype=np.float32).reshape(4, 6, 8) - assert np.array_equal(xy, vol[2]) and np.array_equal(xz, vol[:, 3, :]) + assert np.array_equal(xy, vol[2]) and np.array_equal(xz, vol[:, 3, :]) and np.array_equal(yz, vol[:, :, 4]) # test_double_click_opens: double-clicking a row asks to open the pick def test_double_click_opens(self, qtbot: QtBot, setup: Callable[[], tuple[ReviewTab, Session, EstimateService]]) -> None: @@ -125,8 +163,8 @@ def test_double_click_opens(self, qtbot: QtBot, setup: Callable[[], tuple[Review tab._double_clicked(0, 0) assert blocker.args == [NAME, pick.id] - # test_unmatched_flagged_and_skipped: a pick off its parent's surface is flagged noparent, reported, and left out of the star file - def test_unmatched_flagged_and_skipped(self, qtbot: QtBot, parent_setup: tuple[ReviewTab, Session, EstimateService]) -> None: + # test_unmatched_flagged: a pick off its parent's surface is flagged noparent and can be deleted + def test_unmatched_flagged(self, qtbot: QtBot, parent_setup: tuple[ReviewTab, Session, EstimateService]) -> None: tab, session, service = parent_setup session.store.add(NAME, 'cage', 10.0, 10.0, 10.0) session.store.add(NAME, 'green', 10.0 + 56.0, 10.0, 10.0) @@ -135,8 +173,3 @@ def test_unmatched_flagged_and_skipped(self, qtbot: QtBot, parent_setup: tuple[R tab.refresh() flags = {(tab.table.item(r, 1).text(), tab.table.item(r, 6).text()) for r in range(tab.table.rowCount())} assert ('green', 'noparent') in flags - lines = tab.export_all() - assert any('green: 1 picks have no cage within 100 A' in l for l in lines) - [row] = read_star_loop(session.project.config.out_dir / 'relion' / 'green' / 'particles.star') - assert (row['rlnTomoSubtomogramTilt'], row['rlnAngleTiltPrior']) == ('90.0000', '0.0000') - assert tab.delete_flagged() == 1 diff --git a/tests/gui/test_style.py b/tests/gui/test_style.py index 7078855..c759227 100644 --- a/tests/gui/test_style.py +++ b/tests/gui/test_style.py @@ -5,14 +5,15 @@ # Import external dependencies import pytest from pytestqt.qtbot import QtBot -from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget +from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel, QTableWidget, QVBoxLayout, QWidget # Import internal pickmix objects -from pickmix.gui.kinds_tab import KindsTab -from pickmix.gui.pick_tab import PickTab -from pickmix.gui.project_tab import ProjectTab -from pickmix.gui.review_tab import ReviewTab -from pickmix.gui.style import MARGIN, SPACING, body_pt, form_label, heading, help_marker, hint, icon, standardise, tidy, title +from pickmix.gui.panes.kinds_pane import KindsPane +from pickmix.gui.tabs.export_tab import ExportTab +from pickmix.gui.tabs.pick_tab import PickTab +from pickmix.gui.tabs.project_tab import ProjectTab +from pickmix.gui.tabs.review_tab import ReviewTab +from pickmix.gui.style import MARGIN, MEDIUM_PAD, MEDIUM_SIZE, SPACING, big_button, medium_button, body_pt, form_label, heading, help_marker, hint, icon, icon_button, quiet_table, standardise, tidy, title # TestStyle: shared text levels and spacing class TestStyle: @@ -59,9 +60,34 @@ def test_standardise(self, qtbot: QtBot) -> None: app.setStyleSheet('') # test_tabs_use_shared_spacing: every tab's top-level layout uses the shared margin and spacing - @pytest.mark.parametrize('cls', [ProjectTab, KindsTab, PickTab, ReviewTab]) + @pytest.mark.parametrize('cls', [ProjectTab, PickTab, ReviewTab, ExportTab]) def test_tabs_use_shared_spacing(self, qtbot: QtBot, cls: type[QWidget]) -> None: tab = cls() qtbot.addWidget(tab) layout = tab.layout() assert layout.contentsMargins().left() == MARGIN and layout.spacing() == SPACING + + # test_medium_button: a medium button has its fixed size and its text, and a big one keeps its minimum + def test_medium_button(self, qtbot: QtBot) -> None: + medium, big = medium_button('Export', 'download'), big_button('Open', 'folder_open') + qtbot.addWidget(medium) + qtbot.addWidget(big) + assert (medium.width(), medium.height()) == MEDIUM_SIZE + assert any(label.text() == 'Export' for label in medium.findChildren(QLabel)) + assert big.minimumWidth() == 200 and big.minimumHeight() == 150 + + # test_quiet_table: no row numbers, no grid, whole rows, read-only unless asked + def test_quiet_table(self, qtbot: QtBot) -> None: + table = QTableWidget(1, 1) + qtbot.addWidget(table) + quiet_table(table) + assert table.verticalHeader().isHidden() and not table.showGrid() + assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers + quiet_table(table, editable=True) + assert table.editTriggers() != QAbstractItemView.EditTrigger.NoEditTriggers + + # test_icon_button_size: with a size the button is a square of the icon edge plus padding + def test_icon_button_size(self, qtbot: QtBot) -> None: + button = icon_button('refresh', 'Reload', 'tip', size=28) + qtbot.addWidget(button) + assert button.width() == button.height() == 28 + MEDIUM_PAD diff --git a/tests/gui/test_tomo_dock.py b/tests/gui/test_tomo_dock.py deleted file mode 100644 index c500022..0000000 --- a/tests/gui/test_tomo_dock.py +++ /dev/null @@ -1,64 +0,0 @@ -''' -pickmix: tests for tomogram dock -''' - -# Import external dependencies -from collections.abc import Callable -from pathlib import Path -from pytestqt.qtbot import QtBot - -# Import internal pickmix objects -from pickmix.gui.tomo_dock import COLOUR_CURRENT, COLOUR_PENDING, COLOUR_VISITED, TomoDock - -from pickmix.model import Session -from pickmix.project import open_project - -# THREE: tomograms for the dock tests -THREE: tuple[str, ...] = ('Position_1_2', 'Position_1_3', 'Other_9') - -# _dock: a dock over a three-tomogram session -def _dock(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[TomoDock, Session]: - session = Session(open_project(make_config(names=THREE))) - dock = TomoDock() - qtbot.addWidget(dock) - dock.set_session(session) - return dock, session - -# TestTomoDock: tomogram dock -class TestTomoDock: - # test_lines: number, name and picks, no markers - def test_lines(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - dock, session = _dock(qtbot, make_config) - session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) - assert dock.list.item(0).text().split() == ['1', 'Position_1_2', '1'] - assert dock.list.item(1).text().split() == ['2', 'Position_1_3', '0'] - - # test_state_colours: current blue and bold, opened green, not yet opened orange - def test_state_colours(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - dock, session = _dock(qtbot, make_config) - assert dock.list.item(0).foreground().color() == COLOUR_CURRENT and dock.list.item(0).font().bold() - assert dock.list.item(1).foreground().color() == COLOUR_PENDING and not dock.list.item(1).font().bold() - dock._clicked(dock.list.item(2)) - assert session.current == 'Other_9' - assert dock.list.item(2).foreground().color() == COLOUR_CURRENT - assert dock.list.item(0).foreground().color() == COLOUR_VISITED and not dock.list.item(0).font().bold() - assert dock.list.item(1).foreground().color() == COLOUR_PENDING - - # test_visited_label: visited count sits above the search box, with no per-kind totals - def test_visited_label(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - dock, session = _dock(qtbot, make_config) - assert dock.visited.text() == 'Visited 1/3' - session.go(1) - assert dock.visited.text() == 'Visited 2/3' - assert not hasattr(dock, 'totals') - - - # test_jump_box: a name jumps and clears the box, no match keeps the text - def test_jump_box(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - dock, session = _dock(qtbot, make_config) - dock.jump_edit.setText('other') - dock.jump_edit.returnPressed.emit() - assert session.current == 'Other_9' and dock.jump_edit.text() == '' - dock.jump_edit.setText('zzz') - dock.jump_edit.returnPressed.emit() - assert session.current == 'Other_9' and dock.jump_edit.text() == 'zzz' diff --git a/tests/test_average.py b/tests/test_average.py index 08e0b40..322a57e 100644 --- a/tests/test_average.py +++ b/tests/test_average.py @@ -7,7 +7,7 @@ from pathlib import Path # Import internal pickmix objects -from pickmix.average import average_kind +from pickmix.average import average_kind, save_average from pickmix.config import Config, Kind from pickmix.estimate.registry import Fit from pickmix.model import Pick @@ -31,7 +31,7 @@ def _item(i: int, tomogram: str, x: float, y: float, z: float) -> tuple[Pick, Fi # TestAverage: mean projections, montage and files class TestAverage: - # test_blob_in_centre: the overall mean is brightest in the middle, and files are written per tomogram and overall + # test_blob_in_centre: the overall mean is brightest in the middle, and averaging alone writes nothing def test_blob_in_centre(self, tmp_path: Path) -> None: tomos = {'T': _tomogram(tmp_path, 'T', [(12, 20, 20), (28, 20, 20)]), 'U': _tomogram(tmp_path, 'U', [(20, 28, 20)])} config = Config(raw_dir=tmp_path, tomograms_star=tmp_path / 't.star', out_dir=tmp_path / 'out') @@ -40,7 +40,21 @@ def test_blob_in_centre(self, tmp_path: Path) -> None: assert r.count == 3 and r.box_px == 20 and r.overall.shape == (20, 20) assert r.overall[10, 10] > r.overall[0, 0] + 1.0 assert set(r.per_tomogram) == {'T', 'U'} and r.montage.shape[0] >= 40 - assert sorted(p.name for p in r.folder.glob('*.png')) == ['T_mean.png', 'U_mean.png', 'overall_mean.png', 'overall_montage.png'] + assert not r.folder.exists() + + # test_save_average: the overall images are always written, per-tomogram means only when asked, and old files are cleared + def test_save_average(self, tmp_path: Path) -> None: + tomos = {'T': _tomogram(tmp_path, 'T', [(12, 20, 20), (28, 20, 20)]), 'U': _tomogram(tmp_path, 'U', [(20, 28, 20)])} + config = Config(raw_dir=tmp_path, tomograms_star=tmp_path / 't.star', out_dir=tmp_path / 'out') + items = [_item(1, 'T', 12, 20, 20), _item(2, 'T', 28, 20, 20), _item(3, 'U', 20, 28, 20)] + r = average_kind('yellow', Kind(marker_a=60, box_a=100), items, tomos, config) + assert r.counts == {'T': 2, 'U': 1} and sum(r.counts.values()) == r.count + names = lambda: sorted(p.name for p in r.folder.glob('*.png')) + paths = save_average(r) + assert sorted(p.name for p in paths) == names() == ['overall_mean.png', 'overall_montage.png'] + assert sorted(p.name for p in save_average(r, per_tomogram=True)) == names() == ['T_mean.png', 'U_mean.png', 'overall_mean.png', 'overall_montage.png'] + save_average(r) + assert names() == ['overall_mean.png', 'overall_montage.png'] # test_nothing: no picks is an error def test_nothing(self, tmp_path: Path) -> None: @@ -54,3 +68,12 @@ def test_mixed_pixel_sizes(self, tmp_path: Path) -> None: config = Config(raw_dir=tmp_path, tomograms_star=tmp_path / 't.star', out_dir=tmp_path / 'out') with pytest.raises(ValueError, match='pixel size'): average_kind('yellow', Kind(marker_a=60, box_a=100), [_item(1, 'T', 20, 20, 20), _item(2, 'U', 20, 20, 20)], tomos, config) + + # test_montage_labels: one label per cell, pick order within its tomogram and the tomogram name, and the geometry matches the montage + def test_montage_labels(self, tmp_path: Path) -> None: + tomos = {'T': _tomogram(tmp_path, 'T', [(12, 20, 20), (28, 20, 20)]), 'U': _tomogram(tmp_path, 'U', [(20, 28, 20)])} + config = Config(raw_dir=tmp_path, tomograms_star=tmp_path / 't.star', out_dir=tmp_path / 'out') + items = [_item(1, 'T', 12, 20, 20), _item(2, 'T', 28, 20, 20), _item(3, 'U', 20, 28, 20)] + r = average_kind('yellow', Kind(marker_a=60, box_a=100), items, tomos, config) + assert r.labels == ['1 (T)', '2 (T)', '1 (U)'] and r.grid == 2 and r.pad == 2 + assert r.montage.shape == (r.grid * (r.box_px + r.pad) + r.pad,) * 2 diff --git a/tests/test_config.py b/tests/test_config.py index 643bc80..47ffd0d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -108,3 +108,13 @@ def test_no_kinds(self, tmp_path: Path) -> None: def test_kind_documented(self) -> None: for name, field in Kind.model_fields.items(): assert field.title and field.description, name + + # test_name: project name survives saving/loading and blank name is dropped from the file + def test_name(self, tmp_path: Path) -> None: + config = _config().model_copy(update={'name': 'Cages'}) + save_config(config, tmp_path / 'a.toml') + assert load_config(tmp_path / 'a.toml').name == 'Cages' + blank = Config(name=' ', raw_dir=Path('/r'), tomograms_star=Path('/t.star')) + assert blank.name is None + save_config(blank, tmp_path / 'b.toml') + assert 'name' not in (tmp_path / 'b.toml').read_text() diff --git a/tests/test_export.py b/tests/test_export.py index e92031a..f026d2c 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -3,13 +3,13 @@ ''' # Import external dependencies -import pytest +import csv, pytest from pathlib import Path # Import internal pickmix objects from pickmix.config import Kind from pickmix.estimate.registry import Fit -from pickmix.export import Item, export_kind, recommend_box +from pickmix.export import Item, export_csv, export_kind, recommend_box from pickmix.geometry import match_items from pickmix.model import Pick from pickmix.utils.io import Tomogram @@ -94,3 +94,19 @@ def test_all_unmatched(self, tmp_path: Path) -> None: kind = Kind(marker_a=40, box_a=200, parent='cage') with pytest.raises(ValueError, match='parent matching'): export_kind('green', kind, [_item(1, 4, 3, 2, 0.0)], {'T': TOMO}, tmp_path, parents={}) + + # test_csv: one row per kept pick with pick and fit centres, SNR and flags + def test_csv(self, tmp_path: Path) -> None: + s = export_csv('yellow', [_item(1, 4, 3, 2, 1.5), _item(2, 1, 1, 1, 2.0, flag='noparent')], tmp_path) + rows = list(csv.DictReader(open(tmp_path / 'csv' / 'yellow.csv'))) + assert s.written == 2 and s.path == tmp_path / 'csv' / 'yellow.csv' + assert [r['snr'] for r in rows] == ['1.500', '2.000'] and rows[1]['flag'] == 'noparent' + assert rows[0]['pick_x'] == '4.00' and rows[0]['fit_z'] == '2.00' and rows[0]['tomogram'] == 'T' + + # test_csv_filters: the SNR minimum and include_flagged drop rows, and a kind left empty is an error + def test_csv_filters(self, tmp_path: Path) -> None: + items = [_item(1, 4, 3, 2, 0.5), _item(2, 1, 1, 1, 2.0, flag='noparent'), _item(3, 2, 2, 2, 3.0)] + s = export_csv('yellow', items, tmp_path, min_snr=1.0, include_flagged=False) + assert (s.written, s.below_snr, s.excluded_flagged) == (1, 1, 1) + with pytest.raises(ValueError, match='no picks left'): + export_csv('yellow', items, tmp_path, min_snr=100.0) diff --git a/tests/test_items.py b/tests/test_items.py new file mode 100644 index 0000000..9eb4346 --- /dev/null +++ b/tests/test_items.py @@ -0,0 +1,56 @@ +''' +pickmix: tests for picks paired with their fits +''' + +# Import external dependencies +import pytest +from collections.abc import Callable, Iterator +from pathlib import Path +from pytestqt.qtbot import QtBot + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimates import EstimateService +from pickmix.items import flagged, items, matched +from pickmix.loader import Loader +from pickmix.model import Session +from pickmix.project import open_project + +# NAME: the single tomogram of the fixture project +NAME: str = 'Position_1_2' + +# parts: a session over a cage kind and a green kind whose parent is the cage, with its service, shut down afterwards +@pytest.fixture +def parts(make_config: Callable[..., Path]) -> Iterator[tuple[Session, EstimateService]]: + project = open_project(make_config()) + project.update(kinds={'cage': Kind(marker_a=600, box_a=700), 'green': Kind(marker_a=40, box_a=200, parent='cage')}) + session, loader = Session(project), Loader(project) + service = EstimateService(session, loader) + yield session, service + service.shutdown() + loader.shutdown() + +# TestItems: pick and fit helpers +class TestItems: + # test_no_parent_kind: a kind without a parent matches nothing + def test_no_parent_kind(self, parts: tuple[Session, EstimateService]) -> None: + session, service = parts + assert matched(session, service, 'cage') == {} + + # test_flagged_adds_noparent: a pick with no parent in reach gets noparent, others do not + def test_flagged_adds_noparent(self, qtbot: QtBot, parts: tuple[Session, EstimateService]) -> None: + session, service = parts + session.store.add(NAME, 'cage', 10.0, 10.0, 10.0) + near = session.store.add(NAME, 'green', 10.0 + 56.0, 10.0, 10.0) + far = session.store.add(NAME, 'green', 10.0 + 20.0, 10.0, 10.0) + qtbot.waitUntil(lambda: all(service.store.state(p) == 'ok' for p in session.store.all()), timeout=5000) + got = {p.id: f.flag for p, f in items(session, service, 'green')} + assert 'noparent' not in got[near.id] and 'noparent' in got[far.id] + assert flagged(session, 'green', far, service.store.get(far), matched(session, service, 'green')).flag.endswith('noparent') + + # test_items_skip_missing_fits: a pick with no current fit is left out + def test_items_skip_missing_fits(self, parts: tuple[Session, EstimateService]) -> None: + session, service = parts + service.shutdown() + session.store.add(NAME, 'cage', 10.0, 10.0, 10.0) + assert items(session, service, 'cage') == [] diff --git a/tests/test_model.py b/tests/test_model.py index b86b549..b0d38a1 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -128,3 +128,12 @@ def test_remove_kind(self, make_config: Callable[..., Path]) -> None: store.add('Position_1_2', 'other', 3.0, 1.0, 1.0) assert store.remove_kind('cage') == 1 and store.remove_kind('cage') == 0 assert [p.kind for p in PickStore(store.project).all()] == ['other'] + + # test_find: a number or name finds a tomogram's index without moving to it + def test_find(self, make_config) -> None: + from pickmix.model import Session + from pickmix.project import open_project + session = Session(open_project(make_config(names=('Position_1_2', 'Position_1_3', 'Other_9')))) + assert session.find('2') == 1 and session.find('other') == 2 and session.find('Position_1_3') == 1 + assert session.find('position') == 0 and session.find('zzz') is None and session.find('') is None and session.find('9') is None + assert session.index == 0 and session.jump('3') and session.index == 2 diff --git a/tests/test_project.py b/tests/test_project.py index adcef79..f465b33 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -45,3 +45,18 @@ def test_update(self, make_config: Callable[..., Path]) -> None: project.update(source='raw') assert project.config.source == 'raw' assert load_config(path).source == 'raw' + + # test_display_name: the configured name wins, else the config directory's name + def test_display_name(self, make_config: Callable[..., Path]) -> None: + path = make_config() + project = open_project(path) + assert project.display_name == path.parent.name + project.update(name='Cages') + assert project.display_name == 'Cages' and load_config(path).name == 'Cages' + + # test_create_with_name: a name given at creation is saved + def test_create_with_name(self, tmp_path: Path, relion_project: Callable[[Path, list[str]], None]) -> None: + relion_project(tmp_path / 'p', ['A']) + (tmp_path / 'raw').mkdir() + path, _ = create_project(tmp_path / 'proj', [('r1', tmp_path / 'p')], 'Tomograms/job006', tmp_path / 'raw', name='Cages') + assert load_config(path).name == 'Cages' diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..15f81c3 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,15 @@ +''' +pickmix: tests for the package version +''' + +# Import external dependencies +from importlib.metadata import version + +# Import internal pickmix objects +from pickmix import __version__ + +# TestVersion: version string +class TestVersion: + # test_matches_install: the version is the installed package's + def test_matches_install(self) -> None: + assert __version__ == version('pickmix') diff --git a/uv.lock b/uv.lock index 1332835..9b71180 100644 --- a/uv.lock +++ b/uv.lock @@ -137,14 +137,14 @@ wheels = [ [[package]] name = "pickmix" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "imageio" }, { name = "mrcfile" }, { name = "numpy" }, { name = "pydantic" }, - { name = "pyside6" }, + { name = "pyside6-essentials" }, { name = "scikit-image" }, { name = "scipy" }, { name = "tomli-w" }, @@ -162,7 +162,7 @@ requires-dist = [ { name = "mrcfile", specifier = ">=1.5.4" }, { name = "numpy", specifier = ">=2.5.4" }, { name = "pydantic", specifier = ">=2.14.2" }, - { name = "pyside6", specifier = ">6.9" }, + { name = "pyside6-essentials", specifier = ">6.9" }, { name = "scikit-image", specifier = ">=0.26.0" }, { name = "scipy", specifier = ">=1.18.1" }, { name = "tomli-w", specifier = ">=1.2.0" }, @@ -329,39 +329,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] -[[package]] -name = "pyside6" -version = "6.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-addons" }, - { name = "pyside6-essentials" }, - { name = "pyside6-pdf" }, - { name = "pyside6-webengine" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/db/0722bbb81c3515dd0847fd66a7fb4760b13ad59c89e40b27b362ab5624e2/pyside6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:efea4c2f2aa54403c45de825865a902a1960a1cc9d5aaa92d4ebb8f4c4cd0708", size = 577475, upload-time = "2026-10-08T09:11:15.222Z" }, - { url = "https://files.pythonhosted.org/packages/2f/53/db0cabed1e70d0bc4666b8cbc2611b72bff2f9d2c4813820182621a8c6ee/pyside6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d13ed4d0053cc20f3e4e0fdbf12790a9b7f958d7e22c0843411ecfa8c799e2a9", size = 577709, upload-time = "2026-10-08T09:11:17.345Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8c/dfd4f339285d2e6c31067789b9b7dd2cee26c098ecc78ac7200fb04aa344/pyside6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8920e163f60493e18822d20f1e01c7c4bcf9c1c5cfbc68284788896f692594f8", size = 577708, upload-time = "2026-10-08T09:11:18.705Z" }, - { url = "https://files.pythonhosted.org/packages/a1/da/8e8514fc29ea916c3cd71a524480b77409e19ed12d709e8a5d73be089b81/pyside6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:31a8d2893f2f28ab483b1534dde0438f27af6e234593de30425cb4729052d8f8", size = 578958, upload-time = "2026-10-08T09:11:20.179Z" }, - { url = "https://files.pythonhosted.org/packages/5f/2e/3ebafaf7f4ccea6f6ea9d017f7fc7d9484b79e6b31d1cb92f5f5262055f0/pyside6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:22e2ec6407f32b61955a28f91ab55676e60017210285c28f0c381bfeed389414", size = 562743, upload-time = "2026-10-08T09:11:21.552Z" }, -] - -[[package]] -name = "pyside6-addons" -version = "6.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-essentials" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/16/c401d6455dbf49df37a8b142883e12d446abf51919c3e0ef5d9570e29160/pyside6_addons-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:1d9bcd294ce1c9829194c7d13b01f771a2254b86cb3462cc3399321d2797fdac", size = 90545550, upload-time = "2026-10-08T09:08:14.778Z" }, - { url = "https://files.pythonhosted.org/packages/ff/3e/0d6c5934de575286ac0699a701d4155048185035b8da0ee78d57a38bc7ef/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4ab38c0017f0453671a9313d3a53ca1c0e378924f54b07e596bcea94a9642391", size = 59192702, upload-time = "2026-10-08T09:08:21.13Z" }, - { url = "https://files.pythonhosted.org/packages/94/14/b6c5683614601448c3ea8f19613ca378db75158ba594b662a8a8041e3e5f/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:48cd0b639c06c9f79685f340ccae4319614b66d6efcdf06c2ce3836790d8c46e", size = 63646547, upload-time = "2026-10-08T09:08:26.921Z" }, - { url = "https://files.pythonhosted.org/packages/02/41/ba3d52fe2acd942ebf71eb02b3bdf0897f7f6f4ce8f52289cb50228e48d3/pyside6_addons-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:98d8d62010da6605344cee83e5c96fb846170a1f2d924523f10f5d92078b02b0", size = 40522402, upload-time = "2026-10-08T09:08:31.678Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/860ab764368cd59b1a09cb891a9a4519425395ad1f6c47d83fde908fe603/pyside6_addons-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:3c079cbaad2ad28bc5bfa92fd6bac4ba28d622b825825e3b4220f0682599b2db", size = 35940682, upload-time = "2026-10-08T09:08:35.424Z" }, -] - [[package]] name = "pyside6-essentials" version = "6.12.0" @@ -377,36 +344,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" }, ] -[[package]] -name = "pyside6-pdf" -version = "6.12.0.140" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-essentials" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/33/23949c3b48af4b96b618e0502b7313ac52a782615db8dceae5bc2c516831/pyside6_pdf-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:74287989dd24a82ac1604e27a2d4530a0ab71ff03cdf878c16fdbcf5144373fb", size = 8644803, upload-time = "2026-10-08T09:07:36.716Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f0/9b4f3cb0506641ea53210c7b635703f1c2c1e81c27074438105ff118f8e7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0c94370b6b9d65fbfe54f9d6d25df0677db0cdcfd0a842a63958a3603a42007c", size = 2843877, upload-time = "2026-10-08T09:07:39.208Z" }, - { url = "https://files.pythonhosted.org/packages/39/cb/4a8cf3edb3f1b2396935573c02255be75f5605dcc77e9e3a201a6d6f26d7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2b63ee1f8ce3d3b2b648be2ebda483dd0fbca8d44c92a6c6f924033ac7fbde8c", size = 2837165, upload-time = "2026-10-08T09:07:40.756Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8a/3f5eab9eb9e6486f0334b100322bbd0aff432dd90ad7977a3910979a163f/pyside6_pdf-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:4971ba1a0ca15e7c29cfd808e9600aeb46b2305dad01456eb09fa3197166268e", size = 2983516, upload-time = "2026-10-08T09:07:42.312Z" }, - { url = "https://files.pythonhosted.org/packages/b2/87/5c29bdaf47bf0b0cee7f467e86ffeb07be5bf8aa2afd2ff6f0785d0515b4/pyside6_pdf-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:ce6e4d858df1eb4e2adcbd8ba212f3611ce60f9978881fe10d417f36013a6ff9", size = 2758117, upload-time = "2026-10-08T09:07:43.936Z" }, -] - -[[package]] -name = "pyside6-webengine" -version = "6.12.0.140" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-addons" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/37/3b50067bdcba5c22e20a8d7a029c919850c6d94f8398175b330c3b8765ad/pyside6_webengine-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:9a1de2da360d1817c08e9400a2a2fc5234beb3022a22123e15355169e183074e", size = 249778242, upload-time = "2026-10-08T09:06:03.602Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e0/cb8b68b962afc253709ed61555d0a4f297fd6786c0e77921d2e951504210/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c041c9921d7c91c9e51a2c027f434d05c3c59baa2c8f17b71dc8194e77791f06", size = 116741921, upload-time = "2026-10-08T09:06:13.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/34/75944ed126293868fcbcc589fa2254e3310016920e5d32d21c29be021524/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:66a842ccb7f06fa7e357f6d4d63bc2739e3cbc1f93583f314e38c43024fe5f74", size = 114053128, upload-time = "2026-10-08T09:06:21.275Z" }, - { url = "https://files.pythonhosted.org/packages/21/0d/260c7c286f2d4e8a49c099152a385414a872f022117f937824ca0a3f38e5/pyside6_webengine-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:f9b61b1d457a591ee590a6b80515f25a3e61e2576a2a0a70566380f9d9de4ab0", size = 131697708, upload-time = "2026-10-08T09:06:29.606Z" }, - { url = "https://files.pythonhosted.org/packages/76/34/7a2d1d7b95c96adf173c77d1862c2587789f7afb8754430fcfc2c9e4e47a/pyside6_webengine-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:44d1fac2a057a71b8f947fc5177a95fab8a0ea4e2fda068f223a00fd0193a8c7", size = 65723, upload-time = "2026-10-08T09:06:32.559Z" }, -] - [[package]] name = "pytest" version = "9.2.0"