diff --git a/.github/workflows/check-version-increment.yaml b/.github/workflows/check-version-increment.yaml new file mode 100644 index 0000000..017b4b9 --- /dev/null +++ b/.github/workflows/check-version-increment.yaml @@ -0,0 +1,66 @@ +name: Check Version Number Increments + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + version-check: + name: Check version bump + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@v7 + + - name: Read PR version + id: pr_version + run: | + VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Read main version + id: main_version + run: | + git fetch origin main --depth=1 + VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version *= *' | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from main's pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + + - name: Compare versions + run: | + PR_VERSION="${{ steps.pr_version.outputs.version }}" + MAIN_VERSION="${{ steps.main_version.outputs.version }}" + echo "PR version: $PR_VERSION" + echo "main version: $MAIN_VERSION" + uv run --with packaging python3 - "$PR_VERSION" "$MAIN_VERSION" <<'EOF' + import sys + from packaging.version import Version, InvalidVersion + + pr_raw, main_raw = sys.argv[1], sys.argv[2] + + try: + pr = Version(pr_raw) + main = Version(main_raw) + except InvalidVersion as e: + print(f"Could not parse version as PEP 440: {e}", file=sys.stderr) + sys.exit(1) + + if pr <= main: + print(f'Version in pyproject.toml ({pr_raw}) is not greater than main ({main_raw}). Versions must strictly increase.', file=sys.stderr) + sys.exit(1) + + print(f'OK: {pr_raw} > {main_raw}') + EOF diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml new file mode 100644 index 0000000..4ac2518 --- /dev/null +++ b/.github/workflows/publish-release.yaml @@ -0,0 +1,42 @@ +name: Publish New Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + name: Build and publish release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + + - name: Read version + id: version + run: | + VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build package + run: uv build + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + generate_release_notes: true + draft: false + files: | + dist/*.whl + dist/*.tar.gz diff --git a/README.md b/README.md index 48f0d1d..941be95 100644 --- a/README.md +++ b/README.md @@ -1 +1,73 @@ -# pickmix \ No newline at end of file +# pickmix + +Multi-kind particle picking GUI for cryo-ET sub-tomogram-averaging workflows. + +Pickmix supports picking multiple kinds of user-defined particles from reconstructed tomograms, with built-in SNR-based refinement estimation and a 2D average validation step, for export as a RELION 5-compatible coordinate `.star` file. + +## Installation +### Requirements +- Python (≥3.14) +- [`uv`](https://docs.astral.sh/uv/) package manager + +### Installation command + +```sh +uv tool install git+https://github.com/holsam/pickmix +pickmix [path/to/config.toml] +``` +For development, use `uv sync` then `uv run pickmix`. + +## Inputs + +- One or more RELION projects. Each project must have a tomogram job (default `Tomograms/job006`) which contains a `tomograms.star`. +- Reconstructed tomograms. All raw tomograms should be located (symbolic linking is supported) within a single directory as MRC files. The header shape and pixel size of each MRC file must agree with `tomograms.star`, otherwise the tomogram will be rejected. +- [OPTIONAL] Denoised tomograms. All denoised tomograms should be located (symbolic linking is supported) within a single directory as MRC files. Denoised tomograms can contain an additional specified suffix (e.g. `.denoised`) that will be included when matching to raw tomograms. + +## How to use `pickmix` +### 1. Create a project + +Launch pickmix using `pickmix`, which will open on the Project tab. + +![Project tab](docs/images/empty-project-tab.png) + +Click `[New project]`, and complete the fields in the dialog. Pickmix merges multiple runs into a single `tomograms.star` and writes `config.toml` in the project directory. Tomogram names get the run name as a prefix. Existing files are never overwritten. + +![New project dialog](docs/images/new-project.png) + +Once created, the Project tab lists usable and rejected tomograms and the shared settings. + +![Project tab](docs/images/project-tab.png) + +### 2. Define kinds + +Open the Kinds tab, and add additional 'kinds' (types of pick) as required. Each kind has a marker size, an extraction box, an estimator (`ring` fits a circle to a pick, `none` passes the click straight through), a colour, an optional parent kind and an extraction pixel size. The table order sets the `1` to `9` hotkeys. A kind with a parent takes its membrane-normal orientation from the nearest parent estimate. + +![Kinds tab](docs/images/kinds-tab.png) + +### 3. Pick + +Open the Pick tab and select a kind with `1` to `9`. Left-click adds or selects a pick; picks can be moved by selecting & dragging, or deleted using `Delete`. Right-click to pan across the current slice, and Ctrl/Cmd+Scroll to adjust zoom. Scrolling updates the current Z slice, and `Enter`/`Shift+Enter` steps forward/backward between tomograms. Fitted rings, boxes and normals can be toggled on/off as desired, and contrast sliders adjust the display. + +![Pick tab](docs/images/pick-tab.png) + +### 4. Average + +Return to the Kinds tab, and select a kind. Click `[Run averaging]` to extract a box around each pick and create a mean projection & montage. + +![Average pane](docs/images/average-pane.png) + +### 5. Review and export + +Open the Review tab. Picks can be filtered by SNR and any flags, delete flagged picks, re-estimate, then **Export star files** to write `out/relion//particles.star` plus one `NAME_coords.star` per tomogram. **Extract boxes** cuts the sub-volumes as MRC files. Kinds with a parent also write the subtomogram angles derived from it. + +![Review tab](docs/images/review-tab.png) + +## Project layout + +``` +project/ + config.toml settings and kinds + tomograms.star merged RELION tomogram table + tilt_series/ one star file per tomogram + out/ picks, estimates, averages, RELION export +``` diff --git a/docs/images/average-pane.png b/docs/images/average-pane.png new file mode 100644 index 0000000..b7906d0 Binary files /dev/null and b/docs/images/average-pane.png differ diff --git a/docs/images/empty-project-tab.png b/docs/images/empty-project-tab.png new file mode 100644 index 0000000..304f287 Binary files /dev/null and b/docs/images/empty-project-tab.png differ diff --git a/docs/images/kinds-tab.png b/docs/images/kinds-tab.png new file mode 100644 index 0000000..82e2935 Binary files /dev/null and b/docs/images/kinds-tab.png differ diff --git a/docs/images/new-project.png b/docs/images/new-project.png new file mode 100644 index 0000000..98a4a7a Binary files /dev/null and b/docs/images/new-project.png differ diff --git a/docs/images/pick-tab.png b/docs/images/pick-tab.png new file mode 100644 index 0000000..771a1e6 Binary files /dev/null and b/docs/images/pick-tab.png differ diff --git a/docs/images/project-tab.png b/docs/images/project-tab.png new file mode 100644 index 0000000..0192747 Binary files /dev/null and b/docs/images/project-tab.png differ diff --git a/docs/images/review-tab.png b/docs/images/review-tab.png new file mode 100644 index 0000000..604beee Binary files /dev/null and b/docs/images/review-tab.png differ diff --git a/pyproject.toml b/pyproject.toml index 8666d3b..c5eb67d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,22 @@ [project] name = "pickmix" -version = "0.0.0" -description = "Add your description here" +version = "0.1.0" +description = "Multi-kind particle picking GUI for cryo-ET sub-tomogram averaging workflows." readme = "README.md" authors = [ { name = "Sam Holland", email = "hol_sam@icloud.com" } ] requires-python = ">=3.14" -dependencies = [] +dependencies = [ + "imageio>=2.38.1", + "mrcfile>=1.5.4", + "numpy>=2.5.4", + "pydantic>=2.14.2", + "pyside6>6.9", + "scikit-image>=0.26.0", + "scipy>=1.18.1", + "tomli-w>=1.2.0", +] [project.scripts] pickmix = "pickmix:main" @@ -15,3 +24,9 @@ pickmix = "pickmix:main" [build-system] requires = ["uv_build>=0.13.6,<0.14.0"] build-backend = "uv_build" + +[dependency-groups] +dev = [ + "pytest>=9.2.0", + "pytest-qt>=4.5.0", +] diff --git a/src/pickmix/__init__.py b/src/pickmix/__init__.py index e69de29..638ae35 100644 --- a/src/pickmix/__init__.py +++ b/src/pickmix/__init__.py @@ -0,0 +1,9 @@ +''' +pickmix: entrypoint +''' + +# main: launch the window, opening the config.toml given as the first argument +def main() -> None: + import sys + from pickmix.gui.app import run + raise SystemExit(run(sys.argv[1:])) diff --git a/src/pickmix/average.py b/src/pickmix/average.py new file mode 100644 index 0000000..4ce33d7 --- /dev/null +++ b/src/pickmix/average.py @@ -0,0 +1,76 @@ +''' +pickmix: mean projection and montage of a kind's boxes +''' + +# Import external dependencies +import math, imageio.v3 as iio, numpy as np +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from skimage.util import montage + +# Import internal pickmix objects +from pickmix.config import Config, Kind +from pickmix.export import Item +from pickmix.extract import box_pixels, crop_box, pixel_size_of +from pickmix.utils.io import Tomogram, load_volume + +# _MONTAGE_MAX: most boxes shown in the montage, spread evenly over the picks +_MONTAGE_MAX: int = 64 + +# AverageResult: normalised 2D images, ready to show; overall and per-tomogram means, and the montage +@dataclass(frozen=True) +class AverageResult: + kind: str + count: int + box_px: int + overall: np.ndarray + montage: np.ndarray + per_tomogram: dict[str, np.ndarray] + folder: Path + +# average_kind: cut a box at every item, project the central slab of each along z, normalise it, and average per tomogram and overall; PNGs go to out_dir/average// +def average_kind( + name: str, kind: Kind, items: list[Item], tomos: dict[str, Tomogram], config: Config, + *, log: Callable[[str], None] = lambda _: None, +) -> AverageResult: + if not items: + raise ValueError(f'kind {name!r}: nothing to average') + px = pixel_size_of(name, items, tomos) + box = box_pixels(kind, items, px) + # slab as thick as the particle, so the projection is not washed out by empty box + half = min(max(round(kind.marker_a / 2 / px), 1), box // 2) + centre = box // 2 + images: dict[str, list[np.ndarray]] = {} + for tomogram in dict.fromkeys(p.tomogram for p, _ in items): + vol = load_volume(tomos[tomogram], source=config.source) + for p, f in items: + if p.tomogram != tomogram: + continue + cube = crop_box(vol, (round(f.z), round(f.y), round(f.x)), box) + img = cube[centre - half : centre + half + 1].mean(axis=0) + images.setdefault(tomogram, []).append((img - img.mean()) / (img.std() or 1.0)) + log(f'{name} {tomogram}: {len(images[tomogram])} boxes') + everything = [img for imgs in images.values() for img in imgs] + chosen = np.linspace(0, len(everything) - 1, min(len(everything), _MONTAGE_MAX)).astype(int) + grid = math.ceil(math.sqrt(len(chosen))) + result = AverageResult( + name, len(everything), box, + np.mean(everything, axis=0), + montage(np.stack([everything[i] for i in chosen]), grid_shape=(grid, grid), padding_width=2), + {t: np.mean(imgs, axis=0) for t, imgs in images.items()}, + config.out_dir / 'average' / name, + ) + result.folder.mkdir(parents=True, exist_ok=True) + for stale in result.folder.glob('*.png'): + stale.unlink() + _save(result.folder / 'overall_mean.png', result.overall) + _save(result.folder / 'overall_montage.png', result.montage) + for tomogram, img in result.per_tomogram.items(): + _save(result.folder / f'{tomogram}_mean.png', img) + return result + +# _save: write an image stretched between its 1st and 99th percentiles as an 8-bit PNG +def _save(path: Path, arr: np.ndarray) -> None: + lo, hi = np.percentile(arr, (1, 99)) + iio.imwrite(path, np.clip((arr - lo) * (255.0 / max(hi - lo, 1e-6)), 0, 255).astype(np.uint8)) diff --git a/src/pickmix/config.py b/src/pickmix/config.py new file mode 100644 index 0000000..b1ad4dc --- /dev/null +++ b/src/pickmix/config.py @@ -0,0 +1,119 @@ +''' +pickmix: project configuration schema, kinds table and TOML load/save +''' + +# Import external dependencies +import re, tomllib, tomli_w +from pathlib import Path +from pydantic import BaseModel, Field, field_validator, model_validator +from typing import Literal + +# Kind: one class of particle +class Kind(BaseModel): + # displayed marker diameter, Angstrom + marker_a: float = Field(gt=0) + # extraction box edge, Angstrom; 0 takes the box from the estimator + box_a: float = Field(default=0.0, ge=0) + # name in the estimator registry + estimator: str = 'none' + # any Qt colour name or #rrggbb + colour: str = 'yellow' + # kind whose nearest estimate supplies membrane-normal geometry + parent: str | None = None + # how far a pick may sit from the parent's surface and still count as on it + parent_tolerance_a: float = Field(default=100.0, gt=0) + # written as rlnAngleTiltPrior when set + tilt_prior_deg: float | None = None + # pixel size of the RELION extraction in Angstrom per pixel + extract_angpix: float = Field(default=10.68, gt=0) + +# RingParams: settings of the ring estimator, all lengths in Angstrom +class RingParams(BaseModel): + # cage radius sweep and ring width + radius_range_a: tuple[float, float] = (150.0, 500.0) + radius_step_a: float = 25.0 + ring_sigma_a: float = 30.0 + # background flattening and light smoothing of each projection + highpass_sigma_a: float = 500.0 + smooth_sigma_a: float = 8.0 + # ring half-width and the background annulus as multiples of the ring radius + ring_halfwidth_a: float = 40.0 + bg_range: tuple[float, float] = (1.3, 1.8) + # z slab grown up to walk_max_half_a, stopping after walk_patience steps without a better SNR + walk_max_half_a: float = 400.0 + walk_patience: int = 3 + # how far the centre may move from the pick in xy, and the slab centre in z + locate_range_a: float = 200.0 + z_shift_a: float = 100.0 + # box edge as a multiple of the ring diameter when the kind takes its box from the estimator + box_margin: float = 1.25 + +# _default_kinds: the single cage kind used when a config has no kinds table +def _default_kinds() -> dict[str, Kind]: + return {'cage': Kind(marker_a=600.0, estimator='ring')} + +# Config: validated project settings +class Config(BaseModel): + # tomogram locations; denoised_dir None means raw only + raw_dir: Path + denoised_dir: Path | None = None + # stripped from denoised stems so they pair with raw stems + denoised_suffix: str = '' + # RELION tomograms star: authoritative for names, unbinned pixel size, binning and tomogram size + tomograms_star: Path + # every pickmix output lands here + out_dir: Path = Path('out') + # volume read for display, estimation and extraction; falls back to raw when a tomogram has no denoised pair + source: Literal['raw', 'denoised'] = 'denoised' + ring: RingParams = Field(default_factory=RingParams) + # table order sets the 1 to 9 hotkeys + kinds: dict[str, Kind] = Field(default_factory=_default_kinds) + + # replace: validated copy with some fields swapped + def replace(self, **changes: object) -> 'Config': + return Config.model_validate({**self.model_dump(), **changes}) + + # _names: kind names become folder names, so keep them plain + @field_validator('kinds') + @classmethod + def _names(cls, kinds: dict[str, Kind]) -> dict[str, Kind]: + if not kinds: + raise ValueError('at least one kind is needed') + for name in kinds: + if not re.fullmatch(r'[A-Za-z0-9_]+', name): + raise ValueError(f'kind name {name!r} may only use letters, digits and underscores') + return kinds + + # _relations: parents must exist and form no cycle, and a passthrough kind needs a box + @model_validator(mode='after') + def _relations(self) -> 'Config': + for name, kind in self.kinds.items(): + if kind.estimator == 'none' and kind.box_a == 0: + raise ValueError(f'kind {name!r}: estimator "none" needs box_a above 0') + seen, parent = {name}, kind.parent + while parent is not None: + if parent not in self.kinds: + raise ValueError(f'kind {name!r}: unknown parent {parent!r}') + if parent in seen: + raise ValueError(f'kind {name!r}: parent chain loops through {parent!r}') + seen.add(parent) + parent = self.kinds[parent].parent + return self + +# load_config: read a TOML file into a Config instance, resolving relative paths +def load_config(path: Path) -> Config: + with open(path, 'rb') as handle: + config = Config(**tomllib.load(handle)) + base = path.resolve().parent + fixed = { + field: base / value + for field in ('raw_dir', 'denoised_dir', 'tomograms_star', 'out_dir') + if (value := getattr(config, field)) is not None and not value.is_absolute() + } + return config.model_copy(update=fixed) + +# save_config: write a Config instance as TOML (leaving None fields empty) +def save_config(config: Config, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'wb') as handle: + tomli_w.dump(config.model_dump(mode='json', exclude_none=True), handle) diff --git a/src/pickmix/estimate/none.py b/src/pickmix/estimate/none.py new file mode 100644 index 0000000..22ae737 --- /dev/null +++ b/src/pickmix/estimate/none.py @@ -0,0 +1,16 @@ +''' +pickmix: passthrough estimator +''' + +# Import external dependencies +import numpy as np + +# Import internal pickmix objects +from pickmix.config import Config, Kind +from pickmix.estimate.registry import Fit, register + +# passthrough: the pick as its own centre, size and box from the kind, no score +@register('none') +def passthrough(vol: np.ndarray, xyz: tuple[float, float, float], kind: Kind, pixel_size_a: float, config: Config) -> Fit: + x, y, z = xyz + return Fit(x=x, y=y, z=z, radius_a=kind.marker_a / 2, box_a=kind.box_a, snr=0.0, z_lo=round(z), z_hi=round(z)) diff --git a/src/pickmix/estimate/registry.py b/src/pickmix/estimate/registry.py new file mode 100644 index 0000000..2df6db1 --- /dev/null +++ b/src/pickmix/estimate/registry.py @@ -0,0 +1,42 @@ +''' +pickmix: estimator registry and the common fit result +''' + +# Import external dependencies +import numpy as np +from collections.abc import Callable +from pydantic import BaseModel + +# Import internal pickmix objects +from pickmix.config import Config, Kind + +# Fit: what an estimator reports for one pick; x, y, z in reconstruction voxels, radius and box in Angstrom +class Fit(BaseModel): + x: float + y: float + z: float + radius_a: float + box_a: float + snr: float + # slab the fit used, as inclusive slice indices + z_lo: int + z_hi: int + # comma-separated warnings, empty when fine + flag: str = '' + +# Estimator: volume (z, y, x), pick (x, y, z) in voxels, kind, pixel size in Angstrom, config +type Estimator = Callable[[np.ndarray, tuple[float, float, float], Kind, float, Config], Fit] + +# ESTIMATORS: registered estimators by name +ESTIMATORS: dict[str, Estimator] = {} + +# register: decorator adding an estimator to the registry +def register(name: str) -> Callable[[Estimator], Estimator]: + # add: store fn under name + def add(fn: Estimator) -> Estimator: + ESTIMATORS[name] = fn + return fn + return add + +# imported last so the module can use the names above +from pickmix.estimate import none as _none, ring as _ring # noqa: E402,F401 diff --git a/src/pickmix/estimate/ring.py b/src/pickmix/estimate/ring.py new file mode 100644 index 0000000..9766144 --- /dev/null +++ b/src/pickmix/estimate/ring.py @@ -0,0 +1,159 @@ +''' +pickmix: ring estimator, centre, radius and box of a pick from a z slab grown until ring SNR stops improving +''' + +# Import external dependencies +import numpy as np +from dataclasses import dataclass +from scipy import ndimage +from skimage.feature import match_template + +# Import internal pickmix objects +from pickmix.config import Config, Kind, RingParams +from pickmix.estimate.registry import Fit, register + +# Step: ring fit for one slab half-thickness k; y, x are crop coordinates, radius_px in voxels +@dataclass(frozen=True) +class Step: + k: int + y: float + x: float + radius_px: float + snr: float + +# ring: walk a z slab about the pick, fit a dark ring to its minimum projection, then scan the slab centre in z +@register('ring') +def ring(vol: np.ndarray, xyz: tuple[float, float, float], kind: Kind, pixel_size_a: float, config: Config) -> Fit: + params, px = config.ring, pixel_size_a + x0, y0, z0 = (round(v) for v in xyz) + half = int(np.ceil((params.bg_range[1] * params.radius_range_a[1] + params.locate_range_a) / px)) + kmax = round(params.walk_max_half_a / px) + shift = round(params.z_shift_a / px) + sub, valid, zlo = _crop(vol, z0 - kmax - shift, z0 + kmax + shift, y0, x0, half) + zc = z0 - zlo + best = _walk_z(sub, valid, zc, params, px) + dz, snr = _scan_z(sub, valid, zc, best, shift, params, px) + # crop centre is index half, so crop coordinates map back by (value - half) + ex, ey, ez = x0 + (best.x - half), y0 + (best.y - half), z0 + dz + radius_a = best.radius_px * px + flags = [] + if min(ex, ey) - best.radius_px < 0 or ex + best.radius_px > vol.shape[2] - 1 or ey + best.radius_px > vol.shape[1] - 1: + flags.append('edge') + if zc + dz - best.k <= 0 or zc + dz + best.k >= sub.shape[0] - 1: + flags.append('zedge') + if best.k >= kmax: + flags.append('zmax') + return Fit( + x=ex, y=ey, z=float(ez), radius_a=radius_a, + box_a=kind.box_a if kind.box_a > 0 else 2 * radius_a * params.box_margin, + snr=snr, z_lo=ez - best.k, z_hi=ez + best.k, flag=','.join(flags), + ) + +# ring_template: dark gaussian ring of the given radius, zero elsewhere (low where the image should be low) +def ring_template(radius_px: float, sigma_px: float) -> np.ndarray: + half = int(np.ceil(radius_px + 3 * sigma_px)) + yy, xx = np.mgrid[-half : half + 1, -half : half + 1] + return (-np.exp(-0.5 * ((np.hypot(yy, xx) - radius_px) / sigma_px) ** 2)).astype(np.float32) + +# ring_ncc: best normalised cross-correlation over a radius sweep, with the radius (voxels) that gave it +def ring_ncc(img: np.ndarray, radii_px: np.ndarray, sigma_px: float) -> tuple[np.ndarray, np.ndarray]: + best = np.full(img.shape, -1.0, dtype=np.float32) + radius = np.zeros(img.shape, dtype=np.float32) + for r in radii_px: + ncc = match_template(img, ring_template(float(r), sigma_px), pad_input=True) + better = ncc > best + best[better] = ncc[better] + radius[better] = r + return best, radius + +# ring_snr: dark-ring contrast over background noise for a ring of radius_px centred at (y, x), valid voxels only +def ring_snr(img: np.ndarray, valid: np.ndarray, centre: tuple[float, float], radius_px: float, params: RingParams, pixel_size_a: float) -> float: + d = _distance(img.shape, centre) + ring_mask = valid & (np.abs(d - radius_px) <= params.ring_halfwidth_a / pixel_size_a) + bg = valid & (d >= params.bg_range[0] * radius_px) & (d <= params.bg_range[1] * radius_px) + # too little of either annulus inside the volume to say anything + if ring_mask.sum() < 10 or bg.sum() < 10: + return 0.0 + return float((img[bg].mean() - img[ring_mask].mean()) / (img[bg].std() + 1e-6)) + +# _walk_z: grow a symmetric slab about slice zc one slice at a time, returning the step with the best ring SNR +def _walk_z(sub: np.ndarray, valid: np.ndarray, zc: int, params: RingParams, px: float) -> Step: + lo_r, hi_r = params.radius_range_a + radii = np.arange(lo_r, hi_r + 1e-6, params.radius_step_a) / px + sigma = params.ring_sigma_a / px + kmax = round(params.walk_max_half_a / px) + centre = (float(sub.shape[1] // 2), float(sub.shape[2] // 2)) + slab = sub[zc].copy() + sweep = radii + best: Step | None = None + misses = 0 + for k in range(kmax + 1): + if k: + lo, hi = zc - k, zc + k + # the slab has run out of volume on both sides + if lo < 0 and hi >= sub.shape[0]: + break + for zi in (lo, hi): + if 0 <= zi < sub.shape[0]: + slab = np.minimum(slab, sub[zi]) + img = _prepare(slab, params, px) + y, x, r = _locate(img, centre, sweep, sigma, params, px) + r = _radial_radius(img, valid, (y, x), r, params, px) + step = Step(k, y, x, r, ring_snr(img, valid, (y, x), r, params, px)) + # later steps only need the radii next to the current one + centre, sweep = (y, x), radii[np.argsort(np.abs(radii - r))[:3]] + if best is None or step.snr > best.snr: + best, misses = step, 0 + else: + misses += 1 + if misses >= params.walk_patience: + break + assert best is not None + return best + +# _distance: distance of every pixel from a (y, x) centre +def _distance(shape: tuple[int, ...], centre: tuple[float, float]) -> np.ndarray: + yy, xx = np.indices(shape) + return np.hypot(yy - centre[0], xx - centre[1]) + +# _prepare: smoothed, background-flattened minimum projection +def _prepare(slab: np.ndarray, params: RingParams, px: float) -> np.ndarray: + smoothed = ndimage.gaussian_filter(slab, params.smooth_sigma_a / px) + return smoothed - ndimage.gaussian_filter(smoothed, params.highpass_sigma_a / px) + +# _locate: (y, x, radius_px) maximising ring NCC within locate_range of the previous centre +def _locate(img: np.ndarray, centre: tuple[float, float], radii: np.ndarray, sigma: float, params: RingParams, px: float) -> tuple[float, float, float]: + ncc, radius = ring_ncc(img, radii, sigma) + ncc = np.where(_distance(ncc.shape, centre) <= params.locate_range_a / px, ncc, -np.inf) + y, x = np.unravel_index(np.argmax(ncc), ncc.shape) + return float(y), float(x), float(radius[y, x]) + +# _radial_radius: radius (voxels) of the darkest annulus within one radius step of r0, from the 1-voxel radial profile +def _radial_radius(img: np.ndarray, valid: np.ndarray, centre: tuple[float, float], r0: float, params: RingParams, px: float) -> float: + idx = np.rint(_distance(img.shape, centre)).astype(int)[valid] + profile = np.bincount(idx, weights=img[valid]) / np.maximum(np.bincount(idx), 1) + reach = params.radius_step_a / px + lo, hi = max(int(r0 - reach), 1), min(int(r0 + reach) + 1, len(profile)) + return float(lo + np.argmin(profile[lo:hi])) + +# _scan_z: slab centre shift (slices) maximising ring SNR for the best half-thickness and ring, with that SNR +def _scan_z(sub: np.ndarray, valid: np.ndarray, zc: int, best: Step, shift: int, params: RingParams, px: float) -> tuple[int, float]: + scan = (0, best.snr) + for dz in range(-shift, shift + 1): + lo, hi = max(zc + dz - best.k, 0), min(zc + dz + best.k, sub.shape[0] - 1) + img = _prepare(sub[lo : hi + 1].min(axis=0), params, px) + snr = ring_snr(img, valid, (best.y, best.x), best.radius_px, params, px) + if snr > scan[1]: + scan = (dz, snr) + return scan + +# _crop: sub-volume (z range clipped) round (y, x) with edge padding, the xy mask of real voxels, and the first z index +def _crop(vol: np.ndarray, z0: int, z1: int, y: int, x: int, half: int) -> tuple[np.ndarray, np.ndarray, int]: + z0, z1 = max(z0, 0), min(z1, vol.shape[0] - 1) + ys = slice(max(y - half, 0), min(y + half + 1, vol.shape[1])) + xs = slice(max(x - half, 0), min(x + half + 1, vol.shape[2])) + block = np.asarray(vol[z0 : z1 + 1, ys, xs], dtype=np.float32) + pad_y = (ys.start - (y - half), (y + half + 1) - ys.stop) + pad_x = (xs.start - (x - half), (x + half + 1) - xs.stop) + valid = np.pad(np.ones(block.shape[1:], dtype=bool), (pad_y, pad_x)) + return np.pad(block, ((0, 0), pad_y, pad_x), mode='edge'), valid, z0 diff --git a/src/pickmix/estimates.py b/src/pickmix/estimates.py new file mode 100644 index 0000000..b903fd6 --- /dev/null +++ b/src/pickmix/estimates.py @@ -0,0 +1,163 @@ +''' +pickmix: per-pick estimates +''' + +# Import external dependencies +import csv, hashlib, json, os, threading +from collections.abc import Callable +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from typing import Literal + +# Import internal pickmix objects +from pickmix.estimate.registry import ESTIMATORS, Fit +from pickmix.loader import Loader +from pickmix.model import Pick, PickStore, Session +from pickmix.project import Project + +# _Row: one estimates.csv row, the fit plus the pick it was computed for and a digest of the settings used +class _Row(Fit): + tomogram: str + kind: str + pick_x: float + pick_y: float + pick_z: float + digest: str + +# _Key: tomogram, kind and rounded coordinates of a pick; picks have no stable id across sessions +type _Key = tuple[str, str, float, float, float] + +# EstimateStore: fits for picks, valid while the pick and the settings that produced them are unchanged +class EstimateStore: + # __init__: load estimates.csv if there is one + def __init__(self, project: Project, picks: PickStore) -> None: + self.project = project + self.picks = picks + self.path = project.config.out_dir / 'estimates.csv' + # called from whichever thread stored a result + self.listeners: list[Callable[[], None]] = [] + self._lock = threading.Lock() + self._rows: dict[_Key, _Row] = {} + self._digests: tuple[object, dict[str, str]] = (None, {}) + if self.path.exists(): + with open(self.path, newline='') as handle: + for row in map(_Row.model_validate, csv.DictReader(handle)): + self._rows[_key(row.tomogram, row.kind, row.pick_x, row.pick_y, row.pick_z)] = row + + # digest: fingerprint of everything that changes a kind's estimates, None for a kind not in the config + def digest(self, kind: str) -> str | None: + config = self.project.config + if self._digests[0] is not config: + self._digests = (config, {}) + cache = self._digests[1] + if kind not in cache and kind in config.kinds: + blob = json.dumps({'kind': config.kinds[kind].model_dump(), 'ring': config.ring.model_dump(), 'source': config.source}, sort_keys=True) + cache[kind] = hashlib.sha1(blob.encode()).hexdigest()[:12] + return cache.get(kind) + + # state: ok when a fit matches the pick and settings, stale when only the settings changed, else missing + def state(self, pick: Pick) -> Literal['ok', 'stale', 'missing']: + row = self._rows.get(_key(pick.tomogram, pick.kind, pick.x, pick.y, pick.z)) + if row is None or self.digest(pick.kind) is None: + return 'missing' + return 'ok' if row.digest == self.digest(pick.kind) else 'stale' + + # get: the fit for a pick if it is current + def get(self, pick: Pick) -> Fit | None: + return self._rows.get(_key(pick.tomogram, pick.kind, pick.x, pick.y, pick.z)) if self.state(pick) == 'ok' else None + + # put: store a fit for the pick as it was when asked, save, and tell listeners + def put(self, pick: Pick, fit: Fit, digest: str) -> None: + row = _Row(**fit.model_dump(), tomogram=pick.tomogram, kind=pick.kind, pick_x=pick.x, pick_y=pick.y, pick_z=pick.z, digest=digest) + with self._lock: + self._rows[_key(pick.tomogram, pick.kind, pick.x, pick.y, pick.z)] = row + self._save() + for listener in self.listeners: + listener() + + # _save: write the CSV atomically, keeping only rows that belong to a pick now + def _save(self) -> None: + live = {_key(p.tomogram, p.kind, p.x, p.y, p.z) for p in self.picks.all()} + self._rows = {k: r for k, r in self._rows.items() if k in live} + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix('.csv.tmp') + with open(tmp, 'w', newline='') as handle: + writer = csv.DictWriter(handle, fieldnames=list(_Row.model_fields)) + writer.writeheader() + writer.writerows(r.model_dump() for r in self._rows.values()) + os.replace(tmp, self.path) + +# EstimateService: keeps estimates current for the picks of the tomogram being shown, one at a time on its own thread +class EstimateService: + # __init__: follow edits and navigation + def __init__(self, session: Session, loader: Loader) -> None: + self.session, self.loader = session, loader + self.store = EstimateStore(session.project, session.store) + self.last_error = '' + self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='pickmix-estimate') + self._lock = threading.Lock() + self._pending: dict[int, tuple[_Key, Future[None]]] = {} + self._closed = False + session.store.listeners.append(self.request_current) + session.goto_listeners.append(self.request_current) + self.request_current() + + # pending: number of estimates queued or running + @property + def pending(self) -> int: + with self._lock: + return sum(not f.done() for _, f in self._pending.values()) + + # request: queue an estimate for a pick unless it is current or already queued for the same position + def request(self, pick: Pick) -> None: + if self._closed or pick.kind not in self.session.project.config.kinds or self.store.state(pick) == 'ok': + return + key = _key(pick.tomogram, pick.kind, pick.x, pick.y, pick.z) + with self._lock: + old = self._pending.get(pick.id) + if old is not None and old[0] == key and not old[1].done(): + return + if old is not None: + old[1].cancel() + # a copy, because the pick may be moved while the estimate runs + snapshot = Pick(pick.id, pick.tomogram, pick.kind, pick.x, pick.y, pick.z) + self._pending[pick.id] = (key, self._pool.submit(self._run, snapshot)) + + # request_current: queue estimates for the picks of the tomogram being shown + def request_current(self) -> None: + for pick in self.session.store.picks(self.session.current): + self.request(pick) + + # request_all: queue estimates for every pick that needs one, the current tomogram first + def request_all(self) -> None: + picks = sorted(self.session.store.all(), key=lambda p: p.tomogram != self.session.current) + for pick in picks: + self.request(pick) + + # shutdown: stop the thread + def shutdown(self) -> None: + self._closed = True + self._pool.shutdown(wait=False, cancel_futures=True) + + # _run: estimate one pick; a failure is stored as a flagged fit so it shows and is not retried until something changes + def _run(self, pick: Pick) -> None: + config = self.session.project.config + kind = config.kinds[pick.kind] + digest = self.store.digest(pick.kind) + try: + vol = self.loader.open(pick.tomogram).result().vol + pixel_size = self.session.project.tomos[pick.tomogram].pixel_size + fit = ESTIMATORS[kind.estimator](vol, (pick.x, pick.y, pick.z), kind, pixel_size, config) + except CancelledError: + return + except Exception as e: + self.last_error = f'{pick.tomogram} {pick.kind}: {type(e).__name__}: {e}' + fit = Fit( + x=pick.x, y=pick.y, z=pick.z, radius_a=kind.marker_a / 2, box_a=kind.box_a, snr=0.0, + z_lo=round(pick.z), z_hi=round(pick.z), flag='error', + ) + if digest is not None and not self._closed: + self.store.put(pick, fit, digest) + +# _key: dictionary key for a pick's position +def _key(tomogram: str, kind: str, x: float, y: float, z: float) -> _Key: + return tomogram, kind, round(x, 3), round(y, 3), round(z, 3) diff --git a/src/pickmix/export.py b/src/pickmix/export.py new file mode 100644 index 0000000..44deab5 --- /dev/null +++ b/src/pickmix/export.py @@ -0,0 +1,82 @@ +''' +pickmix: RELION coordinate star files and box recommendation per kind +''' + +# Import external dependencies +import numpy as np +from dataclasses import dataclass +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimate.registry import Fit +from pickmix.geometry import Parent +from pickmix.model import Pick +from pickmix.utils.coords import to_centred_angstrom +from pickmix.utils.io import Tomogram +from pickmix.utils.star import write_star_loop + +# Item: a pick with its current fit +type Item = tuple[Pick, Fit] + +# KindSummary: what an export of one kind wrote +@dataclass(frozen=True) +class KindSummary: + kind: str + written: int + below_snr: int + dropped_flagged: int + unmatched: int + box_a: float + box_px: int + folder: 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: + box_a = kind.box_a + elif items: + box_a = float(np.percentile([fit.box_a for _, fit in items], percentile)) + else: + return 0.0, 0 + box_px = int(np.ceil(box_a / kind.extract_angpix)) + return box_a, box_px + box_px % 2 + +# export_kind: write out_dir/relion//particles.star and one NAME_coords.star per tomogram; a kind with a parent writes the subtomogram angles from parents and leaves out picks without one +def export_kind( + name: str, kind: Kind, items: list[Item], tomos: dict[str, Tomogram], out_dir: Path, + *, min_snr: float = 0.0, drop_flagged: bool = False, percentile: float = 95.0, parents: dict[int, Parent] | None = None, +) -> KindSummary: + kept = [(p, f) for p, f in items if f.snr >= min_snr and not (drop_flagged and f.flag)] + below = sum(f.snr < min_snr for _, f in items) + dropped = sum(bool(f.flag) for _, f in items if f.snr >= min_snr) if drop_flagged else 0 + matched = parents or {} + unmatched = 0 + if kind.parent is not None: + unmatched = sum(p.id not in matched for p, _ in kept) + kept = [(p, f) for p, f in kept if p.id in matched] + if not kept: + raise ValueError(f'kind {name!r}: no particles left after filtering and parent matching') + columns = ['rlnTomoName', 'rlnCenteredCoordinateXAngst', 'rlnCenteredCoordinateYAngst', 'rlnCenteredCoordinateZAngst', 'rlnAutopickFigureOfMerit'] + if kind.parent is not None: + columns += ['rlnTomoSubtomogramRot', 'rlnTomoSubtomogramTilt', 'rlnTomoSubtomogramPsi', 'rlnAngleRot', 'rlnAngleTilt', 'rlnAnglePsi'] + if kind.tilt_prior_deg is not None: + columns.append('rlnAngleTiltPrior') + rows = [] + for p, f in kept: + row = [p.tomogram, *(f'{v:.2f}' for v in to_centred_angstrom((f.x, f.y, f.z), tomos[p.tomogram])), f'{f.snr:.3f}'] + if kind.parent is not None: + g = matched[p.id] + # the angles RELION refines start at zero in the frame the subtomogram angles define + row += [f'{g.rot:.4f}', f'{g.tilt:.4f}', f'{g.psi:.4f}', '0.0000', '0.0000', '0.0000'] + if kind.tilt_prior_deg is not None: + row.append(f'{kind.tilt_prior_deg:.4f}') + rows.append(row) + folder = out_dir / 'relion' / name + for stale in folder.glob('*.star'): + stale.unlink() + write_star_loop(folder / 'particles.star', 'particles', columns, rows) + for tomogram in sorted({r[0] for r in rows}): + 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) diff --git a/src/pickmix/extract.py b/src/pickmix/extract.py new file mode 100644 index 0000000..dab4d37 --- /dev/null +++ b/src/pickmix/extract.py @@ -0,0 +1,66 @@ +''' +pickmix: cubic MRC boxes cut from the tomograms at the fitted centres +''' + +# Import external dependencies +import mrcfile, numpy as np +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Config, Kind +from pickmix.export import Item, recommend_box +from pickmix.utils.io import Tomogram, load_volume + +# ExtractSummary: what an extraction of one kind wrote +@dataclass(frozen=True) +class ExtractSummary: + kind: str + written: int + box_px: int + pixel_size: float + folder: Path + +# crop_box: cubic crop centred on (z, y, x) voxel indices, padded with the crop's own mean where it leaves the volume +def crop_box(vol: np.ndarray, centre: tuple[int, int, int], box: int) -> np.ndarray: + half = box // 2 + src = tuple(slice(max(c - half, 0), min(c - half + box, n)) for c, n in zip(centre, vol.shape, strict=True)) + block = np.asarray(vol[src], dtype=np.float32) + out = np.full((box, box, box), block.mean(), dtype=np.float32) + out[tuple(slice(s.start - (c - half), s.stop - (c - half)) for s, c in zip(src, centre, strict=True))] = block + return out + +# pixel_size_of: the one pixel size the items share, ValueError if they differ +def pixel_size_of(name: str, items: list[Item], tomos: dict[str, Tomogram]) -> float: + sizes = {tomos[p.tomogram].pixel_size for p, _ in items} + if len(sizes) > 1: + raise ValueError(f'kind {name!r}: one box in pixels needs one pixel size, got {sorted(sizes)!r}') + return sizes.pop() + +# box_pixels: the even box edge in reconstruction pixels for a kind, so the centre voxel sits at box // 2 +def box_pixels(kind: Kind, items: list[Item], pixel_size: float) -> int: + box_a, _ = recommend_box(kind, items) + return max(int(round(box_a / pixel_size / 2)) * 2, 2) + +# extract_kind: write out_dir/particles///_NNN.mrc at the reconstruction sampling; one even box for the whole kind +def extract_kind( + name: str, kind: Kind, items: list[Item], tomos: dict[str, Tomogram], config: Config, + *, log: Callable[[str], None] = lambda _: None, +) -> ExtractSummary: + if not items: + raise ValueError(f'kind {name!r}: nothing to extract') + px = pixel_size_of(name, items, tomos) + box = box_pixels(kind, items, px) + folder = config.out_dir / 'particles' / name + for tomogram in sorted({p.tomogram for p, _ in items}): + vol = load_volume(tomos[tomogram], source=config.source) + mine = [f for p, f in items if p.tomogram == tomogram] + for i, f in enumerate(mine): + dest = folder / tomogram / f'{tomogram}_{i:03d}.mrc' + dest.parent.mkdir(parents=True, exist_ok=True) + with mrcfile.new(dest, overwrite=True) as mrc: + mrc.set_data(crop_box(vol, (round(f.z), round(f.y), round(f.x)), box)) + mrc.voxel_size = px + log(f'{name} {tomogram}: {len(mine)} boxes') + return ExtractSummary(name, len(items), box, px, folder) diff --git a/src/pickmix/geometry.py b/src/pickmix/geometry.py new file mode 100644 index 0000000..0f8193d --- /dev/null +++ b/src/pickmix/geometry.py @@ -0,0 +1,70 @@ +''' +pickmix: membrane-normal geometry of picks from nearest parent fit +''' + +# Import external dependencies +import math +from dataclasses import dataclass + +# Import internal pickmix objects +from pickmix.config import Config +from pickmix.estimate.registry import Fit +from pickmix.estimates import EstimateStore +from pickmix.model import Pick, PickStore +from pickmix.utils.coords import normal_to_euler +from pickmix.utils.io import Tomogram + +# _Item: a pick with its current fit +type _Item = tuple[Pick, Fit] + +# Parent: the geometry a pick gets from its parent; normal is a unit (x, y, z) vector from the parent's centre to the pick, angles in degrees +@dataclass(frozen=True) +class Parent: + parent_id: int + normal: tuple[float, float, float] + offset_a: float + rot: float + tilt: float + psi: float + +# match_items: for each child, the nearest parent in its tomogram whose surface lies within tolerance_a of it; children with none are left out +def match_items(children: list[_Item], parents: list[_Item], tolerance_a: float, tomos: dict[str, Tomogram]) -> dict[int, Parent]: + out: dict[int, Parent] = {} + for pick, fit in children: + px = tomos[pick.tomogram].pixel_size + best: tuple[float, Pick, tuple[float, float, float], float] | None = None + for parent, pfit in parents: + if parent.tomogram != pick.tomogram: + continue + vec = (fit.x - pfit.x, fit.y - pfit.y, fit.z - pfit.z) + dist = math.hypot(*vec) + # a pick on the parent's centre has no direction + if dist == 0: + continue + offset = dist * px - pfit.radius_a + if abs(offset) <= tolerance_a and (best is None or abs(offset) < abs(best[0])): + best = (offset, parent, vec, dist) + if best is not None: + offset, parent, vec, dist = best + normal = (vec[0] / dist, vec[1] / dist, vec[2] / dist) + out[pick.id] = Parent(parent.id, normal, offset, *normal_to_euler(normal)) + return out + +# match_parents: geometry for the picks of a kind that has a parent kind, optionally only in one tomogram; picks without a current fit are left out +def match_parents( + kind_name: str, config: Config, store: PickStore, estimates: EstimateStore, tomos: dict[str, Tomogram], *, tomogram: str | None = None, +) -> dict[int, Parent]: + kind = config.kinds[kind_name] + if kind.parent is None: + return {} + + # items: picks of a kind with their current fits + def items(name: str) -> list[_Item]: + out = [] + for pick in store.all(name): + fit = estimates.get(pick) + if fit is not None and (tomogram is None or pick.tomogram == tomogram): + out.append((pick, fit)) + return out + + return match_items(items(kind_name), items(kind.parent), kind.parent_tolerance_a, tomos) diff --git a/src/pickmix/gui/app.py b/src/pickmix/gui/app.py new file mode 100644 index 0000000..55a4e5b --- /dev/null +++ b/src/pickmix/gui/app.py @@ -0,0 +1,19 @@ +''' +pickmix: application start-up for GUI +''' + +# Import external dependencies +from pathlib import Path +from PySide6.QtWidgets import QApplication + +# Import internal pickmix objects +from pickmix.gui.main_window import MainWindow + +# run: show the main window, opening the config.toml in args if one is given, and return the exit code +def run(args: list[str]) -> int: + app = QApplication.instance() or QApplication(['pickmix']) + window = MainWindow() + window.show() + if args: + window.open_config(Path(args[0])) + return app.exec() diff --git a/src/pickmix/gui/average_pane.py b/src/pickmix/gui/average_pane.py new file mode 100644 index 0000000..138d804 --- /dev/null +++ b/src/pickmix/gui/average_pane.py @@ -0,0 +1,131 @@ +''' +pickmix: Average pane of the Kinds tab +''' + +# Import external dependencies +from PySide6.QtCore import Qt +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.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.tomo_list = QListWidget() + self.tomo_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + self.tomo_list.setMaximumHeight(110) + 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) + left = QVBoxLayout() + left.addWidget(QLabel('Kind')) + left.addWidget(self.kind_box) + left.addWidget(QLabel('Tomograms with picks')) + left.addWidget(self.tomo_list) + left.addWidget(self.run_button) + left.addWidget(self.status) + left.addStretch() + body = QHBoxLayout(self) + body.addLayout(left) + body.addWidget(self.mean_label) + body.addWidget(self.montage_label) + body.addWidget(self.results_list) + self.kind_box.currentTextChanged.connect(self._fill_tomograms) + 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._fill_tomograms() + + # items: current fits of the chosen kind in the selected tomograms + def items(self) -> list[Item]: + if self.session is None or self.service is None: + return [] + chosen = {i.text() for i in self.tomo_list.selectedItems()} + out = [] + for pick in self.session.store.all(self.kind_box.currentText()): + fit = self.service.store.get(pick) + if fit is not None and pick.tomogram in chosen: + out.append((pick, fit)) + return out + + # run: average the chosen kind in the background + def run(self) -> None: + if self.session is None: + return + 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) + + # _fill_tomograms: tomograms that have picks of the chosen kind, all selected + def _fill_tomograms(self) -> None: + self.tomo_list.clear() + if self.session is None: + return + kind = self.kind_box.currentText() + for name in self.session.names: + if self.session.store.count(name, kind): + self.tomo_list.addItem(name) + self.tomo_list.selectAll() + + # _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/canvas.py b/src/pickmix/gui/canvas.py new file mode 100644 index 0000000..2abd267 --- /dev/null +++ b/src/pickmix/gui/canvas.py @@ -0,0 +1,321 @@ +''' +pickmix: CPU-rendered slice canvas with pick markers +''' + +# Import external dependencies +import math, numpy as np +from dataclasses import dataclass +from PySide6.QtCore import QPointF, Qt, Signal +from PySide6.QtGui import QColor, QImage, QMouseEvent, QPainter, QPen, QPixmap, QWheelEvent +from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsRectItem, QGraphicsScene, QGraphicsView + +# _HIT_PX: screen distance from a marker centre that counts as clicking it +_HIT_PX: float = 6.0 + +# _FAINT: opacity of a marker away from its own slice +_FAINT: float = 0.3 + +# _DRAG_PX: screen distance before a press on a marker becomes a drag +_DRAG_PX: float = 3.0 + +# FitView: a fit as the canvas draws it; centre in voxels, ring and box sizes in pixels, slab as inclusive slices +@dataclass(frozen=True) +class FitView: + cx: float + cy: float + cz: float + ring_px: float | None + box_px: float | None + z_lo: int + z_hi: int + +# Marker: a pick as the canvas draws it; x, y in voxels, z the slice it was picked on +@dataclass(frozen=True) +class Marker: + id: int + x: float + y: float + z: float + radius_px: float + colour: str + flagged: bool = False + fit: FitView | None = None + # membrane normal projected on the slice, as a unit-length-or-shorter (x, y) vector + normal: tuple[float, float] | None = None + +# SliceCanvas: one slice plus markers; emits requests, never edits picks itself +class SliceCanvas(QGraphicsView): + # add_requested: left click on empty image, in voxel x, y + add_requested = Signal(float, float) + # move_requested: marker dragged and released, pick id and new voxel x, y + move_requested = Signal(int, float, float) + # marker_clicked: marker pressed and released without moving + marker_clicked = Signal(int) + # z_scrolled: wheel steps, positive is up + z_scrolled = Signal(int) + + # __init__: empty scene + def __init__(self) -> None: + super().__init__() + self._scene = QGraphicsScene(self) + self.setScene(self._scene) + self._pixmap = self._scene.addPixmap(QPixmap()) + self._slice: np.ndarray | None = None + self._window = (0.0, 1.0) + self._invert = False + self._z = 0.0 + self._markers: dict[int, Marker] = {} + self._items: dict[int, QGraphicsEllipseItem] = {} + self._extras: dict[int, tuple[QGraphicsEllipseItem | None, QGraphicsRectItem | None]] = {} + self._arrows: dict[int, QGraphicsLineItem] = {} + self._rings = True + self._boxes = True + self._normals = True + self.selected_id: int | None = None + self._drag: tuple[int, QPointF] | None = None + self._drag_moved = False + self._pan: QPointF | None = None + self._wheel = 0 + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.setBackgroundBrush(QColor(20, 20, 20)) + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + + # has_image: whether a slice is showing + @property + def has_image(self) -> bool: + return self._slice is not None + + # image_size: (width, height) of the slice in pixels, (0, 0) if none + def image_size(self) -> tuple[int, int]: + return (0, 0) if self._slice is None else (self._slice.shape[1], self._slice.shape[0]) + + # marker_ids: ids of the markers drawn + def marker_ids(self) -> list[int]: + return sorted(self._markers) + + # opacity: the opacity a marker is drawn with + def opacity(self, marker_id: int) -> float: + return self._items[marker_id].opacity() + + # overlay_visible: whether the fit ring and box of a marker are showing on this slice + def overlay_visible(self, marker_id: int) -> tuple[bool, bool]: + ring, box = self._extras.get(marker_id, (None, None)) + return (ring is not None and ring.isVisible(), box is not None and box.isVisible()) + + # normal_visible: whether a marker's normal arrow is showing + def normal_visible(self, marker_id: int) -> bool: + arrow = self._arrows.get(marker_id) + return arrow is not None and arrow.isVisible() + + # set_overlay: show or hide the fit rings, boxes and normals + def set_overlay(self, *, rings: bool, boxes: bool, normals: bool = True) -> None: + self._rings, self._boxes, self._normals = rings, boxes, normals + self._restyle() + + # set_image: show a slice windowed between lo and hi + def set_image(self, arr: np.ndarray, lo: float, hi: float, *, invert: bool = False) -> None: + self._slice = arr + self._window = (lo, hi) + self._invert = invert + self._render() + + # set_window: change the window of the slice already showing + def set_window(self, lo: float, hi: float, *, invert: bool = False) -> None: + self._window = (lo, hi) + self._invert = invert + if self._slice is not None: + self._render() + + # set_z: slice number shown, which sets how solid each marker is + def set_z(self, z: float) -> None: + self._z = z + self._restyle() + + # set_markers: replace all markers + def set_markers(self, markers: list[Marker]) -> None: + for item in self._items.values(): + self._scene.removeItem(item) + for extra in self._extras.values(): + for item in extra: + if item is not None: + self._scene.removeItem(item) + self._markers = {m.id: m for m in markers} + self._items = {} + self._extras = {} + self._arrows = {} + for m in markers: + ring = QGraphicsEllipseItem(-m.radius_px, -m.radius_px, 2 * m.radius_px, 2 * m.radius_px) + ring.setPos(m.x + 0.5, m.y + 0.5) + ring.setZValue(1) + dot = QGraphicsEllipseItem(-2, -2, 4, 4, ring) + dot.setFlag(QGraphicsEllipseItem.GraphicsItemFlag.ItemIgnoresTransformations) + dot.setBrush(QColor(m.colour)) + dot.setPen(QPen(Qt.PenStyle.NoPen)) + if m.normal is not None: + reach = max(4 * m.radius_px, 16.0) + arrow = QGraphicsLineItem(0, 0, m.normal[0] * reach, m.normal[1] * reach, ring) + arrow.setPen(self._fit_pen(m.colour, Qt.PenStyle.SolidLine, 2)) + self._arrows[m.id] = arrow + self._scene.addItem(ring) + self._items[m.id] = ring + if m.fit is not None: + self._extras[m.id] = self._add_fit(m, m.fit) + if self.selected_id not in self._markers: + self.selected_id = None + self._restyle() + + # select: highlight one marker, or none + def select(self, marker_id: int | None) -> None: + self.selected_id = marker_id if marker_id in self._markers else None + self._restyle() + + # clear: remove the slice and every marker, for a tomogram change + def clear(self) -> None: + self._slice = None + self._pixmap.setPixmap(QPixmap()) + self.set_markers([]) + + # fit: zoom to show the whole slice + def fit(self) -> None: + self.fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + + # mousePressEvent: pan, select or drag a marker, or request a new pick + def mousePressEvent(self, event: QMouseEvent) -> None: + pos = self.mapToScene(event.position().toPoint()) + if event.button() == Qt.MouseButton.MiddleButton: + self._pan = event.position() + self.setCursor(Qt.CursorShape.ClosedHandCursor) + elif event.button() == Qt.MouseButton.LeftButton: + hit = self._marker_at(pos) + if hit is not None: + self.select(hit) + self._drag, self._drag_moved = (hit, pos), False + elif self._slice is not None and self._inside(pos): + self.add_requested.emit(pos.x() - 0.5, pos.y() - 0.5) + else: + super().mousePressEvent(event) + + # mouseMoveEvent: pan, or drag the pressed marker + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if self._pan is not None: + delta = event.position() - self._pan + self._pan = event.position() + self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - int(delta.x())) + self.verticalScrollBar().setValue(self.verticalScrollBar().value() - int(delta.y())) + elif self._drag is not None: + pick_id, start = self._drag + pos = self.mapToScene(event.position().toPoint()) + delta = pos - start + if self._drag_moved or math.hypot(delta.x(), delta.y()) * self.transform().m11() > _DRAG_PX: + self._drag_moved = True + m = self._markers[pick_id] + self._items[pick_id].setPos(m.x + 0.5 + delta.x(), m.y + 0.5 + delta.y()) + else: + super().mouseMoveEvent(event) + + # mouseReleaseEvent: finish a pan, or finish a marker press as a click or a move + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.MiddleButton and self._pan is not None: + self._pan = None + self.unsetCursor() + elif event.button() == Qt.MouseButton.LeftButton and self._drag is not None: + pick_id, start = self._drag + self._drag = None + pos = self.mapToScene(event.position().toPoint()) + if self._drag_moved: + m = self._markers[pick_id] + w, h = self.image_size() + x = min(max(m.x + pos.x() - start.x(), 0.0), max(w - 1, 0)) + y = min(max(m.y + pos.y() - start.y(), 0.0), max(h - 1, 0)) + self.move_requested.emit(pick_id, x, y) + else: + self.marker_clicked.emit(pick_id) + else: + super().mouseReleaseEvent(event) + + # wheelEvent: zoom with Ctrl or Cmd, else scroll z + def wheelEvent(self, event: QWheelEvent) -> None: + if event.modifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier): + factor = 1.25 ** (event.angleDelta().y() / 120) + self.scale(factor, factor) + return + self._wheel += event.angleDelta().y() + steps = int(self._wheel / 120) + if steps: + self._wheel -= steps * 120 + self.z_scrolled.emit(steps) + + # _render: window the slice to 8 bits and show it + def _render(self) -> None: + lo, hi = self._window + scaled = np.clip((self._slice - lo) * (255.0 / max(hi - lo, 1e-6)), 0, 255).astype(np.uint8) + if self._invert: + scaled = 255 - scaled + scaled = np.ascontiguousarray(scaled) + h, w = scaled.shape + self._pixmap.setPixmap(QPixmap.fromImage(QImage(scaled.data, w, h, w, QImage.Format.Format_Grayscale8))) + self._scene.setSceneRect(0, 0, w, h) + + # _restyle: solid within the marker's radius of the current slice, faint elsewhere, white when selected + def _restyle(self) -> None: + for marker_id, item in self._items.items(): + m = self._markers[marker_id] + item.setOpacity(1.0 if abs(self._z - m.z) <= max(2.0, m.radius_px) else _FAINT) + colour = QColor('white') if marker_id == self.selected_id else QColor('red') if m.flagged else QColor(m.colour) + pen = QPen(colour) + pen.setCosmetic(True) + pen.setWidth(3 if marker_id == self.selected_id else 2) + if m.flagged: + pen.setStyle(Qt.PenStyle.DashLine) + item.setPen(pen) + for arrow in self._arrows.values(): + arrow.setVisible(self._normals) + for marker_id, (ring, box) in self._extras.items(): + fit = self._markers[marker_id].fit + assert fit is not None + if ring is not None: + ring.setVisible(self._rings and fit.z_lo <= self._z <= fit.z_hi) + if box is not None and fit.box_px is not None: + box.setVisible(self._boxes and abs(self._z - fit.cz) <= fit.box_px / 2) + + # _add_fit: dashed ring and dotted box items for a fit, in the marker's colour + def _add_fit(self, m: Marker, fit: FitView) -> tuple[QGraphicsEllipseItem | None, QGraphicsRectItem | None]: + ring = box = None + if fit.ring_px: + ring = QGraphicsEllipseItem(-fit.ring_px, -fit.ring_px, 2 * fit.ring_px, 2 * fit.ring_px) + ring.setPen(self._fit_pen(m.colour, Qt.PenStyle.DashLine)) + ring.setPos(fit.cx + 0.5, fit.cy + 0.5) + ring.setZValue(1) + self._scene.addItem(ring) + if fit.box_px: + half = fit.box_px / 2 + box = QGraphicsRectItem(-half, -half, 2 * half, 2 * half) + box.setPen(self._fit_pen(m.colour, Qt.PenStyle.DotLine)) + box.setPos(fit.cx + 0.5, fit.cy + 0.5) + box.setZValue(1) + self._scene.addItem(box) + return ring, box + + # _fit_pen: cosmetic pen for fit outlines and arrows + def _fit_pen(self, colour: str, style: Qt.PenStyle, width: int = 1) -> QPen: + pen = QPen(QColor(colour)) + pen.setCosmetic(True) + pen.setWidth(width) + pen.setStyle(style) + return pen + + # _inside: whether a scene position is on the image + def _inside(self, pos: QPointF) -> bool: + w, h = self.image_size() + return 0 <= pos.x() < w and 0 <= pos.y() < h + + # _marker_at: the marker whose centre is nearest the position and within the hit distance, if any + def _marker_at(self, pos: QPointF) -> int | None: + reach = _HIT_PX / self.transform().m11() + best: tuple[float, int] | None = None + for marker_id, m in self._markers.items(): + d = math.hypot(m.x + 0.5 - pos.x(), m.y + 0.5 - pos.y()) + if d <= reach and (best is None or d < best[0]): + best = (d, marker_id) + return best[1] if best else None diff --git a/src/pickmix/gui/images.py b/src/pickmix/gui/images.py new file mode 100644 index 0000000..f3af853 --- /dev/null +++ b/src/pickmix/gui/images.py @@ -0,0 +1,16 @@ +''' +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/kinds_tab.py b/src/pickmix/gui/kinds_tab.py new file mode 100644 index 0000000..12a749e --- /dev/null +++ b/src/pickmix/gui/kinds_tab.py @@ -0,0 +1,176 @@ +''' +pickmix: Kinds tab, editing the kinds table and saving it to config.toml +''' + +# Import external dependencies +from pydantic import ValidationError +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QComboBox, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimate.registry import ESTIMATORS +from pickmix.gui.average_pane import AveragePane +from pickmix.project import Project + +# _COLUMNS: kinds table headings; the first is the kind name, the rest are Kind fields in order +_COLUMNS: list[str] = [ + 'Name', 'Marker (A)', 'Box (A, 0 = estimator)', 'Estimator', 'Colour', 'Parent', + 'Parent tolerance (A)', 'Tilt prior (deg)', 'Extract A/px', +] + +# _ESTIMATOR_COL, _COLOUR_COL: columns needing more than plain text +_ESTIMATOR_COL: int = 3 +_COLOUR_COL: int = 4 + +# KindsTab: one row per kind, row order is hotkey order +class KindsTab(QWidget): + # config_saved: the kinds were written to config.toml + config_saved = Signal() + + # __init__: build the layout + def __init__(self) -> None: + super().__init__() + self.project: Project | None = None + self.table = QTableWidget(0, len(_COLUMNS)) + self.table.setHorizontalHeaderLabels(_COLUMNS) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.itemChanged.connect(self._paint) + self.status = QLabel() + buttons = QHBoxLayout() + for text, slot in ( + ('Add', self.add_kind), ('Remove', self.remove_selected), ('Up', lambda: self._move(-1)), + ('Down', lambda: self._move(1)), ('Save', self.save), + ): + button = QPushButton(text) + button.clicked.connect(slot) + buttons.addWidget(button) + buttons.addStretch() + self.average = AveragePane() + layout = QVBoxLayout(self) + layout.addWidget(QLabel('Row order sets the 1 to 9 hotkeys in the Pick tab.')) + layout.addWidget(self.table) + layout.addLayout(buttons) + layout.addWidget(self.status) + layout.addWidget(self.average) + + # set_project: show the kinds of a project + def set_project(self, project: Project) -> None: + self.project = project + self.table.setRowCount(0) + for name, kind in project.config.kinds.items(): + self._write_row(self.table.rowCount(), _to_cells(name, kind)) + self.status.setText('') + + # add_kind: append a passthrough kind with placeholder values for the user to edit + def add_kind(self) -> None: + taken = {self._read_row(r)[0] for r in range(self.table.rowCount())} + name = next(f'kind{i}' for i in range(1, 1000) if f'kind{i}' not in taken) + self._write_row(self.table.rowCount(), _to_cells(name, Kind(marker_a=100.0, box_a=100.0))) + + # remove_selected: drop the selected rows + def remove_selected(self) -> None: + for r in sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True): + self.table.removeRow(r) + + # collect: the kinds in the table, in order; raises ValueError naming the problem + def collect(self) -> dict[str, Kind]: + kinds: dict[str, Kind] = {} + for r in range(self.table.rowCount()): + try: + name, kind = _from_cells(self._read_row(r)) + except ValueError as e: + raise ValueError(f'row {r + 1}: {e}') from e + if name in kinds: + raise ValueError(f'row {r + 1}: duplicate kind name {name!r}') + if not QColor(kind.colour).isValid(): + raise ValueError(f'row {r + 1}: {kind.colour!r} is not a colour') + kinds[name] = kind + return kinds + + # save: validate the whole config with these kinds and write it, or show why not + def save(self) -> None: + if self.project is None: + return + try: + self.project.update(kinds=self.collect()) + except ValidationError as e: + self.status.setText(f'Not saved: {e.errors()[0]["msg"]}') + return + except ValueError as e: + self.status.setText(f'Not saved: {e}') + return + self.status.setText('Saved') + self.config_saved.emit() + + # _read_row: the cell strings of a row + def _read_row(self, r: int) -> list[str]: + out = [] + for c in range(len(_COLUMNS)): + if c == _ESTIMATOR_COL: + out.append(self.table.cellWidget(r, c).currentText()) + else: + item = self.table.item(r, c) + out.append(item.text().strip() if item else '') + return out + + # _write_row: set the cell strings of a row, creating it if needed + def _write_row(self, r: int, cells: list[str]) -> None: + if r >= self.table.rowCount(): + self.table.insertRow(r) + for c, text in enumerate(cells): + if c == _ESTIMATOR_COL: + # keep a name the registry does not know yet, so loading never rewrites it + combo = QComboBox() + combo.addItems(sorted({*ESTIMATORS, text})) + combo.setCurrentText(text) + self.table.setCellWidget(r, c, combo) + else: + item = QTableWidgetItem(text) + self.table.setItem(r, c, item) + if c == _COLOUR_COL: + self._paint(item) + + # _move: swap the selected row with its neighbour + def _move(self, delta: int) -> None: + rows = {i.row() for i in self.table.selectedIndexes()} + if len(rows) != 1: + return + a = rows.pop() + b = a + delta + if not 0 <= b < self.table.rowCount(): + return + cells_a, cells_b = self._read_row(a), self._read_row(b) + self._write_row(a, cells_b) + self._write_row(b, cells_a) + self.table.selectRow(b) + + # _paint: shade a colour cell with its colour + def _paint(self, item: QTableWidgetItem) -> None: + if item.column() == _COLOUR_COL: + colour = QColor(item.text().strip()) + item.setBackground(colour if colour.isValid() else QColor('white')) + +# _to_cells: the table strings of a kind +def _to_cells(name: str, kind: Kind) -> list[str]: + tilt = '' if kind.tilt_prior_deg is None else f'{kind.tilt_prior_deg:g}' + return [ + name, f'{kind.marker_a:g}', f'{kind.box_a:g}', kind.estimator, kind.colour, kind.parent or '', + f'{kind.parent_tolerance_a:g}', tilt, f'{kind.extract_angpix:g}', + ] + +# _from_cells: the name and Kind for table strings, ValueError on anything unreadable +def _from_cells(cells: list[str]) -> tuple[str, Kind]: + name, marker, box, estimator, colour, parent, tolerance, tilt, angpix = cells + try: + kind = Kind( + marker_a=float(marker), box_a=float(box), estimator=estimator, colour=colour, parent=parent or None, + parent_tolerance_a=float(tolerance), tilt_prior_deg=float(tilt) if tilt else None, extract_angpix=float(angpix), + ) + except ValidationError as e: + err = e.errors()[0] + raise ValueError(f'{name or "(unnamed)"}: {err["loc"][0]} {err["msg"]}') from e + return name, kind diff --git a/src/pickmix/gui/main_window.py b/src/pickmix/gui/main_window.py new file mode 100644 index 0000000..2d7a38a --- /dev/null +++ b/src/pickmix/gui/main_window.py @@ -0,0 +1,109 @@ +''' +pickmix: main GUI window +''' + +# 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 + +# 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.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 +class MainWindow(QMainWindow): + # __init__: build the tabs and dock + 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.pick_tab = PickTab() + self.review_tab = ReviewTab() + self.dock = TomoDock() + 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.setCentralWidget(self.tabs) + dock = QDockWidget('Tomograms') + dock.setWidget(self.dock) + dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock) + self.project_tab.project_changed.connect(self._on_project) + self.project_tab.config_saved.connect(self._on_settings) + self.kinds_tab.config_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)) + + # 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 + def _step(self, delta: int) -> None: + if self.session is None or self.tabs.currentWidget() is not self.pick_tab: + return + if isinstance(QApplication.focusWidget(), QLineEdit): + return + self.session.step(delta) + + # _on_project: build the session and loader for a project and unlock the tabs + def _on_project(self, project: Project) -> None: + self.project = project + self.setWindowTitle(f'pickmix: {project.path}') + if self.service is not None: + self.service.shutdown() + if self.loader is not None: + self.loader.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.average.set_session(self.session, self.service) + else: + self.session = self.loader = self.service = None + self.kinds_tab.average.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) + + # _on_settings: source or estimator settings changed, so start the session and loader afresh + def _on_settings(self) -> None: + if self.project is not None: + self._on_project(self.project) + + # _on_kinds: kinds changed; redraw what depends on them + def _on_kinds(self) -> None: + self.pick_tab.refresh_kinds() + self.review_tab.refresh_kinds() + self.kinds_tab.average.refresh_kinds() + self.dock.refresh() + + # _open_pick: show a pick from the Review tab in the Pick tab + def _open_pick(self, _tomogram: str, pick_id: int) -> None: + self.tabs.setCurrentWidget(self.pick_tab) + self.pick_tab.show_pick(pick_id) diff --git a/src/pickmix/gui/new_project_dialog.py b/src/pickmix/gui/new_project_dialog.py new file mode 100644 index 0000000..426c00e --- /dev/null +++ b/src/pickmix/gui/new_project_dialog.py @@ -0,0 +1,129 @@ +''' +pickmix: dialog collecting what is needed to create a project from RELION runs +''' + +# Import external dependencies +from pathlib import Path +from PySide6.QtWidgets import ( + QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QTableWidget, QTableWidgetItem, QVBoxLayout, 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) + +# NewProjectDialog: runs table plus directories; values() is the argument set for create_project +class NewProjectDialog(QDialog): + # __init__: build the form + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle('New project') + self.project_dir = PathField() + 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']) + self.runs.horizontalHeader().setStretchLastSection(True) + self.error = QLabel() + self.error.setStyleSheet('color: #b00020') + add = QPushButton('Add run...') + add.clicked.connect(self._add_dialog) + remove = QPushButton('Remove selected') + remove.clicked.connect(self._remove) + 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) + row = QHBoxLayout() + row.addWidget(add) + row.addWidget(remove) + row.addStretch() + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(self.runs) + layout.addLayout(row) + layout.addWidget(self.error) + layout.addWidget(buttons) + self.resize(640, 480) + + # add_run: append a row + def add_run(self, run: str, root: str) -> None: + r = self.runs.rowCount() + self.runs.insertRow(r) + self.runs.setItem(r, 0, QTableWidgetItem(run)) + self.runs.setItem(r, 1, QTableWidgetItem(root)) + + # values: arguments for create_project, raising ValueError with a readable message + def values(self) -> dict[str, object]: + runs = [] + for r in range(self.runs.rowCount()): + run, root = self._cell(r, 0), self._cell(r, 1) + if not run or not root: + raise ValueError(f'run row {r + 1} needs a name and a project root') + runs.append((run, Path(root))) + if not runs: + raise ValueError('add at least one run') + for label, field in (('project directory', self.project_dir), ('raw tomograms directory', self.raw_dir)): + if not field.text(): + raise ValueError(f'{label} is required') + denoised = self.denoised_dir.text() + return { + 'project_dir': Path(self.project_dir.text()), + 'runs': runs, + 'tomo_job': self.tomo_job.text().strip(), + 'raw_dir': Path(self.raw_dir.text()), + 'denoised_dir': Path(denoised) if denoised else None, + 'denoised_suffix': self.denoised_suffix.text().strip(), + } + + # accept: close only when the values are usable + def accept(self) -> None: + try: + self.values() + except ValueError as e: + self.error.setText(str(e)) + return + super().accept() + + # _cell: stripped text of a table cell, empty if unset + def _cell(self, row: int, col: int) -> str: + item = self.runs.item(row, col) + return item.text().strip() if item else '' + + # _add_dialog: choose a project root, name the run after its directory + def _add_dialog(self) -> None: + root = QFileDialog.getExistingDirectory(self, 'RELION project root') + if root: + self.add_run(Path(root).name, root) + + # _remove: drop the selected rows + def _remove(self) -> None: + for r in sorted({i.row() for i in self.runs.selectedIndexes()}, reverse=True): + self.runs.removeRow(r) diff --git a/src/pickmix/gui/pick_tab.py b/src/pickmix/gui/pick_tab.py new file mode 100644 index 0000000..9604c4f --- /dev/null +++ b/src/pickmix/gui/pick_tab.py @@ -0,0 +1,408 @@ +''' +pickmix: Pick tab +''' + +# Import external dependencies +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, QHBoxLayout, QLabel, QPushButton, + QSlider, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.estimates import EstimateService +from pickmix.gui.canvas import FitView, Marker, SliceCanvas +from pickmix.geometry import Parent, match_parents +from pickmix.gui.workers import post +from pickmix.loader import Loader, Opened +from pickmix.model import Session +from pickmix.timing import Timer + +# _STEPS: positions on the contrast sliders +_STEPS: int = 1000 + +# PickTab: canvas, kind buttons, z and contrast sliders, and the pick list of the current tomogram +class PickTab(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._want_select: int | None = None + self._matched: dict[str, dict[int, Parent]] = {} + self.opened: Opened | None = None + self.active: str | None = None + self.hidden: set[str] = set() + self.z = 0 + self._timer: Timer | None = None + self._first_slice = False + self._range = (0.0, 1.0) + self.canvas = SliceCanvas() + self.title = QLabel() + self.status = QLabel() + self.kind_row = QHBoxLayout() + self.kind_buttons: dict[str, QPushButton] = {} + self.kind_boxes: dict[str, QCheckBox] = {} + self.group = QButtonGroup(self) + self.z_slider = QSlider(Qt.Orientation.Horizontal) + 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') + 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.table.setMaximumWidth(380) + self.delete_button = QPushButton('Delete selected') + for button in (self.auto_button, self.fit_button, self.delete_button): + button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._row_ids: list[int] = [] + z_row = QHBoxLayout() + z_row.addWidget(QLabel('Slice')) + z_row.addWidget(self.z_slider, 1) + z_row.addWidget(self.z_label) + contrast_row = QHBoxLayout() + contrast_row.addWidget(QLabel('Contrast')) + contrast_row.addWidget(self.lo_slider, 1) + contrast_row.addWidget(self.hi_slider, 1) + contrast_row.addWidget(self.rings_box) + contrast_row.addWidget(self.boxes_box) + contrast_row.addWidget(self.normals_box) + contrast_row.addWidget(self.invert_box) + contrast_row.addWidget(self.auto_button) + contrast_row.addWidget(self.fit_button) + side = QVBoxLayout() + side.addWidget(self.table, 1) + side.addWidget(self.delete_button) + middle = QHBoxLayout() + middle.addWidget(self.canvas, 1) + middle.addLayout(side) + layout = QVBoxLayout(self) + layout.addWidget(self.title) + layout.addLayout(self.kind_row) + layout.addLayout(middle, 1) + layout.addLayout(z_row) + layout.addLayout(contrast_row) + layout.addWidget(self.status) + 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.invert_box.toggled.connect(self._on_window) + 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.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: + self.session, self.loader, self.service = session, loader, service + session.store.listeners.append(self._on_picks) + # estimates arrive on the service's thread + service.store.listeners.append(lambda: post(self._on_picks)) + session.goto_listeners.append(self._on_goto) + self.refresh_kinds() + self._on_goto() + + # refresh_kinds: rebuild the kind buttons from the config, keeping the active kind if it still exists + def refresh_kinds(self) -> None: + if self.session is 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() + if widget is not None: + widget.deleteLater() + self.kind_buttons, self.kind_boxes = {}, {} + kinds = self.session.project.config.kinds + self.hidden &= set(kinds) + for i, (name, kind) in enumerate(kinds.items()): + button = QPushButton(f'{i + 1} {name}' if i < 9 else name) + button.setCheckable(True) + button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + button.setStyleSheet(f'QPushButton {{ border-bottom: 4px solid {kind.colour}; padding: 4px 10px; }}') + button.clicked.connect(lambda _checked=False, n=name: self._set_active(n)) + box = QCheckBox('show') + box.setChecked(name not in self.hidden) + box.setFocusPolicy(Qt.FocusPolicy.NoFocus) + box.toggled.connect(lambda shown, n=name: self._toggle_kind(n, shown)) + self.group.addButton(button) + self.kind_buttons[name], self.kind_boxes[name] = button, box + self.kind_row.addWidget(button) + self.kind_row.addWidget(box) + self.kind_row.addStretch() + self._set_active(self.active if self.active in kinds else next(iter(kinds))) + self._on_picks() + + # show_pick: go to a pick, in whichever tomogram it is, and select it + def show_pick(self, pick_id: int) -> None: + assert self.session is not None + pick = self.session.store.get(pick_id) + if pick.tomogram == self.session.current and self.opened is not None: + self._clicked(pick_id) + return + self._want_select = pick_id + if pick.tomogram != self.session.current: + self.session.go(self.session.names.index(pick.tomogram)) + + # 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: + self.session.store.remove(self.canvas.selected_id) + + # _set_active: kind new picks are made as + def _set_active(self, name: str) -> None: + self.active = name + self.kind_buttons[name].setChecked(True) + + # _pick_kind: choose the i-th kind by number key + def _pick_kind(self, i: int) -> None: + names = list(self.kind_buttons) + if i < len(names): + self._set_active(names[i]) + + # _toggle_kind: show or hide a kind's markers + def _toggle_kind(self, name: str, shown: bool) -> None: + self.hidden.discard(name) if shown else self.hidden.add(name) + self._on_picks() + + # _on_goto: a different tomogram is wanted; clear the view and start loading + def _on_goto(self) -> None: + assert self.session is not None and self.loader is not None + name = self.session.current + self.opened = None + self.canvas.clear() + self.title.setText(f'{name} ({self.session.index + 1}/{len(self.session.names)}) Enter next, Shift+Enter back') + self.status.setText(f'Loading {name}...') + self._fill_table() + self._timer = Timer(f'switch to {name}') + # the dispatcher must exist before worker threads post to it + post(lambda: None) + future = self.loader.open(name) + future.add_done_callback(lambda f: post(lambda: self._open_done(name, f))) + + # _open_done: the tomogram is mapped; set the sliders, show the middle slice and start prefetching the next one + def _open_done(self, name: str, future: Future[Opened]) -> None: + assert self.session is not None and self.loader is not None + if name != self.session.current: + return + try: + self.opened = future.result() + except Exception as e: + self.status.setText(f'Could not open {name}: {type(e).__name__}: {e}') + return + if self._timer: + self._timer.lap('opened and contrast') + 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)) + self.z_slider.blockSignals(True) + self.z_slider.setRange(0, depth - 1) + self.z_slider.setValue(depth // 2) + self.z_slider.blockSignals(False) + self.status.setText('') + self._first_slice = True + self._matched = self._match() + self.canvas.set_markers(self._markers()) + self._on_overlay() + self._show_z(depth // 2) + if self._want_select is not None: + wanted, self._want_select = self._want_select, None + self._clicked(wanted) + if self.session.index + 1 < len(self.session.names): + self.loader.prefetch(self.session.names[self.session.index + 1]) + + # _show_z: show slice z, from the cache or after reading it in the background + def _show_z(self, z: int) -> None: + if self.opened is None or self.loader is None: + return + name = self.opened.name + self.z = z + self.z_label.setText(f'{z + 1} / {self.opened.vol.shape[0]}') + self.canvas.set_z(z) + cached = self.loader.cached(name, z) + if cached is not None: + self._render(cached) + return + future = self.loader.fetch(name, z) + future.add_done_callback(lambda f: post(lambda: self._slice_done(name, z, f))) + + # _slice_done: a slice read finished; show it if it is still the one wanted + def _slice_done(self, name: str, z: int, future: Future[np.ndarray]) -> None: + if self.opened is None or self.opened.name != name or self.z != z: + return + try: + self._render(future.result()) + except Exception as e: + self.status.setText(f'Could not read slice {z + 1}: {type(e).__name__}: {e}') + + # _render: draw a slice with the current window; the first one of a tomogram also fits the view + def _render(self, arr: np.ndarray) -> None: + assert self.opened is not None and self.loader is not None + lo, hi = self._window() + self.canvas.set_image(arr, lo, hi, invert=self.invert_box.isChecked()) + if self._first_slice: + self._first_slice = False + self.canvas.fit() + if self._timer: + self._timer.lap('first slice shown') + self.loader.warm_around(self.opened.name, self.z) + + # _window: contrast limits from the sliders + def _window(self) -> tuple[float, float]: + lo, hi = (self._slider_value(s.value()) for s in (self.lo_slider, self.hi_slider)) + 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: + 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 + 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._on_window() + + # _slider_pos: slider position for a pixel value + def _slider_pos(self, value: float) -> int: + lo, hi = self._range + return round((value - lo) / (hi - lo) * _STEPS) + + # _slider_value: pixel value for a slider position + def _slider_value(self, pos: int) -> float: + lo, hi = self._range + return lo + pos / _STEPS * (hi - lo) + + # _quiet: set a slider without triggering its handlers + def _quiet(self, slider: QSlider, value: int) -> None: + slider.blockSignals(True) + slider.setValue(value) + slider.blockSignals(False) + + # _on_picks: picks changed; redraw markers and the list + def _on_picks(self) -> None: + if self.session is None: + return + 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 '') + + # _match: parent geometry for the picks of the current tomogram, per kind that has a parent + def _match(self) -> dict[str, dict[int, Parent]]: + assert self.session is not None + if self.service is None: + return {} + config, project = self.session.project.config, self.session.project + return { + name: match_parents(name, config, self.session.store, self.service.store, project.tomos, tomogram=self.session.current) + for name, kind in config.kinds.items() if kind.parent is not None + } + + # _markers: markers for the visible kinds of the current tomogram + def _markers(self) -> list[Marker]: + assert self.session is not None + name = self.session.current + px = self.session.project.tomos[name].pixel_size + out = [] + for kind_name, kind in self.session.project.config.kinds.items(): + if kind_name in self.hidden: + continue + for p in self.session.store.picks(name, kind_name): + fit = self.service.store.get(p) if self.service else None + view = None if fit is None else FitView( + fit.x, fit.y, fit.z, fit.radius_a / px if kind.estimator != 'none' else None, + fit.box_a / px if fit.box_a > 0 else None, fit.z_lo, fit.z_hi, + ) + parent = self._matched.get(kind_name, {}).get(p.id) + unmatched = kind.parent is not None and fit is not None and parent is None + normal = None if parent is None else (parent.normal[0], parent.normal[1]) + 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 + def _fill_table(self) -> None: + assert self.session is not None + name = self.session.current + kinds = self.session.project.config.kinds + picks = [p for k in kinds for p in self.session.store.picks(name, k)] + self._matched = self._match() + self._row_ids = [p.id for p in picks] + self.table.setRowCount(len(picks)) + for r, p in enumerate(picks): + fit = self.service.store.get(p) if self.service else 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)): + self.table.setItem(r, c, QTableWidgetItem(text)) + self._select(self.canvas.selected_id) + + # _select: highlight a pick in the canvas and the list + def _select(self, pick_id: int | None) -> None: + self.canvas.select(pick_id) + if pick_id in self._row_ids: + self.table.selectRow(self._row_ids.index(pick_id)) + else: + self.table.clearSelection() + + # _add: new pick of the active kind on the current slice + def _add(self, x: float, y: float) -> None: + if self.session is None or self.opened is None or self.active is None: + return + pick = self.session.store.add(self.opened.name, self.active, x, y, float(self.z)) + self._select(pick.id) + + # _move: pick dragged to a new position, on the same slice it was picked on + def _move(self, pick_id: int, x: float, y: float) -> None: + if self.session is not None: + self.session.store.move(pick_id, x, y) + self._select(pick_id) + + # _clicked: pick selected on the canvas; show the slice it was picked on + def _clicked(self, pick_id: int) -> None: + assert self.session is not None + self._select(pick_id) + self.z_slider.setValue(round(self.session.store.get(pick_id).z)) + + # _row_clicked: pick chosen in the list + def _row_clicked(self, row: int, _col: int) -> None: + if 0 <= row < len(self._row_ids): + self._clicked(self._row_ids[row]) diff --git a/src/pickmix/gui/project_tab.py b/src/pickmix/gui/project_tab.py new file mode 100644 index 0000000..cceb4d1 --- /dev/null +++ b/src/pickmix/gui/project_tab.py @@ -0,0 +1,218 @@ +''' +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 Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QPushButton, + QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.config import RingParams +from pickmix.gui.new_project_dialog import NewProjectDialog +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, built from its current values +class _ParamsForm(QWidget): + # __init__: one row per field + def __init__(self, params: BaseModel) -> None: + super().__init__() + self._cls = type(params) + self._boxes: dict[str, list[QSpinBox | QDoubleSpinBox]] = {} + layout = QFormLayout(self) + for name, value in params: + values = value if isinstance(value, tuple) else (value,) + boxes = [_spin(v) for v in values] + self._boxes[name] = boxes + row = QHBoxLayout() + for box in boxes: + row.addWidget(box) + layout.addRow(name, 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() + + # __init__: build the layout + def __init__(self) -> None: + super().__init__() + self.project: Project | None = None + self.path_label = QLabel('No project open') + self.open_button = QPushButton('Open config...') + self.new_button = QPushButton('New project...') + self.reload_button = QPushButton('Reload config') + self.reload_button.setEnabled(False) + 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') + self.settings = QGroupBox('Settings') + form = QVBoxLayout(self.settings) + source = QFormLayout() + source.addRow('Volume source', self.source_box) + form.addLayout(source) + form.addWidget(QLabel('Ring estimator')) + form.addWidget(self.ring_form) + form.addWidget(self.save_button) + self.settings.setEnabled(False) + top = QHBoxLayout() + top.addWidget(self.open_button) + top.addWidget(self.new_button) + top.addWidget(self.reload_button) + top.addWidget(self.path_label, 1) + left = QVBoxLayout() + left.addWidget(self.summary) + left.addWidget(self.table) + body = QHBoxLayout() + body.addLayout(left, 3) + body.addWidget(self.settings, 1) + layout = QVBoxLayout(self) + layout.addLayout(top) + layout.addWidget(self.status) + layout.addLayout(body) + self.open_button.clicked.connect(self._open_dialog) + self.new_button.clicked.connect(self._new_dialog) + self.reload_button.clicked.connect(self._reload) + 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.settings.setEnabled(True) + self._idle('') + self.project_changed.emit(project) + + # 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 and reload buttons while work runs + def _busy(self, message: str) -> None: + self.open_button.setEnabled(False) + self.new_button.setEnabled(False) + self.reload_button.setEnabled(False) + self.status.setText(message) + + # _idle: unblock the buttons, reload only with a project open + def _idle(self, message: str) -> None: + self.open_button.setEnabled(True) + self.new_button.setEnabled(True) + self.reload_button.setEnabled(self.project is not None) + 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 new file mode 100644 index 0000000..d607c64 --- /dev/null +++ b/src/pickmix/gui/review_tab.py @@ -0,0 +1,376 @@ +''' +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.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) + 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/tomo_dock.py b/src/pickmix/gui/tomo_dock.py new file mode 100644 index 0000000..babd2c3 --- /dev/null +++ b/src/pickmix/gui/tomo_dock.py @@ -0,0 +1,68 @@ +''' +pickmix: tomogram dock +''' + +# Import external dependencies +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import QLabel, QLineEdit, QListWidget, QListWidgetItem, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.model import Session + +# TomoDock: one line per tomogram, current marked > and opened marked * +class TomoDock(QWidget): + # __init__: build the widgets + def __init__(self) -> None: + super().__init__() + self.session: Session | None = None + self.jump_edit = QLineEdit() + self.jump_edit.setPlaceholderText('go to number or name, then Enter') + self.list = QListWidget() + self.list.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + self.totals = QLabel() + self.totals.setFont(self.list.font()) + layout = QVBoxLayout(self) + layout.addWidget(self.jump_edit) + layout.addWidget(self.list, 1) + layout.addWidget(self.totals) + self.jump_edit.returnPressed.connect(self._jump) + self.list.itemClicked.connect(self._clicked) + + # set_session: show a session and follow its picks and navigation + def set_session(self, session: Session) -> None: + self.session = session + session.store.listeners.append(self.refresh) + session.goto_listeners.append(self.refresh) + self.list.clear() + self.refresh() + + # refresh: update the lines and totals in place + def refresh(self) -> None: + if self.session is None: + return + rows = self.session.rows() + if self.list.count() != len(rows): + self.list.clear() + for _ in rows: + self.list.addItem(QListWidgetItem()) + for row in rows: + self.list.item(row.index).setText(f'{">" if row.current else " "} {"*" if row.visited else " "} {row.index + 1:>3} {row.name} {row.count}') + if rows: + self.list.scrollToItem(self.list.item(self.session.index)) + opened = sum(r.visited for r in rows) + lines = [f'visited {opened}/{len(rows)}'] + lines += [f'{name:<10}{self.session.store.total(name)}' for name in self.session.project.config.kinds] + self.totals.setText('\n'.join(lines)) + + # _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() + + # _clicked: go to the clicked tomogram + def _clicked(self, item: QListWidgetItem) -> None: + if self.session is not None: + self.session.go(self.list.row(item)) diff --git a/src/pickmix/gui/workers.py b/src/pickmix/gui/workers.py new file mode 100644 index 0000000..4fda370 --- /dev/null +++ b/src/pickmix/gui/workers.py @@ -0,0 +1,48 @@ +''' +pickmix: background workers to run work off of the main GUI thread +''' + +# Import external dependencies +from collections.abc import Callable +from PySide6.QtCore import QObject, QThreadPool, Signal, Slot + +# _Dispatcher: lives on the GUI thread; calls emitted from any thread run there +class _Dispatcher(QObject): + call = Signal(object) + + # __init__: route the signal to the slot below + def __init__(self) -> None: + super().__init__() + self.call.connect(self._invoke) + + # _invoke: run a posted callable + @Slot(object) + def _invoke(self, fn: Callable[[], None]) -> None: + fn() + +# _dispatcher: created on first use, which must be on the GUI thread +_dispatcher: _Dispatcher | None = None + +# post: run fn on the GUI thread; callable from any thread once something has used the dispatcher from the GUI thread +def post(fn: Callable[[], None]) -> None: + global _dispatcher + if _dispatcher is None: + _dispatcher = _Dispatcher() + _dispatcher.call.emit(fn) + +# run_in_background: run fn on the thread pool, then on_done(result) or on_failed(message) on the GUI thread +def run_in_background[T](fn: Callable[[], T], on_done: Callable[[T], None], on_failed: Callable[[str], None]) -> None: + # touch the dispatcher here so it is created on the GUI thread + post(lambda: None) + + # job: the pool runs this + def job() -> None: + try: + result = fn() + except Exception as e: + message = f'{type(e).__name__}: {e}' + post(lambda: on_failed(message)) + return + post(lambda: on_done(result)) + + QThreadPool.globalInstance().start(job) diff --git a/src/pickmix/loader.py b/src/pickmix/loader.py new file mode 100644 index 0000000..c19e2cf --- /dev/null +++ b/src/pickmix/loader.py @@ -0,0 +1,155 @@ +''' +pickmix: asynchronous tomogram and slice loading with an LRU slice cache and prefetch +''' + +# Import external dependencies +import json, threading, numpy as np +from collections import OrderedDict +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path + +# Import internal pickmix objects +from pickmix.utils.io import Tomogram, load_volume, volume_path +from pickmix.project import Project +from pickmix.timing import timed + +# _CONTRAST_HALF: slices either side of the middle used for contrast limits +_CONTRAST_HALF: int = 2 + +# _WARM_CHUNK: bytes read per step when warming the page cache +_WARM_CHUNK: int = 8 * 1024 * 1024 + +# Opened: a tomogram ready to show; vol is the read-only (z, y, x) memory map +@dataclass(frozen=True) +class Opened: + name: str + vol: np.ndarray + limits: tuple[float, float] + +# Loader: opens tomograms and serves slices +class Loader: + # __init__: nothing is read until asked + def __init__(self, project: Project, *, cache_slices: int = 96, keep_open: int = 3) -> None: + self.project = project + self.cache_slices = cache_slices + self.keep_open = keep_open + self._pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix='pickmix-load') + self._lock = threading.Lock() + self._opened: OrderedDict[str, Future[Opened]] = OrderedDict() + self._slices: OrderedDict[tuple[str, int], np.ndarray] = OrderedDict() + self._warm_generation = 0 + self._closed = False + self._contrast_path = project.config.out_dir / 'contrast.json' + + # open: future of the tomogram, started once and kept for the last few + def open(self, name: str) -> Future[Opened]: + with self._lock: + future = self._opened.get(name) + if future is None: + future = self._opened[name] = self._submit(self._open, name) + self._opened.move_to_end(name) + while len(self._opened) > self.keep_open: + self._opened.popitem(last=False) + return future + + # cached: the slice if it is already in memory + def cached(self, name: str, z: int) -> np.ndarray | None: + with self._lock: + hit = self._slices.get((name, z)) + if hit is not None: + self._slices.move_to_end((name, z)) + return hit + + # slice: the slice, reading it now if it is not cached; blocks, so call from a worker or when it is cheap + def slice(self, name: str, z: int) -> np.ndarray: + hit = self.cached(name, z) + if hit is not None: + return hit + opened = self.open(name).result() + with timed(f'slice {name} z={z}'): + data = np.array(opened.vol[z]) + with self._lock: + self._slices[(name, z)] = data + while len(self._slices) > self.cache_slices: + self._slices.popitem(last=False) + return data + + # fetch: future of a slice, for the GUI to wait on without blocking + def fetch(self, name: str, z: int) -> Future[np.ndarray]: + return self._submit(self.slice, name, z) + + # prefetch: open a tomogram, read its middle slices and warm the page cache for the rest of the file + def prefetch(self, name: str) -> None: + self._warm_generation += 1 + generation = self._warm_generation + self._submit(self._prefetch, name, generation) + + # warm_around: read slices near z so scrolling is served from memory + def warm_around(self, name: str, z: int, radius: int = 8) -> None: + self._submit(self._warm_around, name, z, radius) + + # shutdown: stop the threads + def shutdown(self) -> None: + self._closed = True + self._warm_generation += 1 + self._pool.shutdown(wait=False, cancel_futures=True) + + # _submit: run on the pool, or hand back a cancelled future once shut down so late callers fail quietly + def _submit[T](self, fn: Callable[..., T], *args: object) -> Future[T]: + if self._closed: + future: Future[T] = Future() + future.cancel() + return future + return self._pool.submit(fn, *args) + + # _tomo: the tomogram record for a name + def _tomo(self, name: str) -> Tomogram: + return self.project.tomos[name] + + # _open: map the volume and find its contrast limits + def _open(self, name: str) -> Opened: + tomo = self._tomo(name) + with timed(f'open {name}'): + vol = load_volume(tomo, source=self.project.config.source) + with timed(f'contrast {name}'): + limits = self._limits(tomo, vol) + return Opened(name, vol, limits) + + # _limits: 1st and 99th percentile of a few middle slices, from the cache file when the volume has not changed + def _limits(self, tomo: Tomogram, vol: np.ndarray) -> tuple[float, float]: + path = volume_path(tomo, source=self.project.config.source) + mtime = path.stat().st_mtime + with self._lock: + cache = json.loads(self._contrast_path.read_text()) if self._contrast_path.exists() else {} + entry = cache.get(tomo.name) + if entry and entry['mtime'] == mtime and entry['path'] == str(path): + return entry['lo'], entry['hi'] + mid = vol.shape[0] // 2 + sample = np.asarray(vol[max(mid - _CONTRAST_HALF, 0) : mid + _CONTRAST_HALF + 1, ::2, ::2], dtype=np.float32) + lo, hi = (float(v) for v in np.percentile(sample, (1, 99))) + with self._lock: + cache = json.loads(self._contrast_path.read_text()) if self._contrast_path.exists() else {} + cache[tomo.name] = {'mtime': mtime, 'path': str(path), 'lo': lo, 'hi': hi} + self._contrast_path.parent.mkdir(parents=True, exist_ok=True) + self._contrast_path.write_text(json.dumps(cache)) + return lo, hi + + # _prefetch: open, read the middle slices, then warm the file unless a newer prefetch started + def _prefetch(self, name: str, generation: int) -> None: + opened = self.open(name).result() + self.slice(name, opened.vol.shape[0] // 2) + self._warm_file(volume_path(self._tomo(name), source=self.project.config.source), generation) + + # _warm_around: read z - radius .. z + radius + def _warm_around(self, name: str, z: int, radius: int) -> None: + depth = self.open(name).result().vol.shape[0] + for zi in range(max(z - radius, 0), min(z + radius + 1, depth)): + self.slice(name, zi) + + # _warm_file: read a file sequentially so its pages are in the operating system cache; stops when a newer prefetch starts + def _warm_file(self, path: Path, generation: int) -> None: + with timed(f'warm {path.name}'), open(path, 'rb') as handle: + while self._warm_generation == generation and handle.read(_WARM_CHUNK): + pass diff --git a/src/pickmix/model.py b/src/pickmix/model.py new file mode 100644 index 0000000..2e51642 --- /dev/null +++ b/src/pickmix/model.py @@ -0,0 +1,177 @@ +''' +pickmix: pick store, tomogram navigation and progress schema +''' + +# Import external dependencies +import csv, itertools, os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from pydantic import BaseModel + +# Import internal pickmix objects +from pickmix.project import Project + +# Pick: one hand-picked centre; x, y, z in reconstruction voxels; id lives for the session only +@dataclass +class Pick: + id: int + tomogram: str + kind: str + x: float + y: float + z: float + +# _Row: one picks.csv row; a missing kind column is a CaVe file, whose picks were all cages +class _Row(BaseModel): + tomogram: str + kind: str = 'cage' + x: float + y: float + z: float + pixel_size_a: float + +# Progress: where picking stopped and which tomograms have been opened, saved beside picks.csv +class Progress(BaseModel): + last: str | None = None + visited: list[str] = [] + +# Row: one line of the tomogram list +@dataclass(frozen=True) +class Row: + index: int + name: str + count: int + current: bool + visited: bool + +# PickStore: every pick of the project, saved to out_dir/picks.csv after each edit +class PickStore: + # __init__: load picks.csv if there is one + def __init__(self, project: Project) -> None: + self.project = project + self.path = project.config.out_dir / 'picks.csv' + self.listeners: list[Callable[[], None]] = [] + self._ids = itertools.count(1) + self._picks: dict[int, Pick] = {} + if self.path.exists(): + with open(self.path, newline='') as handle: + for row in map(_Row.model_validate, csv.DictReader(handle)): + pick = Pick(next(self._ids), row.tomogram, row.kind, row.x, row.y, row.z) + self._picks[pick.id] = pick + + # get: the pick with this id + def get(self, pick_id: int) -> Pick: + return self._picks[pick_id] + + # picks: picks in a tomogram, optionally of one kind, in the order added + def picks(self, tomogram: str, kind: str | None = None) -> list[Pick]: + return [p for p in list(self._picks.values()) if p.tomogram == tomogram and (kind is None or p.kind == kind)] + + # all: every pick, optionally of one kind + def all(self, kind: str | None = None) -> list[Pick]: + return [p for p in list(self._picks.values()) if kind is None or p.kind == kind] + + # count: number of picks in a tomogram, optionally of one kind + def count(self, tomogram: str, kind: str | None = None) -> int: + return len(self.picks(tomogram, kind)) + + # total: number of picks of a kind across all tomograms + def total(self, kind: str | None = None) -> int: + return len(self.all(kind)) + + # add: new pick; saves and notifies + def add(self, tomogram: str, kind: str, x: float, y: float, z: float) -> Pick: + pick = Pick(next(self._ids), tomogram, kind, x, y, z) + self._picks[pick.id] = pick + self._changed() + return pick + + # move: new position for a pick; saves and notifies + def move(self, pick_id: int, x: float, y: float, z: float | None = None) -> None: + pick = self._picks[pick_id] + pick.x, pick.y = x, y + if z is not None: + pick.z = z + self._changed() + + # remove: delete picks; saves and notifies once + def remove(self, *pick_ids: int) -> None: + for pick_id in pick_ids: + del self._picks[pick_id] + self._changed() + + # save: write the CSV atomically + def save(self) -> None: + pixel = {name: t.pixel_size for name, t in self.project.tomos.items()} + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix('.csv.tmp') + with open(tmp, 'w', newline='') as handle: + writer = csv.DictWriter(handle, fieldnames=list(_Row.model_fields)) + writer.writeheader() + for p in list(self._picks.values()): + # unusable tomograms keep their picks; pixel size is only informative + writer.writerow(_Row(tomogram=p.tomogram, kind=p.kind, x=p.x, y=p.y, z=p.z, pixel_size_a=pixel.get(p.tomogram, 0.0)).model_dump()) + os.replace(tmp, self.path) + + # _changed: save, then tell listeners + def _changed(self) -> None: + self.save() + for listener in self.listeners: + listener() + +# Session: the store plus which tomogram is shown; navigation clamps and never wraps +class Session: + # __init__: resume at the tomogram shown last time if it is still usable + def __init__(self, project: Project) -> None: + self.project = project + self.store = PickStore(project) + self.names = list(project.tomos) + self.progress_path = project.config.out_dir / 'progress.json' + self.goto_listeners: list[Callable[[], None]] = [] + saved = Progress.model_validate_json(self.progress_path.read_text()) if self.progress_path.exists() else Progress() + self.visited = {n for n in saved.visited if n in project.tomos} + self.index = self.names.index(saved.last) if saved.last in self.names else 0 + if self.names: + self._remember() + + # current: name of the tomogram shown + @property + def current(self) -> str: + return self.names[self.index] + + # go: show tomogram i, clamped; saves progress and notifies + def go(self, i: int) -> None: + self.index = min(max(i, 0), len(self.names) - 1) + self._remember() + for listener in self.goto_listeners: + listener() + + # step: move by delta tomograms + 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: + 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 + if hit is None: + return False + self.go(hit) + return True + + # rows: the tomogram list with counts, the current one and the opened ones flagged + def rows(self) -> list[Row]: + return [Row(i, n, self.store.count(n), i == self.index, n in self.visited) for i, n in enumerate(self.names)] + + # _remember: mark the current tomogram visited and save progress + def _remember(self) -> None: + self.visited.add(self.current) + self.progress_path.parent.mkdir(parents=True, exist_ok=True) + self.progress_path.write_text(Progress(last=self.current, visited=sorted(self.visited)).model_dump_json()) diff --git a/src/pickmix/project.py b/src/pickmix/project.py new file mode 100644 index 0000000..61b108d --- /dev/null +++ b/src/pickmix/project.py @@ -0,0 +1,55 @@ +''' +pickmix: project creation from RELION runs +''' + +# Import external dependencies +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Config, load_config, save_config +from pickmix.utils.io import Tomogram, TomogramError, find_tomograms +from pickmix.utils.star import MergeResult, merge_tomograms, read_star_loop + +# Project: an open config.toml +@dataclass +class Project: + path: Path + config: Config + names: list[str] + tomos: dict[str, Tomogram] + issues: dict[str, TomogramError] + + # update: swap config fields, validated, and write config.toml + def update(self, **changes: object) -> None: + config = self.config.replace(**changes) + save_config(config, self.path) + self.config = config + +# open_project: load a config.toml and discover its tomograms +def open_project(path: Path) -> Project: + config = load_config(path) + issues: dict[str, TomogramError] = {} + tomos = find_tomograms(config, issues=issues) + names = [row['rlnTomoName'] for row in read_star_loop(config.tomograms_star)] + return Project(path.resolve(), config, names, tomos, issues) + +# 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 = '', +) -> tuple[Path, MergeResult]: + config_path = project_dir / 'config.toml' + for path in (config_path, project_dir / 'tomograms.star'): + if path.exists(): + raise FileExistsError(f'{str(path)!r} already exists') + result = merge_tomograms(runs, tomo_job, project_dir) + config = Config( + raw_dir=raw_dir.resolve(), + denoised_dir=denoised_dir.resolve() if denoised_dir else None, + denoised_suffix=denoised_suffix, + tomograms_star=result.star, + ) + save_config(config, config_path) + return config_path, result diff --git a/src/pickmix/timing.py b/src/pickmix/timing.py new file mode 100644 index 0000000..a180b3f --- /dev/null +++ b/src/pickmix/timing.py @@ -0,0 +1,36 @@ +''' +pickmix: optional per-step timing +''' + +# Import external dependencies +import os, sys, time +from collections.abc import Iterator +from contextlib import contextmanager + +# enabled: whether timing output is on, read each call so tests can switch it +def enabled() -> bool: + return os.environ.get('PICKMIX_TIMING') == '1' + +# timed: context manager printing how long its body took +@contextmanager +def timed(label: str) -> Iterator[None]: + start = time.perf_counter() + try: + yield + finally: + if enabled(): + print(f'[timing] {label}: {time.perf_counter() - start:.3f} s', file=sys.stderr) + +# Timer: laps from one start, printing the time since the previous lap and since the start +class Timer: + # __init__: start now + def __init__(self, label: str) -> None: + self.label = label + self.start = self.last = time.perf_counter() + + # lap: report a step + def lap(self, step: str) -> None: + now = time.perf_counter() + if enabled(): + print(f'[timing] {self.label}: {step} {now - self.last:.3f} s (total {now - self.start:.3f} s)', file=sys.stderr) + self.last = now diff --git a/src/pickmix/utils/coords.py b/src/pickmix/utils/coords.py new file mode 100644 index 0000000..4570b59 --- /dev/null +++ b/src/pickmix/utils/coords.py @@ -0,0 +1,36 @@ +''' +pickmix: conversion utilities for reconstruction voxels and RELION centred Angstrom coordinates +''' + +# Import external dependencies +import math + +# Import internal pickmix objects +from pickmix.utils.io import Tomogram + +# to_centred_angstrom: (x, y, z) reconstruction voxels converted to RELION centred (x, y, z) in Angstrom +def to_centred_angstrom(xyz: tuple[float, float, float], tomo: Tomogram) -> tuple[float, float, float]: + x, y, z = xyz + cx, cy, cz = _centre(tomo) + return x * tomo.pixel_size - cx, y * tomo.pixel_size - cy, z * tomo.pixel_size - cz + +# from_centred_angstrom: RELION centred (x, y, z) in Angstrom converted to (x, y, z) reconstruction voxels +def from_centred_angstrom(xyz: tuple[float, float, float], tomo: Tomogram) -> tuple[float, float, float]: + x, y, z = xyz + cx, cy, cz = _centre(tomo) + return (x + cx) / tomo.pixel_size, (y + cy) / tomo.pixel_size, (z + cz) / tomo.pixel_size + +# normal_to_euler: (rot, tilt, psi) in degrees turning the z axis onto a unit normal; psi, the spin about the normal, is arbitrary and 0 +def normal_to_euler(normal: tuple[float, float, float]) -> tuple[float, float, float]: + nx, ny, nz = normal + return math.degrees(math.atan2(ny, nx)), math.degrees(math.acos(max(-1.0, min(1.0, nz)))), 0.0 + +# euler_to_normal: the unit vector the z axis is turned onto by (rot, tilt), in degrees; the inverse of normal_to_euler +def euler_to_normal(rot: float, tilt: float) -> tuple[float, float, float]: + r, t = math.radians(rot), math.radians(tilt) + return math.sin(t) * math.cos(r), math.sin(t) * math.sin(r), math.cos(t) + +# _centre: tomogram centre in Angstrom, from unbinned size +def _centre(tomo: Tomogram) -> tuple[float, float, float]: + cx, cy, cz = (n * tomo.unbinned_pixel_size / 2 for n in tomo.size_xyz) + return cx, cy, cz diff --git a/src/pickmix/utils/io.py b/src/pickmix/utils/io.py new file mode 100644 index 0000000..80dcfe6 --- /dev/null +++ b/src/pickmix/utils/io.py @@ -0,0 +1,121 @@ +''' +pickmix: tomogram discovery and volume reading utilities +''' + +# Import external dependencies +import mrcfile, sys, numpy as np +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +# Import internal pickmix objects +from pickmix.config import Config +from pickmix.utils.star import read_star_loop + +# Tomogram: one reconstruction paired with its RELION star row; shape is (z, y, x) at the reconstruction sampling +@dataclass(frozen=True) +class Tomogram: + # RELION name from tomograms.star, e.g. raw2_Position_10_4 + name: str + # file stem of the reconstruction as RELION knows it + stem: str + raw_path: Path + denoised_path: Path | None + # reconstruction (binned) voxel size from the MRC header + pixel_size: float + shape: tuple[int, int, int] + # tilt-series (unbinned) voxel size, and tomogram size in unbinned voxels (x, y, z), from the star file + unbinned_pixel_size: float + size_xyz: tuple[int, int, int] + +# TomogramError: a tomogram that cannot be used +class TomogramError(ValueError): + # __init__: keep what could be read alongside the message + def __init__( + self, reason: str, detail: str, *, + shape: tuple[int, int, int] | None = None, pixel_size: float | None = None, denoised: bool | None = None, + ) -> None: + super().__init__(detail) + # short label for why tomogram can't be used + self.reason = reason + self.shape = shape + self.pixel_size = pixel_size + self.denoised = denoised + +# find_tomograms: pair every tomograms.star row with its raw and denoised MRC, keyed by RELION name; bad tomograms go to the issues dict if given, else a warning on stderr +def find_tomograms(config: Config, *, issues: dict[str, TomogramError] | None = None) -> dict[str, Tomogram]: + # each directory is listed once, not once per row + raw = _index(config.raw_dir, '') + denoised = _index(config.denoised_dir, config.denoised_suffix) if config.denoised_dir else None + out: dict[str, Tomogram] = {} + for row in read_star_loop(config.tomograms_star): + name = row['rlnTomoName'] + try: + out[name] = _build(row, raw, denoised) + except TomogramError as e: + if issues is None: + print(f'skipped: {name!r}: {e}', file=sys.stderr) + else: + issues[name] = e + return out + +# volume_path: the denoised MRC when asked for and present, else the raw one +def volume_path(tomo: Tomogram, *, source: Literal['raw', 'denoised'] = 'denoised') -> Path: + return tomo.denoised_path if source == 'denoised' and tomo.denoised_path else tomo.raw_path + +# load_volume: read-only memory-mapped (z, y, x) volume +def load_volume(tomo: Tomogram, *, source: Literal['raw', 'denoised'] = 'denoised') -> np.ndarray: + return mrcfile.mmap(volume_path(tomo, source=source), mode='r', permissive=True).data + +# _Index: the MRC files of one directory with their stems, suffix removed +@dataclass(frozen=True) +class _Index: + directory: Path + entries: list[tuple[Path, str]] + +# _index: list a directory once +def _index(directory: Path, suffix: str) -> _Index: + return _Index(directory, [(p, p.stem.removesuffix(suffix)) for p in sorted(directory.glob('*.mrc'))]) + +# _build: Tomogram for one star row, raising if files are missing or disagree with the star +def _build(row: dict[str, str], raw_index: _Index, denoised_index: _Index | None) -> Tomogram: + name = row['rlnTomoName'] + stem = Path(row['rlnTomoReconstructedTomogram']).stem + raw = _match(raw_index, name, stem) + if raw is None: + raise TomogramError('no raw tomogram', f'no raw tomogram in {str(raw_index.directory)!r}') + denoised = _match(denoised_index, name, stem) if denoised_index else None + pixel_size, shape = _header(raw) + seen = {'shape': shape, 'pixel_size': pixel_size, 'denoised': denoised is not None} + binning = float(row['rlnTomoTomogramBinning']) + unbinned = float(row['rlnTomoTiltSeriesPixelSize']) + size_xyz = (int(row['rlnTomoSizeX']), int(row['rlnTomoSizeY']), int(row['rlnTomoSizeZ'])) + # the header and the star file must describe the same reconstruction + expected = tuple(round(n / binning) for n in size_xyz) + if any(abs(a - b) > 1 for a, b in zip(expected, shape[::-1], strict=True)): + raise TomogramError('shape mismatch', f'header shape (z, y, x) {shape!r} disagrees with star size {size_xyz!r} at binning {binning}', **seen) + if abs(pixel_size / (unbinned * binning) - 1.0) > 0.02: + raise TomogramError('pixel size mismatch', f'header pixel size {pixel_size:.2f} disagrees with {unbinned} x {binning}', **seen) + return Tomogram(name, stem, raw, denoised, pixel_size, shape, unbinned, size_xyz) + +# _stem_matches: whether a file stem is + reconstruction stem, the prefix being the one the merged name carries +def _stem_matches(stem: str, name: str, recon: str) -> bool: + prefix = stem.removesuffix(recon) + if prefix == stem and recon: + return False + # the name is , and the original name begins the reconstruction stem + return name.startswith(prefix) and recon.startswith(name.removeprefix(prefix)) + +# _match: single MRC in an index whose stem matches the tomogram, or None +def _match(index: _Index, name: str, recon: str) -> Path | None: + hits = [p for p, s in index.entries if _stem_matches(s, name, recon)] + # unmerged star names carry no run prefix, so any prefixed file is accepted + hits = hits or [p for p, s in index.entries if s == recon or s.endswith('_' + recon)] + if len(hits) > 1: + raise TomogramError('ambiguous match', f'{len(hits)} files in {str(index.directory)!r} match') + return hits[0] if hits else None + +# _header: voxel size and (z, y, x) shape from an MRC header without reading the data +def _header(path: Path) -> tuple[float, tuple[int, int, int]]: + with mrcfile.open(path, permissive=True, header_only=True) as mrc: + return float(mrc.voxel_size.x), (int(mrc.header.nz), int(mrc.header.ny), int(mrc.header.nx)) diff --git a/src/pickmix/utils/star.py b/src/pickmix/utils/star.py new file mode 100644 index 0000000..4a42b4e --- /dev/null +++ b/src/pickmix/utils/star.py @@ -0,0 +1,86 @@ +''' +pickmix: STAR file read/write and merging utilities +''' + +# Import external dependencies +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path + +# read_star_loop: rows of the first loop_ in a star file as column-name to string dicts +def read_star_loop(path: Path) -> list[dict[str, str]]: + columns: list[str] = [] + rows: list[dict[str, str]] = [] + in_loop = False + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if line == 'loop_': + # a second loop means the first one is finished + if in_loop: + break + in_loop = True + elif line.startswith('data_'): + if in_loop: + break + elif in_loop and line.startswith('_'): + columns.append(line.split()[0].removeprefix('_')) + elif in_loop: + rows.append(dict(zip(columns, line.split(), strict=True))) + return rows + +# write_star_loop: write one data block holding a single loop_ table +def write_star_loop(path: Path, block: str, columns: Sequence[str], rows: Iterable[Sequence[object]]) -> None: + lines = [f'data_{block}', '', 'loop_', *(f'_{c} #{i}' for i, c in enumerate(columns, 1))] + lines += [' '.join(str(v) for v in row) for row in rows] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('\n'.join(lines) + '\n') + +# MergeResult: the merged star file, how many tomograms it holds and which duplicates were left out +@dataclass(frozen=True) +class MergeResult: + star: Path + count: int + skipped: tuple[str, ...] + +# merge_tomograms: write a merged tomograms.star plus per-tomogram tilt-series stars under out_dir, names prefixed by run; runs are (run name, project root) +def merge_tomograms(runs: Sequence[tuple[str, Path]], tomo_job: str, out_dir: Path) -> MergeResult: + # star files must hold absolute paths + out_dir = out_dir.resolve() + merged: list[dict[str, str]] = [] + seen: set[str] = set() + skipped: list[str] = [] + for run, root in runs: + if not run: + raise ValueError(f'empty run name for {str(root)!r}') + for row in read_star_loop(root / tomo_job / 'tomograms.star'): + new = f'{run}_{row["rlnTomoName"]}' + # first occurrence wins when a project lists a tomogram twice + if new in seen: + skipped.append(new) + continue + seen.add(new) + ts_rows = read_star_loop(root / row['rlnTomoTiltSeriesStarFile']) + for r in ts_rows: + r['rlnMicrographName'] = _absolute(root, r['rlnMicrographName']) + ts_path = out_dir / 'tilt_series' / f'{new}.star' + _write_rows(ts_path, new, ts_rows) + row['rlnTomoName'] = new + row['rlnTomoTiltSeriesStarFile'] = str(ts_path) + row['rlnTomoReconstructedTomogram'] = _absolute(root, row['rlnTomoReconstructedTomogram']) + merged.append(row) + if not merged: + raise ValueError('no tomograms found in the given runs') + star = out_dir / 'tomograms.star' + _write_rows(star, 'global', merged) + return MergeResult(star, len(merged), tuple(skipped)) + +# _absolute: resolve a path relative to a project root, leaving absolute paths alone +def _absolute(root: Path, value: str) -> str: + return value if value.startswith('/') else str(root / value) + +# _write_rows: write dict rows as a single-loop star data block +def _write_rows(path: Path, block: str, rows: list[dict[str, str]]) -> None: + columns = list(rows[0]) if rows else [] + write_star_loop(path, block, columns, ([r[c] for c in columns] for r in rows)) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b9185c4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +''' +pickmix: shared test fixtures +''' + +# Import external dependencies +import mrcfile, os, pytest, numpy as np +from collections.abc import Callable +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Config, save_config + +# set headless Qt for GUI tests +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + +# STAR_HEAD: tomograms.star header; each tomogram is 32 x 24 x 16 voxels at 1.34 A, binned 4 to 8 x 6 x 4 at 5.36 A +STAR_HEAD = '''data_global + +loop_ +_rlnTomoName #1 +_rlnTomoTiltSeriesPixelSize #2 +_rlnTomoTomogramBinning #3 +_rlnTomoSizeX #4 +_rlnTomoSizeY #5 +_rlnTomoSizeZ #6 +_rlnTomoReconstructedTomogram #7 +''' + +# write_mrc: write a small MRC with the given voxel size and (z, y, x) shape, values counting up +def write_mrc(path: Path, voxel: float, shape: tuple[int, int, int]) -> None: + with mrcfile.new(path) as mrc: + mrc.set_data(np.arange(np.prod(shape), dtype=np.float32).reshape(shape)) + mrc.voxel_size = voxel + +# make_config: factory writing raw and denoised MRCs, a star file and a config.toml under tmp_path, returning the config path +@pytest.fixture +def make_config(tmp_path: Path) -> Callable[..., Path]: + # make: build the project; shape is (z, y, x), one tomogram per name + def make(shape: tuple[int, int, int] = (4, 6, 8), names: tuple[str, ...] = ('Position_1_2',)) -> Path: + (tmp_path / 'raw').mkdir() + (tmp_path / 'den').mkdir() + rows = [] + for n in names: + write_mrc(tmp_path / 'raw' / f'nt_raw4_{n}_stack_Vol.mrc', 5.36, shape) + write_mrc(tmp_path / 'den' / f'nt_raw4_{n}_stack_Vol.denoised.mrc', 5.36, shape) + rows.append(f'{n} 1.34 4.0 32 24 16 Tomograms/job006/tomograms/{n}_stack_Vol.mrc') + (tmp_path / 't.star').write_text(STAR_HEAD + '\n'.join(rows) + '\n') + config = Config(raw_dir=tmp_path / 'raw', denoised_dir=tmp_path / 'den', denoised_suffix='.denoised', tomograms_star=tmp_path / 't.star') + save_config(config, tmp_path / 'config.toml') + return tmp_path / 'config.toml' + return make + +# relion_project: factory for a fake RELION project root with tomograms.star and one tilt-series star per name +@pytest.fixture +def relion_project() -> Callable[[Path, list[str]], None]: + # make: write the project under root + def make(root: Path, names: list[str]) -> None: + job = root / 'Tomograms' / 'job006' + job.mkdir(parents=True) + (root / 'tilt_series').mkdir() + lines = ['data_global', '', 'loop_', '_rlnTomoName #1', '_rlnTomoTiltSeriesStarFile #2', '_rlnTomoReconstructedTomogram #3'] + for n in names: + lines.append(f'{n} tilt_series/{n}.star Tomograms/job006/tomograms/{n}_Vol.mrc') + (root / 'tilt_series' / f'{n}.star').write_text('data_x\n\nloop_\n_rlnMicrographName #1\nframes/a.mrc\n') + (job / 'tomograms.star').write_text('\n'.join(lines) + '\n') + return make diff --git a/tests/gui/test_average_pane.py b/tests/gui/test_average_pane.py new file mode 100644 index 0000000..f71c7cb --- /dev/null +++ b/tests/gui/test_average_pane.py @@ -0,0 +1,61 @@ +''' +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_lists_tomograms_with_picks: only tomograms with picks of the kind are offered, all selected + def test_lists_tomograms_with_picks(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: + widget, session, _ = pane + assert widget.tomo_list.count() == 0 + session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + widget.refresh_kinds() + assert widget.tomo_list.count() == 1 and len(widget.tomo_list.selectedItems()) == 1 + + # 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 new file mode 100644 index 0000000..1f992d9 --- /dev/null +++ b/tests/gui/test_canvas.py @@ -0,0 +1,124 @@ +''' +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_kinds_tab.py b/tests/gui/test_kinds_tab.py new file mode 100644 index 0000000..9011c6d --- /dev/null +++ b/tests/gui/test_kinds_tab.py @@ -0,0 +1,95 @@ +''' +pickmix: tests for the Kinds tab +''' + +# Import external dependencies +from collections.abc import Callable +from pathlib import Path +from pytestqt.qtbot import QtBot +from PySide6.QtWidgets import QComboBox + +# Import internal pickmix objects +from pickmix.config import Kind, load_config +from pickmix.gui.kinds_tab import KindsTab +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 + +# TestKindsTab: kinds tab +class TestKindsTab: + # test_load: the default cage kind shows and collects back unchanged + def test_load(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, _ = _tab(qtbot, make_config) + assert tab.table.rowCount() == 1 + assert tab.collect() == tab.project.config.kinds + + # test_unregistered_estimator_kept: an estimator name the registry does not know must survive load and save + def test_unregistered_estimator_kept(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + path = make_config() + project = open_project(path) + project.update(kinds={'cage': Kind(marker_a=600, estimator='future_fit')}) + tab = KindsTab() + qtbot.addWidget(tab) + tab.set_project(project) + combo = tab.table.cellWidget(0, 3) + assert isinstance(combo, QComboBox) and combo.currentText() == 'future_fit' + tab.save() + assert load_config(path).kinds['cage'].estimator == 'future_fit' + + # test_add_edit_save: a new kind edited in the table reaches config.toml in order + def test_add_edit_save(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path = _tab(qtbot, make_config) + tab.add_kind() + tab.table.item(1, 0).setText('yellow') + tab.table.item(1, 1).setText('100') + with qtbot.waitSignal(tab.config_saved): + tab.save() + kinds = load_config(path).kinds + assert list(kinds) == ['cage', 'yellow'] + assert kinds['yellow'] == Kind(marker_a=100, box_a=100) + + # test_invalid_not_saved: a bad table leaves the file alone and says why + def test_invalid_not_saved(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path = _tab(qtbot, make_config) + before = path.read_text() + tab.add_kind() + tab.table.item(1, 5).setText('missing') + tab.save() + assert 'unknown parent' in tab.status.text() + assert path.read_text() == before + + # test_bad_number_and_colour: unreadable cells are reported with the row + def test_bad_number_and_colour(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, _ = _tab(qtbot, make_config) + tab.table.item(0, 1).setText('abc') + tab.save() + assert tab.status.text().startswith('Not saved: row 1') + tab.table.item(0, 1).setText('600') + tab.table.item(0, 4).setText('notacolour') + tab.save() + assert 'not a colour' in tab.status.text() + + # test_duplicate_name: two rows with one name are rejected + def test_duplicate_name(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, _ = _tab(qtbot, make_config) + tab.add_kind() + tab.table.item(1, 0).setText('cage') + tab.save() + assert 'duplicate' in tab.status.text() + + # test_move_and_remove: Up swaps rows, Remove drops the selection + def test_move_and_remove(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, _ = _tab(qtbot, make_config) + tab.add_kind() + tab.table.selectRow(1) + tab._move(-1) + assert list(tab.collect()) == ['kind1', 'cage'] + tab.table.selectRow(0) + tab.remove_selected() + assert list(tab.collect()) == ['cage'] diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py new file mode 100644 index 0000000..5f56ff2 --- /dev/null +++ b/tests/gui/test_main_window.py @@ -0,0 +1,74 @@ +''' +pickmix: tests for GUI main window +''' + +# Import external dependencies +from collections.abc import Callable +from pathlib import Path +from pytestqt.qtbot import QtBot + +# Import internal pickmix objects +from pickmix.gui.main_window import MainWindow +from pickmix.project import open_project + +# TestMainWindow: main window +class TestMainWindow: + # test_tabs_locked_until_project: only Project is usable at first, all tabs after a project opens + def test_tabs_locked_until_project(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + assert [window.tabs.isTabEnabled(i) for i in range(4)] == [True, False, False, False] + window.project_tab.set_project(open_project(make_config())) + 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: + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(make_config())) + assert window.kinds_tab.table.item(0, 0).text() == '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: + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(make_config(names=('A_1', 'B_2', 'C_3')))) + window._step(1) + assert window.session.index == 0 + window.tabs.setCurrentWidget(window.pick_tab) + window._step(1) + assert window.session.index == 1 + window._step(-1) + window._step(-1) + assert window.session.index == 0 + window.loader.shutdown() + + # test_no_usable_tomograms: a project with nothing usable keeps the Pick tab locked + def test_no_usable_tomograms(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + 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) + + # 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: + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(make_config())) + assert window.tabs.isTabEnabled(3) and window.service is not None + pick = window.session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) + window._open_pick('Position_1_2', pick.id) + assert window.tabs.currentWidget() is window.pick_tab + window.service.shutdown() + window.loader.shutdown() + + # test_average_pane_wired: the Average pane in the Kinds 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() diff --git a/tests/gui/test_new_project_dialog.py b/tests/gui/test_new_project_dialog.py new file mode 100644 index 0000000..4ff520d --- /dev/null +++ b/tests/gui/test_new_project_dialog.py @@ -0,0 +1,39 @@ +''' +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_pick_tab.py b/tests/gui/test_pick_tab.py new file mode 100644 index 0000000..f46c4cc --- /dev/null +++ b/tests/gui/test_pick_tab.py @@ -0,0 +1,150 @@ +''' +pickmix: tests for the Pick tab +''' + +# 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.pick_tab import PickTab +from pickmix.loader import Loader +from pickmix.model import Session +from pickmix.project import open_project + +# THREE: tomograms for navigation tests +THREE: tuple[str, ...] = ('Position_1_2', 'Position_1_3', 'Other_9') + +# Setup: a tab over a session, loader and estimate service, shut down after the test +@pytest.fixture +def setup(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[Callable[..., tuple[PickTab, Session]]]: + closers: list[Callable[[], None]] = [] + + # build: a tab on the first tomogram, with two kinds + def build(names: tuple[str, ...] = ('Position_1_2',)) -> tuple[PickTab, Session]: + project = open_project(make_config(names=names)) + project.update(kinds={ + 'cage': Kind(marker_a=600, box_a=700), 'yellow': Kind(marker_a=100, box_a=100), + 'green': Kind(marker_a=40, box_a=200, parent='cage'), + }) + session, loader = Session(project), Loader(project) + service = EstimateService(session, loader) + closers.extend([service.shutdown, loader.shutdown]) + tab = PickTab() + qtbot.addWidget(tab) + tab.set_session(session, loader, service) + qtbot.waitUntil(lambda: tab.canvas.has_image, timeout=5000) + return tab, session + + yield build + for close in closers: + close() + +# TestPickTab: Pick tab +class TestPickTab: + # test_opens_middle_slice: the middle slice of the tomogram is shown + def test_opens_middle_slice(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert tab.z == 2 and tab.canvas.image_size() == (8, 6) + assert tab.z_label.text() == '3 / 4' + + # 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() + tab.canvas.add_requested.emit(3.0, 2.0) + [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' + + # 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) + 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) + assert tab.active == 'yellow' + + # test_move_and_delete: moving keeps z, deleting removes the marker + def test_move_and_delete(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + tab.canvas.add_requested.emit(3.0, 2.0) + pick = session.store.all()[0] + tab.canvas.move_requested.emit(pick.id, 5.0, 4.0) + assert (pick.x, pick.y, pick.z) == (5.0, 4.0, 2.0) + tab.delete_selected() + assert session.store.total() == 0 and tab.canvas.marker_ids() == [] + + # test_click_pick_jumps_to_z: selecting a pick shows the slice it was picked on + def test_click_pick_jumps_to_z(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + pick = session.store.add('Position_1_2', 'cage', 1.0, 1.0, 0.0) + tab.canvas.marker_clicked.emit(pick.id) + assert tab.z == 0 and tab.canvas.selected_id == pick.id + + # test_scroll_z: wheel steps move the slice within range + def test_scroll_z(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + tab.canvas.z_scrolled.emit(5) + assert tab.z == 3 + tab.canvas.z_scrolled.emit(-9) + assert tab.z == 0 + + # 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() + session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) + tab.kind_boxes['cage'].setChecked(False) + assert tab.canvas.marker_ids() == [] and tab.table.rowCount() == 1 + + # test_navigation_clears_selection: a new tomogram starts with no selection and its own picks + def test_navigation_clears_selection(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup(THREE) + tab.canvas.add_requested.emit(3.0, 2.0) + assert tab.canvas.selected_id is not None + session.step(1) + assert tab.canvas.selected_id is None and tab.table.rowCount() == 0 + assert tab.canvas.marker_ids() == [] + session.step(-1) + assert tab.table.rowCount() == 1 + + # test_late_open_ignored: results for a tomogram the user already left are dropped + def test_late_open_ignored(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup(THREE) + future = tab.loader.open('Position_1_2') + session.step(1) + tab._open_done('Position_1_2', future) + assert tab.opened is None or tab.opened.name == 'Position_1_3' + + # test_estimate_overlay: a fitted pick gets its box drawn and its SNR and flag listed + def test_estimate_overlay(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + 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() == '' + + # 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: + tab, session = setup(THREE) + pick = session.store.add('Other_9', 'cage', 1.0, 1.0, 1.0) + tab.show_pick(pick.id) + assert session.current == 'Other_9' + qtbot.waitUntil(lambda: tab.canvas.selected_id == pick.id, timeout=5000) + assert tab.z == 1 + + # test_normals_and_noparent: a pick on its parent's surface gets a normal, one off the surface is flagged noparent + def test_normals_and_noparent(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + session.store.add('Position_1_2', 'cage', 3.0, 2.0, 2.0) + 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'] diff --git a/tests/gui/test_project_tab.py b/tests/gui/test_project_tab.py new file mode 100644 index 0000000..1b9f6cf --- /dev/null +++ b/tests/gui/test_project_tab.py @@ -0,0 +1,56 @@ +''' +pickmix: tests for the Project 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 load_config +from pickmix.gui.project_tab import ProjectTab +from pickmix.project import open_project + +# TestProjectTab: project tab +class TestProjectTab: + # 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' + + # 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)' + + # 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: + tab = ProjectTab() + qtbot.addWidget(tab) + with qtbot.waitSignal(tab.project_changed): + tab.open_config(make_config()) + assert tab.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: + 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) diff --git a/tests/gui/test_review_tab.py b/tests/gui/test_review_tab.py new file mode 100644 index 0000000..a2013fb --- /dev/null +++ b/tests/gui/test_review_tab.py @@ -0,0 +1,142 @@ +''' +pickmix: tests for the Review tab +''' + +# Import external dependencies +import numpy as np +from collections.abc import Callable, Iterator +from pathlib import Path +import pytest +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.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: a review 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[ReviewTab, Session, EstimateService]]]: + closers: list[Callable[[], None]] = [] + + # build: the tab and its parts + def build() -> tuple[ReviewTab, 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 = ReviewTab() + qtbot.addWidget(tab) + tab.set_session(session, loader, service) + return tab, session, service + + yield build + for close in closers: + close() + +# parent_setup: a review 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[ReviewTab, 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 = ReviewTab() + qtbot.addWidget(tab) + tab.set_session(session, loader, service) + yield tab, session, service + service.shutdown() + loader.shutdown() + +# TestReviewTab: Review tab +class TestReviewTab: + # 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() + 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: service.pending == 0 and service.store.state(session.store.all()[1]) == 'ok', timeout=5000) + tab.refresh() + assert tab.table.rowCount() == 2 and tab.count_label.text() == '2 shown of 2' + 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) + 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: + 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 + 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: + 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_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: + 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_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)) + 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, :]) + + # 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: + tab, session, service = setup() + pick = session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + tab.refresh() + with qtbot.waitSignal(tab.open_pick) as blocker: + 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: + 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) + 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_tomo_dock.py b/tests/gui/test_tomo_dock.py new file mode 100644 index 0000000..431919a --- /dev/null +++ b/tests/gui/test_tomo_dock.py @@ -0,0 +1,57 @@ +''' +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 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: current and opened markers, numbers, names and counts + 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_click_goes: clicking a line shows that tomogram and moves the marker + def test_click_goes(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + dock, session = _dock(qtbot, make_config) + dock._clicked(dock.list.item(2)) + assert session.current == 'Other_9' + assert dock.list.item(2).text().startswith('> *') + assert dock.list.item(0).text().startswith(' *') + + # 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' + + # test_totals: visited count and per-kind totals + def test_totals(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + dock, session = _dock(qtbot, make_config) + session.store.add('Position_1_3', 'cage', 1.0, 1.0, 1.0) + assert dock.totals.text().split() == ['visited', '1/3', 'cage', '1'] diff --git a/tests/gui/test_workers.py b/tests/gui/test_workers.py new file mode 100644 index 0000000..c9bd4af --- /dev/null +++ b/tests/gui/test_workers.py @@ -0,0 +1,30 @@ +''' +pickmix: tests for GUI background workers +''' + +# Import external dependencies +from pytestqt.qtbot import QtBot +from PySide6.QtCore import QThread +from PySide6.QtWidgets import QApplication + +# Import internal pickmix objects +from pickmix.gui.workers import run_in_background + +# TestWorkers: background workers +class TestWorkers: + # test_done: the result arrives on the GUI thread + def test_done(self, qtbot: QtBot) -> None: + got: list[tuple[int, bool]] = [] + run_in_background(lambda: 6 * 7, lambda r: got.append((r, QThread.currentThread() == QApplication.instance().thread())), lambda m: None) + qtbot.waitUntil(lambda: bool(got)) + assert got == [(42, True)] + + # test_failed: an exception arrives as a message + def test_failed(self, qtbot: QtBot) -> None: + got: list[str] = [] + # boom: always fails + def boom() -> None: + raise ValueError('nope') + run_in_background(boom, lambda r: None, got.append) + qtbot.waitUntil(lambda: bool(got)) + assert got == ['ValueError: nope'] diff --git a/tests/test_average.py b/tests/test_average.py new file mode 100644 index 0000000..08e0b40 --- /dev/null +++ b/tests/test_average.py @@ -0,0 +1,56 @@ +''' +pickmix: tests for kind averages +''' + +# Import external dependencies +import mrcfile, numpy as np, pytest +from pathlib import Path + +# Import internal pickmix objects +from pickmix.average import average_kind +from pickmix.config import Config, Kind +from pickmix.estimate.registry import Fit +from pickmix.model import Pick +from pickmix.utils.io import Tomogram + +# _tomogram: a 40-voxel cube at the given voxel size with a bright sphere at each (x, y, z) centre, in noise +def _tomogram(path: Path, name: str, centres: list[tuple[int, int, int]], voxel: float = 5.0) -> Tomogram: + rng = np.random.default_rng(3) + vol = rng.normal(0.0, 0.1, (40, 40, 40)).astype(np.float32) + zz, yy, xx = np.mgrid[:40, :40, :40] + for x, y, z in centres: + vol[np.hypot(np.hypot(xx - x, yy - y), zz - z) <= 5] += 1.0 + with mrcfile.new(path / f'{name}.mrc') as mrc: + mrc.set_data(vol) + mrc.voxel_size = voxel + return Tomogram(name, name, path / f'{name}.mrc', None, voxel, (40, 40, 40), voxel / 4, (160, 160, 160)) + +# _item: a pick of kind yellow with a fit at the same place +def _item(i: int, tomogram: str, x: float, y: float, z: float) -> tuple[Pick, Fit]: + return Pick(i, tomogram, 'yellow', x, y, z), Fit(x=x, y=y, z=z, radius_a=30.0, box_a=100.0, snr=0, z_lo=round(z), z_hi=round(z)) + +# 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 + 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') + 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.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'] + + # test_nothing: no picks is an error + def test_nothing(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='nothing to average'): + average_kind('yellow', Kind(marker_a=60, box_a=100), [], {}, config) + + # test_mixed_pixel_sizes: one box in pixels needs one pixel size + def test_mixed_pixel_sizes(self, tmp_path: Path) -> None: + tomos = {'T': _tomogram(tmp_path, 'T', [(20, 20, 20)]), 'U': _tomogram(tmp_path, 'U', [(20, 20, 20)], voxel=6.0)} + 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) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d636dc8 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,68 @@ +''' +pickmix: tests for configuration schema, kinds validation and TOML round trip +''' + +# Import external dependencies +import pytest +from pathlib import Path +from pydantic import ValidationError + +# Import internal pickmix objects +from pickmix.config import Config, Kind, load_config, save_config + +# _config: a config with absolute paths, so it survives a save and load unchanged, and the given kinds +def _config(**kinds: Kind) -> Config: + extra = {'kinds': kinds} if kinds else {} + return Config(raw_dir=Path('/r'), tomograms_star=Path('/t.star'), out_dir=Path('/out'), **extra) + +# TestConfig: kinds validation, paths and the TOML round trip +class TestConfig: + # test_default_kind: no kinds table means one cage kind + def test_default_kind(self) -> None: + assert list(_config().kinds) == ['cage'] + + # test_round_trip: save then load keeps every field and the kind order + def test_round_trip(self, tmp_path: Path) -> None: + config = _config( + cage=Kind(marker_a=600, estimator='ring'), + green=Kind(marker_a=40, box_a=200, parent='cage', tilt_prior_deg=0), + yellow=Kind(marker_a=100, box_a=100), + ) + save_config(config, tmp_path / 'config.toml') + loaded = load_config(tmp_path / 'config.toml') + assert loaded == config + assert list(loaded.kinds) == ['cage', 'green', 'yellow'] + + # test_relative_paths: relative paths resolve against the config file, not the cwd + def test_relative_paths(self, tmp_path: Path) -> None: + (tmp_path / 'config.toml').write_text('raw_dir = "raw"\ntomograms_star = "t.star"\n') + config = load_config(tmp_path / 'config.toml') + assert config.raw_dir == tmp_path.resolve() / 'raw' + assert config.out_dir == tmp_path.resolve() / 'out' + assert config.denoised_dir is None + + # test_replace: replace validates the result + def test_replace(self) -> None: + assert _config().replace(source='raw').source == 'raw' + with pytest.raises(ValidationError): + _config().replace(source='other') + + # test_unknown_parent: a parent that is not a kind is rejected + def test_unknown_parent(self) -> None: + with pytest.raises(ValidationError, match='unknown parent'): + _config(green=Kind(marker_a=40, box_a=200, parent='cage')) + + # test_parent_cycle: a parent chain that loops is rejected + def test_parent_cycle(self) -> None: + with pytest.raises(ValidationError, match='loops'): + _config(a=Kind(marker_a=1, box_a=1, parent='b'), b=Kind(marker_a=1, box_a=1, parent='a')) + + # test_none_needs_box: a passthrough kind with no box is rejected + def test_none_needs_box(self) -> None: + with pytest.raises(ValidationError, match='box_a'): + _config(yellow=Kind(marker_a=100)) + + # test_bad_kind_name: names that cannot be folder names are rejected + def test_bad_kind_name(self) -> None: + with pytest.raises(ValidationError, match='letters'): + _config(**{'bad name': Kind(marker_a=1, box_a=1)}) diff --git a/tests/test_estimate.py b/tests/test_estimate.py new file mode 100644 index 0000000..63725bf --- /dev/null +++ b/tests/test_estimate.py @@ -0,0 +1,77 @@ +''' +pickmix: tests for the estimator registry, passthrough and ring estimator +''' + +# Import external dependencies +import numpy as np, pytest +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Config, Kind, RingParams +from pickmix.estimate.registry import ESTIMATORS +from pickmix.estimate.ring import ring_snr + +# PX: voxel size of the synthetic volume, so 450 A is 84 voxels +PX = 5.36 + +# _config: coarse sweep and short walk to keep the ring tests fast +def _config(**kinds: Kind) -> Config: + return Config( + raw_dir=Path('/r'), tomograms_star=Path('/t.star'), ring=RingParams(radius_step_a=50.0, walk_max_half_a=250.0, z_shift_a=60.0), + **({'kinds': kinds} if kinds else {}), + ) + +# _volume: noise with a dark ring of radius 84 voxels at (y, x) = (170, 170) present only in z = 25 to 55 +def _volume() -> np.ndarray: + rng = np.random.default_rng(1) + vol = rng.normal(0.0, 1.0, (80, 340, 340)).astype(np.float32) + yy, xx = np.mgrid[:340, :340] + vol[25:56] -= 1.5 * np.exp(-0.5 * ((np.hypot(yy - 170, xx - 170) - 84) / 5.6) ** 2) + return vol + +# TestEstimateRegistry: estimator registry tests +class TestEstimateRegistry: + # test_registered: the passthrough is available by name + def test_registered(self) -> None: + assert {'none', 'ring'} <= set(ESTIMATORS) + +# TestEstimateNone: passthrough estimator tests +class TestEstimateNone: + # test_passthrough: centre kept, size and box from the kind, no score + def test_passthrough(self) -> None: + kind = Kind(marker_a=100, box_a=120) + fit = ESTIMATORS['none'](np.zeros((1, 1, 1)), (10.4, 20.0, 30.6), kind, PX, _config(yellow=kind)) + assert (fit.x, fit.y, fit.z) == (10.4, 20.0, 30.6) + assert fit.radius_a == 50 and fit.box_a == 120 and fit.snr == 0.0 + assert fit.z_lo == fit.z_hi == 31 and fit.flag == '' + +# TestEstimateRing: ring estimator tests + # test_ring_snr: a dark ring scores high, flat noise scores near zero + def test_ring_snr(self) -> None: + params = _config().ring + rng = np.random.default_rng(2) + yy, xx = np.mgrid[:340, :340] + ring = np.exp(-0.5 * ((np.hypot(yy - 170, xx - 170) - 84) / 5.6) ** 2) + noise = rng.normal(0.0, 1.0, (340, 340)) + valid = np.ones((340, 340), dtype=bool) + assert ring_snr(noise - 4.0 * ring, valid, (170.0, 170.0), 84.0, params, PX) > 3.0 + assert abs(ring_snr(noise, valid, (170.0, 170.0), 84.0, params, PX)) < 1.0 + + # test_ring_fit: an off-centre pick is pulled onto the ring, with a sensible radius, slab and box + def test_ring_fit(self) -> None: + kind = Kind(marker_a=600, estimator='ring') + fit = ESTIMATORS['ring'](_volume(), (165, 174, 36), kind, PX, _config(cage=kind)) + assert fit.x == pytest.approx(170, abs=3) + assert fit.y == pytest.approx(170, abs=3) + assert fit.radius_a == pytest.approx(450.0, abs=30.0) + assert fit.z == pytest.approx(40, abs=8) + assert fit.z_lo <= 36 <= fit.z_hi + assert fit.box_a == pytest.approx(2 * fit.radius_a * 1.25) + assert fit.snr > 3.0 + assert 'edge' not in fit.flag + + # test_ring_fixed_box: a kind with its own box keeps it + def test_ring_fixed_box(self) -> None: + kind = Kind(marker_a=600, box_a=900, estimator='ring') + fit = ESTIMATORS['ring'](_volume(), (165, 174, 36), kind, PX, _config(cage=kind)) + assert fit.box_a == 900 diff --git a/tests/test_estimates.py b/tests/test_estimates.py new file mode 100644 index 0000000..b4195b0 --- /dev/null +++ b/tests/test_estimates.py @@ -0,0 +1,94 @@ +''' +pickmix: tests for the estimate store and background service +''' + +# Import external dependencies +import pytest, time +from collections.abc import Callable, Iterator +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimates import EstimateService, EstimateStore +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' + +# _wait: poll until a condition holds +def _wait(condition: Callable[[], bool], timeout: float = 5.0) -> None: + end = time.monotonic() + timeout + while not condition(): + assert time.monotonic() < end, 'timed out' + time.sleep(0.01) + +# service: a service over a project with a passthrough kind, a failing kind, and a session and loader that are shut down afterwards +@pytest.fixture +def service(make_config: Callable[..., Path]) -> Iterator[EstimateService]: + project = open_project(make_config()) + project.update(kinds={'yellow': Kind(marker_a=100, box_a=100), 'bad': Kind(marker_a=50, box_a=50, estimator='missing')}) + session = Session(project) + loader = Loader(project) + svc = EstimateService(session, loader) + yield svc + svc.shutdown() + loader.shutdown() + +# TestEstimates: staleness, persistence and background running +class TestEstimates: + # test_estimate_on_add: a new pick is estimated without being asked + def test_estimate_on_add(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + _wait(lambda: service.store.state(pick) == 'ok') + fit = service.store.get(pick) + assert fit is not None and fit.box_a == 100 and (fit.x, fit.y, fit.z) == (3.0, 2.0, 1.0) + + # test_moved_pick_is_missing_then_refit: a moved pick has no fit until re-estimated, and the old row is dropped + def test_moved_pick_is_missing_then_refit(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + _wait(lambda: service.store.state(pick) == 'ok') + service.session.store.move(pick.id, 4.0, 2.0) + _wait(lambda: service.store.state(pick) == 'ok') + assert service.store.get(pick).x == 4.0 + assert len(service.store.path.read_text().splitlines()) == 2 + + # test_settings_change_makes_stale: changing the kind invalidates its fits until they are recomputed + def test_settings_change_makes_stale(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + _wait(lambda: service.store.state(pick) == 'ok') + service.session.project.update(kinds={**service.session.project.config.kinds, 'yellow': Kind(marker_a=100, box_a=120)}) + assert service.store.state(pick) == 'stale' and service.store.get(pick) is None + service.request(pick) + _wait(lambda: service.store.state(pick) == 'ok') + assert service.store.get(pick).box_a == 120 + + # test_persists: a new store reads back current fits + def test_persists(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + _wait(lambda: service.store.state(pick) == 'ok') + assert EstimateStore(service.session.project, service.session.store).state(pick) == 'ok' + + # test_failure_is_flagged: an estimator that fails gives a flagged fit and a message, not a retry loop + def test_failure_is_flagged(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'bad', 3.0, 2.0, 1.0) + _wait(lambda: service.store.state(pick) == 'ok') + assert service.store.get(pick).flag == 'error' + assert 'KeyError' in service.last_error + + # test_request_all_covers_other_tomograms: everything needing a fit is queued + def test_request_all(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + _wait(lambda: service.pending == 0) + service.store._rows.clear() + assert service.store.state(pick) == 'missing' + service.request_all() + _wait(lambda: service.store.state(pick) == 'ok') + + # test_after_shutdown: requests do nothing + def test_after_shutdown(self, service: EstimateService) -> None: + service.shutdown() + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + time.sleep(0.1) + assert service.store.state(pick) == 'missing' diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..e92031a --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,96 @@ +''' +pickmix: tests for per-kind star file export +''' + +# Import external dependencies +import 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.geometry import match_items +from pickmix.model import Pick +from pickmix.utils.io import Tomogram +from pickmix.utils.star import read_star_loop + +# TOMO: 32 x 24 x 16 unbinned voxels at 1.34 A, reconstructed at 5.36 A +TOMO: Tomogram = Tomogram('T', 'T', Path('r.mrc'), None, 5.36, (4, 6, 8), 1.34, (32, 24, 16)) + +# KIND: a passthrough kind with a 100 A box +KIND: Kind = Kind(marker_a=100, box_a=100) + +# _item: a pick of kind yellow with its fit at the same place +def _item(i: int, x: float, y: float, z: float, snr: float, flag: str = '', box: float = 100.0) -> Item: + return ( + Pick(i, 'T', 'yellow', x, y, z), + Fit(x=x, y=y, z=z, radius_a=50.0, box_a=box, snr=snr, z_lo=round(z), z_hi=round(z), flag=flag), + ) + +# TestExport: star files, filters and the box recommendation +class TestExport: + # test_files: particles.star and a per-tomogram file with centred Angstrom coordinates + def test_files(self, tmp_path: Path) -> None: + s = export_kind('yellow', KIND, [_item(1, 4, 3, 2, 1.5), _item(2, 0, 0, 0, 2.0)], {'T': TOMO}, tmp_path) + rows = read_star_loop(tmp_path / 'relion' / 'yellow' / 'particles.star') + assert [r['rlnTomoName'] for r in rows] == ['T', 'T'] + assert (rows[0]['rlnCenteredCoordinateXAngst'], rows[0]['rlnCenteredCoordinateYAngst'], rows[0]['rlnCenteredCoordinateZAngst']) == ('0.00', '0.00', '0.00') + assert rows[1]['rlnCenteredCoordinateXAngst'] == '-21.44' and rows[1]['rlnAutopickFigureOfMerit'] == '2.000' + assert len(read_star_loop(tmp_path / 'relion' / 'yellow' / 'T_coords.star')) == 2 + assert s.written == 2 and s.box_a == 100 and s.box_px == 10 + + # test_filters: minimum SNR and flagged picks are dropped and counted + def test_filters(self, tmp_path: Path) -> None: + items = [_item(1, 1, 1, 1, 0.5), _item(2, 2, 2, 2, 3.0, flag='edge'), _item(3, 3, 3, 3, 3.0)] + s = export_kind('yellow', KIND, items, {'T': TOMO}, tmp_path, min_snr=1.0, drop_flagged=True) + assert (s.written, s.below_snr, s.dropped_flagged) == (1, 1, 1) + + # test_nothing_left: an empty result is an error, not an empty file + def test_nothing_left(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match='no particles'): + export_kind('yellow', KIND, [_item(1, 1, 1, 1, 0.5)], {'T': TOMO}, tmp_path, min_snr=2.0) + + # test_old_files_removed: a tomogram that no longer has particles loses its file + def test_old_files_removed(self, tmp_path: Path) -> None: + other = Tomogram('U', 'U', Path('u.mrc'), None, 5.36, (4, 6, 8), 1.34, (32, 24, 16)) + first = [_item(1, 1, 1, 1, 1.0), (Pick(2, 'U', 'yellow', 1, 1, 1), _item(2, 1, 1, 1, 1.0)[1])] + export_kind('yellow', KIND, first, {'T': TOMO, 'U': other}, tmp_path) + assert (tmp_path / 'relion' / 'yellow' / 'U_coords.star').exists() + export_kind('yellow', KIND, first[:1], {'T': TOMO, 'U': other}, tmp_path) + assert not (tmp_path / 'relion' / 'yellow' / 'U_coords.star').exists() + + # test_recommend_box: a fixed box wins; otherwise a percentile of the fits, in even pixels + def test_recommend_box(self) -> None: + assert recommend_box(KIND, []) == (100, 10) + ring = Kind(marker_a=600, estimator='ring') + items = [_item(i, 1, 1, 1, 1.0, box=b) for i, b in enumerate((1000.0, 1100.0, 1200.0))] + assert recommend_box(ring, items, percentile=100.0) == (1200.0, 114) + assert recommend_box(ring, []) == (0.0, 0) + + # test_parent_columns: a kind with a parent writes subtomogram angles, zero refined angles and the tilt prior, and leaves out unmatched picks + def test_parent_columns(self, tmp_path: Path) -> None: + cage = _item(1, 4, 3, 2, 1.0) + near, far = _item(2, 4 + 56, 3, 2, 0.0), _item(3, 4, 3, 22, 0.0) + kind = Kind(marker_a=40, box_a=200, parent='cage', tilt_prior_deg=0.0) + radius = 56 * 5.36 + cage = (cage[0], cage[1].model_copy(update={'radius_a': radius})) + parents = match_items([near, far], [cage], kind.parent_tolerance_a, {'T': TOMO}) + s = export_kind('green', kind, [near, far], {'T': TOMO}, tmp_path, parents=parents) + assert (s.written, s.unmatched) == (1, 1) + [row] = read_star_loop(tmp_path / 'relion' / 'green' / 'particles.star') + assert (row['rlnTomoSubtomogramRot'], row['rlnTomoSubtomogramTilt'], row['rlnTomoSubtomogramPsi']) == ('0.0000', '90.0000', '0.0000') + assert (row['rlnAngleRot'], row['rlnAngleTilt'], row['rlnAnglePsi']) == ('0.0000', '0.0000', '0.0000') + assert row['rlnAngleTiltPrior'] == '0.0000' + + # test_no_geometry_columns_without_parent: a plain kind writes neither angles nor a prior + def test_no_geometry_columns_without_parent(self, tmp_path: Path) -> None: + export_kind('yellow', KIND, [_item(1, 4, 3, 2, 1.0)], {'T': TOMO}, tmp_path) + [row] = read_star_loop(tmp_path / 'relion' / 'yellow' / 'particles.star') + assert not any(c.startswith(('rlnTomoSubtomogram', 'rlnAngle')) for c in row) + + # test_all_unmatched: a kind whose picks all lack a parent is an error + 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={}) diff --git a/tests/test_extract.py b/tests/test_extract.py new file mode 100644 index 0000000..e684175 --- /dev/null +++ b/tests/test_extract.py @@ -0,0 +1,44 @@ +''' +pickmix: tests for box extraction +''' + +# Import external dependencies +import mrcfile, numpy as np, pytest +from collections.abc import Callable +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimate.registry import Fit +from pickmix.extract import crop_box, extract_kind +from pickmix.model import Pick +from pickmix.project import open_project + +# TestExtract: cropping and writing boxes +class TestExtract: + # test_crop_pads_with_mean: a crop leaving the volume is padded with the crop's own mean + def test_crop_pads_with_mean(self) -> None: + vol = np.arange(4 * 6 * 8, dtype=np.float32).reshape(4, 6, 8) + box = crop_box(vol, (0, 0, 0), 4) + assert box.shape == (4, 4, 4) + assert np.array_equal(box[2:, 2:, 2:], vol[:2, :2, :2]) + assert box[0, 0, 0] == pytest.approx(vol[:2, :2, :2].mean()) + + # test_extract_kind: one even box per particle at the reconstruction pixel size + def test_extract_kind(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config()) + kind = Kind(marker_a=20, box_a=21.44) + fit = Fit(x=4, y=3, z=2, radius_a=10, box_a=21.44, snr=0, z_lo=2, z_hi=2) + s = extract_kind('yellow', kind, [(Pick(1, 'Position_1_2', 'yellow', 4, 3, 2), fit)], project.tomos, project.config) + assert s.written == 1 and s.box_px == 4 + path = project.config.out_dir / 'particles' / 'yellow' / 'Position_1_2' / 'Position_1_2_000.mrc' + with mrcfile.open(path) as mrc: + expected = np.arange(4 * 6 * 8, dtype=np.float32).reshape(4, 6, 8)[0:4, 1:5, 2:6] + assert np.array_equal(mrc.data, expected) + assert float(mrc.voxel_size.x) == pytest.approx(5.36, rel=1e-4) + + # test_nothing: no particles is an error + def test_nothing(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config()) + with pytest.raises(ValueError, match='nothing to extract'): + extract_kind('yellow', Kind(marker_a=20, box_a=20), [], project.tomos, project.config) diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..a3fab26 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,54 @@ +''' +pickmix: tests for parent geometry +''' + +# Import external dependencies +import pytest +from pathlib import Path + +# Import internal pickmix objects +from pickmix.estimate.registry import Fit +from pickmix.geometry import match_items +from pickmix.model import Pick +from pickmix.utils.io import Tomogram + +# TOMOS: one tomogram at 5.36 A per voxel +TOMOS: dict[str, Tomogram] = {'T': Tomogram('T', 'T', Path('r.mrc'), None, 5.36, (4, 6, 8), 1.34, (32, 24, 16))} + +# _item: a pick with a fit at the same place; radius_a is the fit's radius +def _item(i: int, kind: str, x: float, y: float, z: float, radius_a: float = 0.0, tomogram: str = 'T') -> tuple[Pick, Fit]: + return Pick(i, tomogram, kind, x, y, z), Fit(x=x, y=y, z=z, radius_a=radius_a, box_a=100, snr=0, z_lo=round(z), z_hi=round(z)) + +# TestGeometry: parent finding and normals +class TestGeometry: + # test_on_surface: a pick one radius from the centre gets the outward normal + def test_on_surface(self) -> None: + cage = _item(1, 'cage', 100, 100, 50, radius_a=536.0) + green = _item(2, 'green', 200, 100, 50) + [(pick_id, g)] = match_items([green], [cage], 100.0, TOMOS).items() + assert pick_id == 2 and g.parent_id == 1 + assert g.normal == pytest.approx((1.0, 0.0, 0.0)) + assert (g.rot, g.tilt, g.psi) == pytest.approx((0.0, 90.0, 0.0)) + assert g.offset_a == pytest.approx(0.0, abs=1.0) + + # test_outside_tolerance: a pick far from the surface has no parent + def test_outside_tolerance(self) -> None: + cage = _item(1, 'cage', 100, 100, 50, radius_a=536.0) + assert match_items([_item(2, 'green', 150, 100, 50)], [cage], 100.0, TOMOS) == {} + + # test_nearest_surface_wins: of two cages in range, the one whose surface is closer + def test_nearest_surface_wins(self) -> None: + a = _item(1, 'cage', 100, 100, 50, radius_a=536.0) + b = _item(2, 'cage', 300, 100, 50, radius_a=536.0 + 40.0) + green = _item(3, 'green', 200, 100, 50) + assert match_items([green], [a, b], 100.0, TOMOS)[3].parent_id == 1 + + # test_other_tomogram_ignored: parents in another tomogram do not count + def test_other_tomogram_ignored(self) -> None: + cage = _item(1, 'cage', 100, 100, 50, radius_a=536.0, tomogram='U') + assert match_items([_item(2, 'green', 200, 100, 50)], [cage], 100.0, TOMOS) == {} + + # test_on_centre: a pick exactly on the parent's centre has no direction + def test_on_centre(self) -> None: + cage = _item(1, 'cage', 100, 100, 50, radius_a=0.0) + assert match_items([_item(2, 'green', 100, 100, 50)], [cage], 100.0, TOMOS) == {} diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..5cc36e5 --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,87 @@ +''' +pickmix: tests for tomogram slice loading +''' + +# Import external dependencies +import json, numpy as np +from collections.abc import Callable +from pathlib import Path + +# Import internal pickmix objects +from pickmix.loader import Loader +from pickmix.project import open_project + +# NAME: the single tomogram of the fixture project +NAME: str = 'Position_1_2' + +# _loader: a loader over a fresh project; the fixture volume counts up, shape (4, 6, 8) +def _loader(make_config: Callable[..., Path], **kwargs: int) -> Loader: + return Loader(open_project(make_config()), **kwargs) + +class TestLoader: + # test_slice_values: a slice is the right plane of the volume + def test_slice_values(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config) + expected = np.arange(4 * 6 * 8, dtype=np.float32).reshape(4, 6, 8) + assert np.array_equal(loader.slice(NAME, 2), expected[2]) + loader.shutdown() + + # test_cached: a read slice is served from memory + def test_cached(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config) + assert loader.cached(NAME, 1) is None + loader.slice(NAME, 1) + assert loader.cached(NAME, 1) is not None + loader.shutdown() + + # test_lru_eviction: the oldest slice goes first and a touched one stays + def test_lru_eviction(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config, cache_slices=2) + loader.slice(NAME, 0) + loader.slice(NAME, 1) + loader.cached(NAME, 0) + loader.slice(NAME, 2) + assert loader.cached(NAME, 0) is not None and loader.cached(NAME, 1) is None + loader.shutdown() + + # test_contrast_limits_cached: limits come from the middle slices and are written to out/contrast.json + def test_contrast_limits_cached(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config) + opened = loader.open(NAME).result() + vol = np.arange(4 * 6 * 8, dtype=np.float32).reshape(4, 6, 8)[:, ::2, ::2] + lo, hi = np.percentile(vol, (1, 99)) + assert opened.limits == (float(lo), float(hi)) + cache = json.loads((loader.project.config.out_dir / 'contrast.json').read_text()) + assert cache[NAME]['lo'] == float(lo) + loader.shutdown() + + # test_contrast_cache_used: a cached entry with the same mtime is returned without recomputing + def test_contrast_cache_used(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config) + loader.open(NAME).result() + path = loader.project.config.out_dir / 'contrast.json' + cache = json.loads(path.read_text()) + cache[NAME]['lo'], cache[NAME]['hi'] = -1.0, 1.0 + path.write_text(json.dumps(cache)) + again = Loader(loader.project) + assert again.open(NAME).result().limits == (-1.0, 1.0) + loader.shutdown() + again.shutdown() + + # test_prefetch: prefetching opens the tomogram and caches its middle slice + def test_prefetch(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config) + loader.prefetch(NAME) + loader.open(NAME).result() + for _ in range(200): + if loader.cached(NAME, 2) is not None: + break + loader._pool.submit(lambda: None).result() + assert loader.cached(NAME, 2) is not None + loader.shutdown() + + # test_fetch: the future resolves to the slice + def test_fetch(self, make_config: Callable[..., Path]) -> None: + loader = _loader(make_config) + assert loader.fetch(NAME, 3).result().shape == (6, 8) + loader.shutdown() diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..528c071 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,110 @@ +''' +pickmix: tests for schemas +''' + +# Import external dependencies +from collections.abc import Callable +from pathlib import Path + +# Import internal pickmix objects +from pickmix.model import PickStore, Session +from pickmix.project import open_project + +# _store: a store over a fresh project +def _store(make_config: Callable[..., Path]) -> PickStore: + return PickStore(open_project(make_config())) + +# THREE: three tomograms for navigation tests +THREE: tuple[str, ...] = ('Position_1_2', 'Position_1_3', 'Other_9') + +# TestModel: pick store/tomogram navigation/progress schema +class TestModel: + # test_add_move_remove: edits are reflected in queries and listeners hear each one + def test_add_move_remove(self, make_config: Callable[..., Path]) -> None: + store = _store(make_config) + heard: list[int] = [] + store.listeners.append(lambda: heard.append(1)) + a = store.add('Position_1_2', 'cage', 1.0, 2.0, 3.0) + b = store.add('Position_1_2', 'yellow', 4.0, 5.0, 6.0) + assert store.count('Position_1_2') == 2 and store.count('Position_1_2', 'cage') == 1 + store.move(a.id, 10.0, 20.0) + assert (store.get(a.id).x, store.get(a.id).y, store.get(a.id).z) == (10.0, 20.0, 3.0) + store.remove(b.id) + assert store.total() == 1 and store.total('yellow') == 0 + assert len(heard) == 4 + + # test_autosave_reload: a new store reads back what the last edit saved + def test_autosave_reload(self, make_config: Callable[..., Path]) -> None: + path = make_config() + store = PickStore(open_project(path)) + store.add('Position_1_2', 'yellow', 1.5, 2.5, 3.5) + again = PickStore(open_project(path)) + [pick] = again.all() + assert (pick.tomogram, pick.kind, pick.x, pick.y, pick.z) == ('Position_1_2', 'yellow', 1.5, 2.5, 3.5) + + # test_cave_csv: a CaVe file with no kind column reads as cages + def test_cave_csv(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config()) + out = project.config.out_dir + out.mkdir(parents=True) + (out / 'picks.csv').write_text('tomogram,x,y,z,pixel_size_a\nPosition_1_2,1.0,2.0,3.0,5.36\n') + [pick] = PickStore(project).all() + assert pick.kind == 'cage' + + # test_unknown_kind_kept: a pick of a kind not in the config survives a save + def test_unknown_kind_kept(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config()) + out = project.config.out_dir + out.mkdir(parents=True) + (out / 'picks.csv').write_text('tomogram,kind,x,y,z,pixel_size_a\nPosition_1_2,gone,1.0,2.0,3.0,5.36\n') + store = PickStore(project) + store.add('Position_1_2', 'cage', 0.0, 0.0, 0.0) + assert sorted(p.kind for p in PickStore(project).all()) == ['cage', 'gone'] + + # test_navigation_clamps: stepping stops at both ends + def test_navigation_clamps(self, make_config: Callable[..., Path]) -> None: + session = Session(open_project(make_config(names=THREE))) + assert session.names == ['Position_1_2', 'Position_1_3', 'Other_9'] + session.step(-1) + assert session.index == 0 + session.step(1) + session.step(1) + session.step(1) + assert session.index == 2 + + # test_jump: number, exact name, then first containing the text + def test_jump(self, make_config: Callable[..., Path]) -> None: + session = Session(open_project(make_config(names=THREE))) + assert session.jump('3') and session.current == 'Other_9' + assert session.jump('position_1_2') and session.current == 'Position_1_2' + assert session.jump('1_3') and session.current == 'Position_1_3' + assert session.jump('position') and session.current == 'Position_1_2' + assert not session.jump('9x') and not session.jump('0') and not session.jump('4') and not session.jump('') + assert session.current == 'Position_1_2' + + # test_resume: a new session opens at the last tomogram and remembers the visited ones + def test_resume(self, make_config: Callable[..., Path]) -> None: + path = make_config(names=THREE) + session = Session(open_project(path)) + session.go(2) + again = Session(open_project(path)) + assert again.current == 'Other_9' + assert again.visited == {'Position_1_2', 'Other_9'} + + # test_rows_and_counts: the list marks current and visited and counts picks + def test_rows_and_counts(self, make_config: Callable[..., Path]) -> None: + session = Session(open_project(make_config(names=THREE))) + session.store.add('Position_1_3', 'cage', 1.0, 1.0, 1.0) + session.go(1) + rows = session.rows() + assert [(r.name, r.count, r.current, r.visited) for r in rows] == [ + ('Position_1_2', 0, False, True), ('Position_1_3', 1, True, True), ('Other_9', 0, False, False), + ] + + # test_goto_listener: listeners hear navigation + def test_goto_listener(self, make_config: Callable[..., Path]) -> None: + session = Session(open_project(make_config(names=THREE))) + heard: list[str] = [] + session.goto_listeners.append(lambda: heard.append(session.current)) + session.step(1) + assert heard == ['Position_1_3'] diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..adcef79 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,47 @@ +''' +pickmix: tests for creating and opening projects +''' + +# Import external dependencies +import pytest +from collections.abc import Callable +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import load_config +from pickmix.project import create_project, open_project + +# TestProject: creating and opening projects +class TestProject: + # test_create_project: writes a loadable config and refuses to overwrite it + def test_create_project(self, tmp_path: Path, relion_project: Callable[[Path, list[str]], None]) -> None: + relion_project(tmp_path / 'p', ['A']) + (tmp_path / 'raw').mkdir() + path, result = create_project(tmp_path / 'proj', [('r1', tmp_path / 'p')], 'Tomograms/job006', tmp_path / 'raw') + config = load_config(path) + assert config.tomograms_star == result.star + assert config.raw_dir == (tmp_path / 'raw').resolve() + with pytest.raises(FileExistsError): + create_project(tmp_path / 'proj', [('r1', tmp_path / 'p')], 'Tomograms/job006', tmp_path / 'raw') + + # test_open: usable tomograms and star order + def test_open(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config()) + assert project.names == ['Position_1_2'] + assert list(project.tomos) == ['Position_1_2'] + assert project.issues == {} + + # test_open_rejected: a bad tomogram is listed in names and issues but not in tomos + def test_open_rejected(self, make_config: Callable[..., Path]) -> None: + project = open_project(make_config(shape=(4, 6, 20))) + assert project.names == ['Position_1_2'] + assert project.tomos == {} + assert project.issues['Position_1_2'].reason == 'shape mismatch' + + # test_update: changes are validated, kept and written + def test_update(self, make_config: Callable[..., Path]) -> None: + path = make_config() + project = open_project(path) + project.update(source='raw') + assert project.config.source == 'raw' + assert load_config(path).source == 'raw' diff --git a/tests/test_timing.py b/tests/test_timing.py new file mode 100644 index 0000000..7d01d6f --- /dev/null +++ b/tests/test_timing.py @@ -0,0 +1,29 @@ +''' +pickmix: tests for timing output +''' + +# Import external dependencies +import pytest + +# Import internal pickmix objects +from pickmix.timing import Timer, timed + +# TestTiming: timing hook +class TestTiming: + # test_off_by_default: nothing is printed unless PICKMIX_TIMING=1 + def test_off_by_default(self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv('PICKMIX_TIMING', raising=False) + with timed('step'): + pass + Timer('t').lap('a') + assert capsys.readouterr().err == '' + + # test_on: both helpers print their label + def test_on(self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('PICKMIX_TIMING', '1') + with timed('load'): + pass + timer = Timer('switch') + timer.lap('contrast') + err = capsys.readouterr().err + assert '[timing] load:' in err and '[timing] switch: contrast' in err diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..280b6c0 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,124 @@ +''' +pickmix: tests for the utils modules +''' + +# Import external dependencies +import numpy as np, pytest +from collections.abc import Callable +from pathlib import Path + +# Import internal pickmix objects +from pickmix.config import load_config +from pickmix.utils.coords import euler_to_normal, from_centred_angstrom, normal_to_euler, to_centred_angstrom +from pickmix.utils.io import Tomogram, TomogramError, find_tomograms, load_volume, volume_path +from pickmix.utils.star import merge_tomograms, read_star_loop, write_star_loop + +# TOMO: 32 x 24 x 16 unbinned voxels at 1.34 A, reconstructed at 5.36 A (bin 4) +TOMO: Tomogram = Tomogram('T', 'T', Path('r.mrc'), None, 5.36, (4, 6, 8), 1.34, (32, 24, 16)) + +# TestStar: STAR reading and writing, and merging RELION runs +class TestStar: + # test_round_trip: columns and values survive a write then a read + def test_round_trip(self, tmp_path: Path) -> None: + path = tmp_path / 'sub' / 'p.star' + write_star_loop(path, 'particles', ['rlnTomoName', 'rlnCenteredCoordinateXAngst'], [('T1', 1.5), ('T2', -2.25)]) + assert read_star_loop(path) == [ + {'rlnTomoName': 'T1', 'rlnCenteredCoordinateXAngst': '1.5'}, + {'rlnTomoName': 'T2', 'rlnCenteredCoordinateXAngst': '-2.25'}, + ] + + # test_only_first_loop: a second loop is ignored + def test_only_first_loop(self, tmp_path: Path) -> None: + path = tmp_path / 'p.star' + path.write_text('data_a\n\nloop_\n_rlnA #1\n1\n\nloop_\n_rlnB #1\n2\n') + assert read_star_loop(path) == [{'rlnA': '1'}] + + # test_merge: names get the run prefix and every path is absolute + def test_merge(self, tmp_path: Path, relion_project: Callable[[Path, list[str]], None]) -> None: + relion_project(tmp_path / 'p', ['A', 'B']) + result = merge_tomograms([('r1', tmp_path / 'p')], 'Tomograms/job006', tmp_path / 'out') + rows = read_star_loop(result.star) + assert [r['rlnTomoName'] for r in rows] == ['r1_A', 'r1_B'] + assert rows[0]['rlnTomoReconstructedTomogram'] == str(tmp_path / 'p' / 'Tomograms/job006/tomograms/A_Vol.mrc') + ts = read_star_loop(Path(rows[0]['rlnTomoTiltSeriesStarFile'])) + assert ts[0]['rlnMicrographName'] == str(tmp_path / 'p' / 'frames/a.mrc') + + # test_merge_duplicates: the first occurrence wins and the rest are reported + def test_merge_duplicates(self, tmp_path: Path, relion_project: Callable[[Path, list[str]], None]) -> None: + relion_project(tmp_path / 'p', ['A', 'B']) + result = merge_tomograms([('r1', tmp_path / 'p'), ('r1', tmp_path / 'p')], 'Tomograms/job006', tmp_path / 'out') + assert result.count == 2 + assert result.skipped == ('r1_A', 'r1_B') + + # test_merge_nothing: runs with no rows are an error + def test_merge_nothing(self, tmp_path: Path, relion_project: Callable[[Path, list[str]], None]) -> None: + relion_project(tmp_path / 'p', []) + with pytest.raises(ValueError, match='no tomograms'): + merge_tomograms([('r1', tmp_path / 'p')], 'Tomograms/job006', tmp_path / 'out') + +# TestIo: tomogram discovery and volume access +class TestIo: + # test_find_tomograms: pairing by stem, header values and star values + def test_find_tomograms(self, make_config: Callable[..., Path]) -> None: + t = find_tomograms(load_config(make_config()))['Position_1_2'] + assert t.shape == (4, 6, 8) + assert t.pixel_size == pytest.approx(5.36, rel=1e-4) + assert t.size_xyz == (32, 24, 16) + assert t.denoised_path is not None + assert load_volume(t, source='denoised').shape == (4, 6, 8) + + # test_volume_path: raw is used when asked for, denoised otherwise when present + def test_volume_path(self, make_config: Callable[..., Path]) -> None: + t = find_tomograms(load_config(make_config()))['Position_1_2'] + assert volume_path(t, source='raw') == t.raw_path + assert volume_path(t, source='denoised') == t.denoised_path + + # test_shape_mismatch: a header that disagrees with the star file is skipped and reported + def test_shape_mismatch(self, make_config: Callable[..., Path]) -> None: + issues: dict[str, TomogramError] = {} + tomos = find_tomograms(load_config(make_config(shape=(4, 6, 20))), issues=issues) + assert tomos == {} + assert issues['Position_1_2'].reason == 'shape mismatch' + assert issues['Position_1_2'].shape == (4, 6, 20) + + # test_no_raw: a star row with no matching file is reported + def test_no_raw(self, make_config: Callable[..., Path]) -> None: + config = load_config(make_config()) + next(config.raw_dir.glob('*.mrc')).unlink() + issues: dict[str, TomogramError] = {} + assert find_tomograms(config, issues=issues) == {} + assert issues['Position_1_2'].reason == 'no raw tomogram' + +# TestCoords: voxel and centred Angstrom conversions +class TestCoords: + # test_centre_is_origin: the middle voxel of the tomogram maps to zero + def test_centre_is_origin(self) -> None: + assert to_centred_angstrom((4.0, 3.0, 2.0), TOMO) == pytest.approx((0.0, 0.0, 0.0), abs=1e-6) + + # test_known_value: voxel 0 is half the unbinned size away from the centre, and x, y, z keep their order + def test_known_value(self) -> None: + assert to_centred_angstrom((0.0, 0.0, 0.0), TOMO) == pytest.approx((-21.44, -16.08, -10.72)) + assert to_centred_angstrom((8.0, 0.0, 0.0), TOMO)[0] == pytest.approx(21.44) + + # test_round_trip: the conversion inverts + def test_round_trip(self) -> None: + xyz = (6.0, 2.5, 1.5) + assert from_centred_angstrom(to_centred_angstrom(xyz, TOMO), TOMO) == pytest.approx(xyz) + + # test_normal_to_euler_axes: the axes map to the expected angles + def test_normal_to_euler_axes(self) -> None: + assert normal_to_euler((0.0, 0.0, 1.0)) == pytest.approx((0.0, 0.0, 0.0)) + assert normal_to_euler((1.0, 0.0, 0.0)) == pytest.approx((0.0, 90.0, 0.0)) + assert normal_to_euler((0.0, 1.0, 0.0)) == pytest.approx((90.0, 90.0, 0.0)) + assert normal_to_euler((0.0, 0.0, -1.0))[1] == pytest.approx(180.0) + + # test_euler_rotation_gives_normal: Rz(rot) Ry(tilt) applied to z is the normal, and the inverse recovers the angles + def test_euler_rotation_gives_normal(self) -> None: + n = np.array([0.3, -0.5, 0.2]) + n /= np.linalg.norm(n) + rot, tilt, _ = normal_to_euler(tuple(n)) + r, t = np.radians(rot), np.radians(tilt) + rz = np.array([[np.cos(r), -np.sin(r), 0], [np.sin(r), np.cos(r), 0], [0, 0, 1]]) + ry = np.array([[np.cos(t), 0, np.sin(t)], [0, 1, 0], [-np.sin(t), 0, np.cos(t)]]) + assert rz @ ry @ np.array([0, 0, 1]) == pytest.approx(n) + assert euler_to_normal(rot, tilt) == pytest.approx(tuple(n)) diff --git a/uv.lock b/uv.lock index e4a51e9..de5a129 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,576 @@ version = 1 revision = 5 requires-python = ">=3.14" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "imageio" +version = "2.38.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/cd/69e4ac55b6dafdd2b5f32075236841a3945dea7323d0232d80f28c37cea8/imageio-2.38.1.tar.gz", hash = "sha256:6769f1f01c4dd46448307863a787c9a22fa4dbe11c0c88f525c6e410fdfd7983", size = 403667, upload-time = "2026-10-08T14:35:32.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/8a/3b7f62b9df56460959f1b9bef094248d2a4d7f393c5b0d2da8efcdf0e40e/imageio-2.38.1-py3-none-any.whl", hash = "sha256:36d23eb7423d2fb63f637098758edb3d2df3687f7125e0a5ce8596572022dcf9", size = 328389, upload-time = "2026-10-08T14:35:31.075Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/8c/0f2ff2a8b7513e68871a74740c17a504b0488c679b379e6445ebb7bd78dc/lazy_loader-0.6.tar.gz", hash = "sha256:2f4b7824d6401958639008a0cae20c776b61dff619accd6890693e2de8260167", size = 18438, upload-time = "2026-09-21T21:31:32.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/93/4e45e37f26c820216704b36b8e67cc55c490a7abd287a78a26a2c0e21386/lazy_loader-0.6-py3-none-any.whl", hash = "sha256:77253be3391b06124a0e16105bd663b6c54470af1a9ca8e1cf026f38d58ed056", size = 8791, upload-time = "2026-09-21T21:31:31.632Z" }, +] + +[[package]] +name = "mrcfile" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/22/8834f44c2455291e241fb2543c2d5d4f0fe58fd63ad0f42e86570f0b1161/mrcfile-1.5.4.tar.gz", hash = "sha256:8fa9eb2e237d87d99b301c61bf02854dd85a451db25eefcbe142c09b36d8d601", size = 52052606, upload-time = "2025-01-22T12:36:10.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/cb/3f4ee8233f30c7926f1ed4885ff32b79ec7ce3210370f43e1cb2b385bed6/mrcfile-1.5.4-py2.py3-none-any.whl", hash = "sha256:195370a13db5ce19600499f9bdf90fd9888979bf39de19a17c19024436c6f4c2", size = 45012, upload-time = "2025-01-22T12:02:38.502Z" }, +] + +[[package]] +name = "networkx" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "pickmix" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "imageio" }, + { name = "mrcfile" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pyside6" }, + { name = "scikit-image" }, + { name = "scipy" }, + { name = "tomli-w" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-qt" }, +] + +[package.metadata] +requires-dist = [ + { name = "imageio", specifier = ">=2.38.1" }, + { name = "mrcfile", specifier = ">=1.5.4" }, + { name = "numpy", specifier = ">=2.5.4" }, + { name = "pydantic", specifier = ">=2.14.2" }, + { name = "pyside6", specifier = ">6.9" }, + { name = "scikit-image", specifier = ">=0.26.0" }, + { name = "scipy", specifier = ">=1.18.1" }, + { name = "tomli-w", specifier = ">=1.2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.2.0" }, + { name = "pytest-qt", specifier = ">=4.5.0" }, +] + +[[package]] +name = "pillow" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz", hash = "sha256:fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d", size = 911596, upload-time = "2026-10-29T16:37:12.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl", hash = "sha256:7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118", size = 490403, upload-time = "2026-10-29T16:37:10.181Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz", hash = "sha256:1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775", size = 518711, upload-time = "2026-10-29T16:34:57.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/64/fc525bb981a9f287cfa34346e7ed1d5ef7c043c2b9b517f54c6136032d10/pydantic_core-2.50.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:efad0bdc67d3b119c3bab6b94d341a27333c2bb89ed75dfa182a4a06cd0ea329", size = 2226414, upload-time = "2026-10-29T16:32:58.559Z" }, + { url = "https://files.pythonhosted.org/packages/db/68/315701095f25687938ab79d263a6d939337456c2840fe69038fd38000338/pydantic_core-2.50.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ffb4dce3ecb2cd563aa30f9eb5088a1cf1f4f688b90cbb374fa96e49fc50b02", size = 2095041, upload-time = "2026-10-29T16:32:59.873Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4b/efd3abf31aa24bc97b311735f418886f947b4f193df02a1977643963b03e/pydantic_core-2.50.2-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:150f6e101f575c21a4ecfed1be1c05bb89a0ad49cb9c846ed377583bd71b32c2", size = 2257635, upload-time = "2026-10-29T16:33:01.096Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6c/146b36eed4de8115a0fda76840703e75f5f8b36aeb3af86976bf094d1a17/pydantic_core-2.50.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1db9cab6197e797824f0479dd2ae5fd2e272aa61c61b7faa5a6e450ca7a0d3b7", size = 2116807, upload-time = "2026-10-29T16:33:02.692Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/db006ea79bbe405cc38b25899319c7d461ff1813cbdfe6aa90a65ed1dfff/pydantic_core-2.50.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1b9f652e17a13bbc09b45f74c3c326cca72059ce4dcdef687fcb04389db413a9", size = 2201037, upload-time = "2026-10-29T16:33:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/41/f9/ea2bc1ec13a67af4a63c9a887fa0aa6ef4ade9018e2bb412d4fc80110e19/pydantic_core-2.50.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:823b222c4fc861a82c650998a6117ecbd150cc71bf8b78f9a31bf3f48c456108", size = 2374904, upload-time = "2026-10-29T16:33:05.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/8e/26a6a075301f0e563646037ea69bef8c63911d38fbd257f94cec69cdf985/pydantic_core-2.50.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:783abdfac21247d33bec40aa683d26bba30d61655c40de38b2decc4797aabf92", size = 2457626, upload-time = "2026-10-29T16:33:07.271Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a2/8eeabaf1d394f0d3b9a27129ac21556eabee6f2ddcb418069ef4037e00b0/pydantic_core-2.50.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b29b7c5adf4b2be7c941f7f76432aa3269cd5052ff8610376b8ece24d7b02c51", size = 2245505, upload-time = "2026-10-29T16:33:08.824Z" }, + { url = "https://files.pythonhosted.org/packages/bd/54/449778cd397e2682b30d1bcfab0872f6ac36702b59cc326296b7f4f14928/pydantic_core-2.50.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4ac996e42a93d80122a23edce44e02c6b9c4913f5c6198a077da31475341a39a", size = 2259563, upload-time = "2026-10-29T16:33:10.107Z" }, + { url = "https://files.pythonhosted.org/packages/26/c2/2f65691d25488ee0f3c17861d079e8ccb7817955522d07cd3c3970d30af5/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:99e1d482138a71902415ba375bb4627679a5fefd3bd07803c3a8f37632f4981d", size = 2290470, upload-time = "2026-10-29T16:33:11.273Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/aa7c40234cd24536afa0895ff1d26f9f15638004e25242c5193ce053b96b/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a53d5f11cd6d15569ba3e53f3498077f0d5e633f6ec604d2ea613e895017ebee", size = 2477272, upload-time = "2026-10-29T16:33:12.771Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/c347c68e040cf93d8487a8f4fc28f2768312834c7d21c301538f416f9955/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23385c4626f8b5a7792aa381ea573d9432cfbf4c21375a0a5eed2a4fb64e0e58", size = 2484352, upload-time = "2026-10-29T16:33:14.485Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8e/72556126c8b6154fe7acaf757b96b2daa37aeffc3a517deb76e272f5ad09/pydantic_core-2.50.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9a8a62281fd9c6a373b3fd3d473685e6838d43341eecde5f47729748e2c11ee3", size = 1491226, upload-time = "2026-10-29T16:33:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/5b/66/004c85511812964a36b3535af022c5c0dfcb760a31e4905b96310e8dd6c8/pydantic_core-2.50.2-cp314-cp314-win32.whl", hash = "sha256:18f008ef58cd30782f81b6e0119e20b7e884edcefbc2d370162ebe033f6e5c99", size = 2113134, upload-time = "2026-10-29T16:33:16.87Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5f/dcd3df20ab2a7279d42a8415fc184e8e77bcf9aa3c9ac3e938c1300a242c/pydantic_core-2.50.2-cp314-cp314-win_amd64.whl", hash = "sha256:b62c89545d21d930c333208fd548e7b4f262228ba3a322fd75c987a75ab11f31", size = 2253407, upload-time = "2026-10-29T16:33:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fb/0685895f5d199b215011dc4eb8ccea032a64ec23dfa88eeb5acca939c2d2/pydantic_core-2.50.2-cp314-cp314-win_arm64.whl", hash = "sha256:6e731bf5a5e8e25398544d7709e39068ec4230b0177e4db7fa955692fd415ba3", size = 2160456, upload-time = "2026-10-29T16:33:19.782Z" }, + { url = "https://files.pythonhosted.org/packages/84/2f/4c8f67efe62837a508b74cf910904f73029cb211371351c5ceb36d56e85e/pydantic_core-2.50.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:33c6800ab9563adaa0d7893e9cd8f5b972ef1e6a23df70498e05c24f3fcec5e1", size = 2217558, upload-time = "2026-10-29T16:33:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/e0ef88cfbcca5409f57751289bc20be63a58af596a36e94b8cd795e59747/pydantic_core-2.50.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f81df206b937d0dca5efb38c25fc99037d82dbcd3740e34c3f81997d2322ac1c", size = 2074335, upload-time = "2026-10-29T16:33:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/7b/cc/47844a3179ebce27064e13619594b3fc1a9b4509d8dec1e3960c5f87f5bb/pydantic_core-2.50.2-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:21ea3b00f1e87c66f3f9e92f9f6fec20495ac0e60d7d2e68ba1fc49739d7cf40", size = 2264201, upload-time = "2026-10-29T16:33:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/30e9d3c86d81884ee3199560ddfa2c1a66a8dc0736441748eae43064abbc/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9eff570402175c7d5fff8497d5a9c05580664a79bb66c07b8f205d17f69e580b", size = 2094353, upload-time = "2026-10-29T16:33:26.739Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/392a5999480d0bcab4539db0dafa93265152396fd63e87be4d24e13d6d82/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:240fec32a74af0979f7253532859b3fd209ad5eea822416bb010929dba445c3d", size = 2203119, upload-time = "2026-10-29T16:33:28.154Z" }, + { url = "https://files.pythonhosted.org/packages/cb/80/6e4e276a3fde5500224a39150e06a4f134f136165908be69402720718a36/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92acf02c770b9d5c4e9a3e8d64e9d95ae26456a86ebb7b7cf64a97f899e5871b", size = 2392881, upload-time = "2026-10-29T16:33:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/d5/5eb56c6317ab4abf33ef9519a50bfac0c96f7f408bad1bc36da6f27d43e4/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1ac5b99bc7b0a68ec64f5f75a6828a7bab5535c8df0cdc07901deb50e0815910", size = 2457290, upload-time = "2026-10-29T16:33:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f1fe9384bb37e4e728c51c79151808deacf7b2502e245f50b57c85eeb199/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:713d7c33c0e231e3786eb0ec95885814dd65d5f3a09754399b6acbf5e9365f11", size = 2241568, upload-time = "2026-10-29T16:33:32.04Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f1/cffc4f8456c35690bcb65a8f2304afbf2f519766345a28aba553d5a55cc8/pydantic_core-2.50.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:61e13100b30d8d25fc0d1f0297014b32941bf0975a3584bd08f3adebb68d1ead", size = 2257185, upload-time = "2026-10-29T16:33:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/ccd4df1cc39cb1e42fb7ec86a0dc0a23e8479de04d3012ca6b0bf2279a03/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:523fe6574aea50f456cbc301c1cdddcbf237939f7657f9af99b4f0f71942b59c", size = 2299384, upload-time = "2026-10-29T16:33:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/83/60/404b4b0c416a61867ee642cdc1450bca4d8a9a10e5f5f21e1ecaad05fdc8/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6c25b8826be85714b27dafe908d3461e1b09e3e31c0279a7029040c832c781ef", size = 2479384, upload-time = "2026-10-29T16:33:36.106Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0d/57c81418a2fdfde0578c71396df773965adebd4421d9985a0f8dcbe9ec4d/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a59db03b80a156a6fbeae41096866d1cb89cdf31031ba44c768a804538880636", size = 2504616, upload-time = "2026-10-29T16:33:37.423Z" }, + { url = "https://files.pythonhosted.org/packages/08/66/dec78e28a604f3f4985f5a795d4da3e5fef78b0921aeb7f614ed7c780a32/pydantic_core-2.50.2-cp314-cp314t-win32.whl", hash = "sha256:0d090ae76d5b09c003600c7779b3adcf3d1b49268f616c5c51d844e54c8e318d", size = 2093179, upload-time = "2026-10-29T16:33:38.705Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ea/79d9ee60648463804145d44b08f1e7e2474427d9704e3331d814483908b3/pydantic_core-2.50.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9b01a092955c1c7530196ad526755cff4f2d11f37ca9a6fc9ebe37ca49a48977", size = 2220575, upload-time = "2026-10-29T16:33:39.929Z" }, + { url = "https://files.pythonhosted.org/packages/eb/01/aa6a7bd017c3228a41942da4fd788b86fb4aad9677212a4737a0c157fe74/pydantic_core-2.50.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4ea01f30e119f1ec7a2034e85c40f491d469896aef02cd6436fd9292fe1be3fb", size = 2160866, upload-time = "2026-10-29T16:33:41.237Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7f/ec2974015b4be5e50266ce732b8942d3d0b3bff751ace8900999c6577bce/pydantic_core-2.50.2-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:a3317630e2791f56071b82f89bb71624146125e4311e6643f708c6ba744b5ac5", size = 2230936, upload-time = "2026-10-29T16:33:43.656Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/af99a09eddc348c1d0744c0df28dfcd4839ca497067f0fa2168208b41b1b/pydantic_core-2.50.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f73930dacad5e611fe019e18df6f53c30629b1843d038d303b8dec86a32493bd", size = 2100409, upload-time = "2026-10-29T16:33:44.946Z" }, + { url = "https://files.pythonhosted.org/packages/84/75/8db3c46f02d6005277e5c2ff5933c85e7e1eb8798aa334c676c828650c3f/pydantic_core-2.50.2-cp315-cp315-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:e2e6240a0a5ad847017b3d65187877fdb7a11d5a58d43c30736f9f523478913b", size = 2259728, upload-time = "2026-10-29T16:33:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9d/98c8d524921ca24e5c8a81a4b8f12209ac6ab05d0dd58b374e357da0ffc5/pydantic_core-2.50.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fdf5b52ed22f1404c2433d0b46df2d86d9befb742d0b9b4042a2325c43cdb97", size = 2121771, upload-time = "2026-10-29T16:33:47.952Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ea/68c3b1fa919946012c940dea88e94e33e7dde46abe3e3d57053793c9bab8/pydantic_core-2.50.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4e9392ddf67fe97ab9889f796d468c9908a8754b8b6c7520eb03a65012b9a1e7", size = 2196328, upload-time = "2026-10-29T16:33:49.131Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9b/cfb6312a5b65b02eab6bc00fb0d258d0e2e49468dab29b430e7cb49b2ceb/pydantic_core-2.50.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:48e944091b7bff233920bed8a60b0ed6c96c73d3c5836bf51c4b9a4bfb58e4ca", size = 2380152, upload-time = "2026-10-29T16:33:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/7fcced1d84ea294200abfa93e488f36d00732101d17bf5e1f1389e66de26/pydantic_core-2.50.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:91c92856b846aa690ea6239ffd6c5003c5e3e296a69095570bf4efee5bfa0bdc", size = 2462299, upload-time = "2026-10-29T16:33:51.884Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d0/dd0e355694094f0045922f4da60ac9bc7a314babc494e46089fb00640fd3/pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5299296ff5cafcb69851fd652de8f9268a0bb036bcbedbfcaa94cf743baf3986", size = 2249999, upload-time = "2026-10-29T16:33:53.333Z" }, + { url = "https://files.pythonhosted.org/packages/3a/79/f5f4b36434604d22aacb7152667f87a470e68847c89b136c68a278cf409d/pydantic_core-2.50.2-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d3d049b557875f9d15230eec8a731749a7a873d8ed6a051903b05bdf46e0e65f", size = 2262745, upload-time = "2026-10-29T16:33:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/a4c47a286745857758a4bb8d4d5e260c736a7bd6fab5da618f5711d1a3f1/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b0b25d647036972c2951059046cb498f72b7af5a2def27bf8e5f44c3067ab7dd", size = 2293663, upload-time = "2026-10-29T16:33:56.132Z" }, + { url = "https://files.pythonhosted.org/packages/0d/db/25330e609fd219ce16d0a089af1b4b62095f01595dbe6972f7a7dfc313b9/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:8835566b3e94026da3ce2249f9ee35c9d2ebe4cd131f6a39bfc82708d3010a46", size = 2473247, upload-time = "2026-10-29T16:33:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/53/16/704ca88b82156dc485da2dfb11d5d52a10b9ef15a294639c0aa5ebd1643e/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:3fbcd402e4b607d4b8de5d4591215f31f042af03f9d09e58f693a6221e32833f", size = 2490536, upload-time = "2026-10-29T16:33:59.297Z" }, + { url = "https://files.pythonhosted.org/packages/50/34/1fdd0eebee9f8b6c4ec3c2490f49dc59a652c8c5a7fa0cbcaada0d91cda9/pydantic_core-2.50.2-cp315-cp315-win32.whl", hash = "sha256:8431ecb131489cbdaf6f51f7ade47475f73aff4f1912552f75ba19a6cfe233a8", size = 2117204, upload-time = "2026-10-29T16:34:00.514Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/702adc91486fb96ba55675d58ee15ddc38fb48553fb35e26fd005e303206/pydantic_core-2.50.2-cp315-cp315-win_amd64.whl", hash = "sha256:04cf672960b8c4fc28bb159c8cdd101fbd311c520237a5c0360448b851adfb41", size = 2261064, upload-time = "2026-10-29T16:34:01.773Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/7f117432b8614526aa0d96fadd2b7fb7393156ddf2caf26a6fe606e6cfb6/pydantic_core-2.50.2-cp315-cp315-win_arm64.whl", hash = "sha256:36917e29c0b815d6b940bc91560fcf453b5b85d55c579796373f0ea11d1c111a", size = 2161783, upload-time = "2026-10-29T16:34:02.966Z" }, + { url = "https://files.pythonhosted.org/packages/45/1c/2ba7a34fe8da4c83ec200fb9f85ab4d54b961be08367922743623f987849/pydantic_core-2.50.2-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:bd1fe69056a19c377a6c6de2c1240fb8676ba0512a969965efb486510c5e0f1f", size = 2223184, upload-time = "2026-10-29T16:34:04.214Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/f71a8da1f53a5be1787d2a97ba6fd9adba115305c7f15ea0bf733ed8336c/pydantic_core-2.50.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9b2383933b1dd7a8c93e02f99ebfd305f1286057172483b8e6898bed3fef69d3", size = 2078174, upload-time = "2026-10-29T16:34:05.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0a/847e1f5427372692f6eb5c38261fed6b8cedf379e37d9f6134d48b9c8ee9/pydantic_core-2.50.2-cp315-cp315t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:a1b4d60bad57d6e0e7cd30cc4d1dc646d258dd6dfb7935ce76c7dd72dd1e98b0", size = 2268813, upload-time = "2026-10-29T16:34:06.733Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bb/12966dc82b5e9c347981658b52e24349b6242902b644725c00ee7bdf4267/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93bedc68c4138088ea3c042189c01d2945f530f0dcc36e1ea02df8418bd4f1cd", size = 2098331, upload-time = "2026-10-29T16:34:08.69Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/340416b7ebe664524e7ada16ad2e3e069888b168010d75bda6a502bf2bf8/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:d684ff87d00de2f6abcbbfa9b0fdce8b513dd799a900809dcfa7eb62ad69832a", size = 2197432, upload-time = "2026-10-29T16:34:10.091Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4d/425162c3d88af0d190c4dc4f940402cc6dd44d934359a37f50da6b7e4672/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bb6d49f00e2babd40af4889d6b9f4bf50879f522dd4e2e22cb705cac5e0c5e8e", size = 2399300, upload-time = "2026-10-29T16:34:11.462Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fe/2882bddf2e2038c8f41fa69f16ddeb6eeb1c002cc0cc0eb2157f73bb34e9/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6d47b2d0e00e030e0ebdb08115a37da7294e7a6d196d134a77ed7ee6c142284b", size = 2462672, upload-time = "2026-10-29T16:34:12.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/d7e6bfc6a95573647d1a699e19228dd99914ae6aab79a2bec293a038e980/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95838be125133fa314753f8cc5168f7c41e3a9dd5eef5383dbbbeed511bd4853", size = 2246293, upload-time = "2026-10-29T16:34:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/64/97473ccaeb4ae5eb338162bd8479af121484e67f7078499730e3ee0a5592/pydantic_core-2.50.2-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:2d4a043c820363359e40ac96270e1915e5376e116be1f59cee0701775de3e376", size = 2259507, upload-time = "2026-10-29T16:34:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/78a9b7a6a0d0679ce1e46cdd38ff95afd18dd2d6a01ed87c80a01bc003f7/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:57bccd3e1128736c792094da1feac6da46ec3feb1957ef649a9b3e4f8de6fa04", size = 2303550, upload-time = "2026-10-29T16:34:17.162Z" }, + { url = "https://files.pythonhosted.org/packages/39/ba/b697173f82e99cdcf5ec6654b2edb66a00f2f92ef9d9a168861fca3fcded/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:24120c644322a35b5eb765416ac8442214c45689e255f5fc6e761e7e7c743c8b", size = 2474919, upload-time = "2026-10-29T16:34:18.841Z" }, + { url = "https://files.pythonhosted.org/packages/71/10/986be76723784ae87f9664658fae39df340eaa21681cea95b6339d2e1b3e/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:b1adf2f37521b578c438f6ec61d1c1d0c20b076863ddd5d8b6f3ab658857db3f", size = 2509095, upload-time = "2026-10-29T16:34:20.16Z" }, + { url = "https://files.pythonhosted.org/packages/5f/0b/38dbd38c67de7642f87348da70122b89ca4c5fe8227cfdd5cc00c1cdb454/pydantic_core-2.50.2-cp315-cp315t-win32.whl", hash = "sha256:d79aaa468193c1bed6b0560d3c5c9f9e369e3efb0b128b38a2537e2d51d42773", size = 2096972, upload-time = "2026-10-29T16:34:21.715Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/154baf8413b08d6daf2f5dec211c303ec6bed50c032717f354f8dd85c994/pydantic_core-2.50.2-cp315-cp315t-win_amd64.whl", hash = "sha256:cc488daac19e6f5292180fac5e0ef7efffabeef4e096af9a17a75a7d3d6781f6", size = 2226659, upload-time = "2026-10-29T16:34:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/eea7a1e7d98a79ef11b6d31748b70c58f8723f87d7ee42edbb6a101f6245/pydantic_core-2.50.2-cp315-cp315t-win_arm64.whl", hash = "sha256:91315aea7adb51821d4a3378ede953bfdd76e3bf71551de4a82d7638cef7dc02", size = 2166387, upload-time = "2026-10-29T16:34:24.433Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "pytest-qt" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + +[[package]] +name = "shiboken6" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.10.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/87/a982ca5b892579397c2b155275cc0119b51feb1701eaefa18acb0245f0a4/tifffile-2026.10.15.tar.gz", hash = "sha256:a5ee666dbd6d62e059830a3450825a2521a981b3f45c07a80ab009484aef56f0", size = 454150, upload-time = "2026-10-16T00:32:10.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/bc/9e7664e2d30d4e82ada1b29dbb249d2dcac1ed1114cbbf11764f670d8be7/tifffile-2026.10.15-py3-none-any.whl", hash = "sha256:3dcf6c56731a45c1f85c81e90dad922b0a380a2d639e77f71207e010f8ef0f7b", size = 276988, upload-time = "2026-10-16T00:32:09.158Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +]