From 770a01dd435dab35d4c3ab8a4d319ff4d9bc7562 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sat, 20 Apr 2024 04:16:35 +0200 Subject: [PATCH] first release of the plugin --- .github/workflows/ci.yaml | 15 + .github/workflows/format.yaml | 25 ++ .github/workflows/lint.yaml | 25 ++ .github/workflows/tests.yaml | 23 ++ .gitignore | 9 + CHANGELOG.md | 10 + docs/README.md | 18 + docs/development.md | 10 + docs/using-the-plugin.md | 53 +++ looptrace_loci_vis/__init__.py | 3 + looptrace_loci_vis/examples/P0001.qcfail.csv | 10 + looptrace_loci_vis/examples/P0001.qcpass.csv | 10 + looptrace_loci_vis/examples/README.md | 2 + looptrace_loci_vis/napari.yaml | 16 + looptrace_loci_vis/reader.py | 383 +++++++++++++++++++ pyproject.toml | 168 ++++++++ shell.nix | 22 ++ tests/test_locus_points_smoke.py | 35 ++ 18 files changed, 837 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/format.yaml create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/tests.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 docs/README.md create mode 100644 docs/development.md create mode 100644 docs/using-the-plugin.md create mode 100644 looptrace_loci_vis/__init__.py create mode 100644 looptrace_loci_vis/examples/P0001.qcfail.csv create mode 100644 looptrace_loci_vis/examples/P0001.qcpass.csv create mode 100644 looptrace_loci_vis/examples/README.md create mode 100644 looptrace_loci_vis/napari.yaml create mode 100644 looptrace_loci_vis/reader.py create mode 100644 pyproject.toml create mode 100644 shell.nix create mode 100644 tests/test_locus_points_smoke.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..7580cfc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,15 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + uses: ./.github/workflows/tests.yaml + lint: + uses: ./.github/workflows/lint.yaml + format: + uses: ./.github/workflows/format.yaml diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml new file mode 100644 index 0000000..cac2956 --- /dev/null +++ b/.github/workflows/format.yaml @@ -0,0 +1,25 @@ +name: format + +on: [workflow_call] + +jobs: + format: + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + os: [ubuntu-22.04] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install project + run: pip install .[formatting] + - name: Run codespell + run: codespell --enable-colors + - name: Run ruff + run: ruff format --diff . \ No newline at end of file diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..806dbfa --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,25 @@ +name: lint + +on: [workflow_call] + +jobs: + lint: + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11"] + os: [ubuntu-22.04] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install project + run: pip install .[linting] + - name: Run mypy on Python ${{ matrix.python-version }} on ${{ matrix.os }} + run: mypy + - name: Run ruff on Python ${{ matrix.python-version }} on ${{ matrix.os }} + run: ruff check . diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..4a84120 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,23 @@ +name: Tests + +on: [workflow_call] + +jobs: + tests: + strategy: + fail-fast: false + matrix: + python-version: [ "3.10", "3.11" ] + os: [ ubuntu-latest, macos-latest, windows-latest, ubuntu-20.04 ] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install project + run: python -m pip install .[testsuite] + - name: Run unit tests on Python ${{ matrix.python-version }} on ${{ matrix.os }} + run: pytest tests -vv --cov diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a550073 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# build-related +build/ +*.egg-info/ +*.pyc +.venv/ +__pycache__/ + +# test-related +.coverage* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0a9cfc6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [v0.1.0] - 2024-04-20 + +### Added +* This package, first release diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..63b30d2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +## Documentation +Here you will find both a [user guide](./using-the-plugin.md) and [development documentation](./development.md). + +### Installation and environment +This project uses a Nix shell to provide a development environment. +To use this plugin, you have at least two options. + +__Nix shell (preferred)__ + +If you've installed the [Nix package manager](https://nixos.org/download/) installed, then from the this project's [root folder](../), you can simply launch the Nix shell with `nix-shell`, and you should have everything you need ready to go. +Note that in general, we use this on Mac machines with relatively new processors. +If something doesn't work for you, and especially if you're using this on different hardware, please let us know by opening a ticket on our [issue tracker](https://github.com/gerlichlab/looptrace-loci-vis/issues). +Please open a ticket even if you're able to resolve the problem yourself, as it will help us define the environment in a way that's compatible with a wider variety of hardwares. +If you'd like, you may even open a pull request to suggest an improvement to our environment definition. + +__Virtual environment (e.g. `virtualenv`)__ + +If you're not inclined to use Nix, ensure that you've installed `virtualenv` (e.g., `brew install virtualenv` on a Mac), and that you have Python available (e.g., `brew install python@3.11`). Then you may create and activate a virtual environment, into which you may install this project. Simply run `pip install .` from this project's [root folder](../). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..4733393 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,10 @@ +## Development +First, please refer to the general [installation and environment](./README.md#installation-and-environment). + +By default, with `nix-shell`, you should have all the dependencies you'll need not only to _use_ this plugin, but also to _develop_ on it. +In other words, dependencies to do things like run tests, run linter(s), and run type checking should all be provided. +If you try and that's not the case, please check the [Issue Tracker](https://github.com/gerlichlab/looptrace-loci-vis/issues). +If an issue's open for what you're experiencing, please upvote the initial description of the issue and/or comment on the ticket. + +### Testing, formatting, and linting +The various tests run against the project can be found in the [GitHub actions workflows](../.github/workflows/). diff --git a/docs/using-the-plugin.md b/docs/using-the-plugin.md new file mode 100644 index 0000000..4af111c --- /dev/null +++ b/docs/using-the-plugin.md @@ -0,0 +1,53 @@ +## Viewing locus spots +First, check the general information about [installation and environment](./README.md#installation-and-environment). + +### Quickstart +To visualise the locus spots, you need to __drag-and-drop a _folder___ into an active Napari window. This folder will have been produced directly by `looptrace`, or you'll create it by moving files created by `looptrace`. + +That folder must... +* be named like `PXXXX`, where `XXXX` is the 4-digit representation of the 1-based field of view from which data came, with the 4 digits being left-padded with 0. +* contain __3 items__: + * ZARR (folder) array with name matching the folder it's in (up to the `.zarr` extension) + * `*.qcfail.csv`: CSV file with coordinate of spot fit centers, and column encoding QC fail reasons + * `*.qcpass.csv`: same as QC fail file, but without column for failures + +These properties should be entirely or nearly satisfied by a run of `looptrace`. +At most, only these steps should be required to prepare the data: +1. Create a new folder, ideally just locally on your desktop, to organise the data. +1. Within that folder, create a subfolder named like `PXXXX` for each FOV you want to visualised. +1. Copy, for each FOV you want to view, the ZARR and QC pass/fail CSV files from the locus spot images visualisation folder made by `looptrace`, into the corresponding FOV subfolder. + +For the image layer, you'll want to select "continuous" rather than "once" for the "auto-contrast" setting in the upper-left area of the Napari window. For each field of view, you'll need to clear the layers from the active Napari window, then drag in the next folder. + +### What you should see / notice +* __Sliders__: A Napari window with three sliders (trace ID on bottom, timepoint in middle, $z$ slice on top) should be displayed. +* __Colors__: Yellow indicates a locus spot that passed all QC filters, and blue indicates that the spot failed at least one QC filter. +* __Sizes and Shapes__: A larger star indicates that you're viewing the $z$-slice corresponding to the truncation-toward-0 of the $z$-coordinate of the centroid of the Gaussian fit to the locus spot pixels. A smaller point/circle is shown when you're in a non-central $z$-slice. Text is shown for QC-fail points +* __Text__: Text labels encode [QC failure reasons](#qc-failure-codes). + +### QC failure codes +* `O`: "*O*ut-of-bounds" -- the locus point's center is literally outside the bounding box, or within one standard deviation of its fit to at least one of the boundaries +* `R`: "far from *R*egion center" -- the locus spot's center is too far from its region barcode's spot's center +* `S`: "*S*ignal-to-noise (too low)" -- the fit's signal-to-noise ratio is too low. +* `xy`: The fit's too diffuse in `xy`. +* `z`: The fit's too diffuse in `z`. + +### Details and troubleshooting +* Each relevant file, regardless of which kind of data are inside, should have a basename like `PXXXX`, where `XXXX` corresponds to the 1-based integer index of the field of view, left-padded with zeroes, e.g. `P0001`. +* Each image file should have a `.zarr` extension. +* Each points file should have a `.qc(pass|fail).csv` extension. +* Each `.zarr` should either have a `.zarray` immediately inside it, or have a single `0` subfolder which has a `.zarray` inside it. +* Each points file should have __NO HEADER__. See [the examples](../looptrace_loci_vis/examples/). +* Ensure that "continuous" rather than "once" is selected for the "auto-contrast" setting in the upper-left area of the Napari window. + +### Frequently asked questions (FAQ) +1. __Why blue and yellow as the colors for QC-fail and QC-pass (respectively) spots?__\ + This pair of colors is relatively tends to be seen roughly the same (or at least to maintain sharp contrast) by a colorblind person as for a non-colorblind person. +1. __Why are the layers named the same (e.g., P0001[2], P0001[1], P0001s) but with little boxed numbers?__\ + The name for each layer comes from the file or folder represented (in the case of this plugin, corresponding to the field of view of the data). The bracketed numbers just distinguish the layers but may be regarded as arbitrary. +1. __Why are some points a different shape or size?__\ + A star is represents the center of the locus point's Gaussian fit when viewing the $z$-slice corresponding to the truncated-toward-0 $z$-coordinate of the center of that fit. In all other slices/planes, a circle/dot is used instead of a star. +1. __Why are some slices entirely empty?__\ + This can happen when the bounding box for the region went outside actual image boundaries, resulting in a case of unextractable data. +1. __Why do some slices lack a point at all?__\ + This can happen when the center of the Gaussian fit to the pixel volume's data lies outside that volume ($z$-stack / rectangular prism). diff --git a/looptrace_loci_vis/__init__.py b/looptrace_loci_vis/__init__.py new file mode 100644 index 0000000..f16f268 --- /dev/null +++ b/looptrace_loci_vis/__init__.py @@ -0,0 +1,3 @@ +"""Napari plugin for visualising locus-specific points from looptrace""" + +__version__ = "0.1.0" diff --git a/looptrace_loci_vis/examples/P0001.qcfail.csv b/looptrace_loci_vis/examples/P0001.qcfail.csv new file mode 100644 index 0000000..3b1e1ad --- /dev/null +++ b/looptrace_loci_vis/examples/P0001.qcfail.csv @@ -0,0 +1,10 @@ +0,13,5.880338307654485,12.20211975317036,10.728294496728491,S +0,17,10.594366532607864,10.95875680073854,20.711938561802768,R;S;xy;z +0,47,10.198132167665957,14.398450914314138,15.378219719077295,R +0,48,0.7173811741523715,13.999908344240598,2.011625563698183,R;z +0,49,6.274792365451074,14.440034853392085,8.81613597404698,S +0,69,10.03907938905064,18.453449673327853,7.594187495036839,R;S;xy;z +0,71,8.157075382512406,12.780500232574296,3.1916456736466685,R +0,74,7.03360935199292,15.324188332927145,3.6995859572616823,xy +0,76,2.426576702500344,20.546530442060508,2.151493771689803,R;S;xy;O +0,77,6.0415531254567885,13.910733825016758,10.238202728231837,S diff --git a/looptrace_loci_vis/examples/P0001.qcpass.csv b/looptrace_loci_vis/examples/P0001.qcpass.csv new file mode 100644 index 0000000..0cf7113 --- /dev/null +++ b/looptrace_loci_vis/examples/P0001.qcpass.csv @@ -0,0 +1,10 @@ +0,1,5.81567065413679,19.474750228527004,12.175479170066252 +0,2,4.127040930402973,16.836316423238255,16.211982617584344 +0,3,4.694554107453964,16.789722139429465,12.25402999243208 +0,4,4.813168873918884,15.256065437706804,17.11569530869283 +0,5,4.548093164740155,14.213239208919436,12.147029706673518 +0,6,5.541487916309182,16.87867172260054,11.99790265928896 +0,7,5.363507239961485,12.895567140315253,11.943658590602544 +0,8,6.516566467869967,11.700423462604856,13.31509301523442 +0,9,5.419675410652808,13.538758567521947,11.329204727566513 +0,10,6.9955481900291785,11.640001066231846,11.540667655397932 diff --git a/looptrace_loci_vis/examples/README.md b/looptrace_loci_vis/examples/README.md new file mode 100644 index 0000000..826604f --- /dev/null +++ b/looptrace_loci_vis/examples/README.md @@ -0,0 +1,2 @@ +## Examples +This folder contains examples of some of the relevant files for using this plugin. \ No newline at end of file diff --git a/looptrace_loci_vis/napari.yaml b/looptrace_loci_vis/napari.yaml new file mode 100644 index 0000000..9574491 --- /dev/null +++ b/looptrace_loci_vis/napari.yaml @@ -0,0 +1,16 @@ +name: looptrace-loci-vis +contributions: + commands: + - id: looptrace-loci-vis.read_looptrace_locus_points + title: Read looptrace locus-specific image points data and overlays + python_name: looptrace_loci_vis.reader:get_reader + readers: + - command: looptrace-loci-vis.read_looptrace_locus_points + filename_patterns: + # - '*.qcfail.csv' + # - '*.qcpass.csv' + # - '*.qc_fail.csv' + # - '*.qc_pass.csv' + #accepts_directories: false + - 'P*' + accepts_directories: true diff --git a/looptrace_loci_vis/reader.py b/looptrace_loci_vis/reader.py new file mode 100644 index 0000000..acd9199 --- /dev/null +++ b/looptrace_loci_vis/reader.py @@ -0,0 +1,383 @@ +"""Reading locus-specific spots and points data from looptrace for visualisation in napari""" + +import csv +import dataclasses +import logging +import os +from collections.abc import Callable +from enum import Enum +from math import floor +from pathlib import Path +from typing import Literal, Optional, Union + +import numpy as np +from gertils.geometry import ImagePoint3D, LocatableXY, LocatableZ, ZCoordinate +from gertils.pathtools import find_multiple_paths_by_fov, get_fov_sort_key +from gertils.types import FieldOfViewFrom1, PixelArray +from gertils.types import TimepointFrom0 as Timepoint +from gertils.types import TraceIdFrom0 as TraceId +from gertils.zarr_tools import read_zarr +from numpydoc_decorator import doc # type: ignore[import-untyped] + +__author__ = "Vince Reuter" +__credits__ = ["Vince Reuter"] + +CsvRow = list[str] +FlatPointRecord = list[Union[float, ZCoordinate]] +LayerParams = dict[str, object] +ImageLayer = tuple[PixelArray, LayerParams, Literal["image"]] +PointsLayer = tuple[list[FlatPointRecord], LayerParams, Literal["points"]] +QCFailReasons = str +PathLike = str | Path +PathOrPaths = PathLike | list[PathLike] +Reader = Callable[[PathLike], list[ImageLayer | PointsLayer]] + +# See: https://davidmathlogic.com/colorblind/ +DEEP_SKY_BLUE = "#0C7BDC" +GOLDENROD = "#FFC20A" + + +class QCStatus(Enum): + """Binary classification of QC status""" + + FAIL = "fail" + PASS = "pass" # noqa: S105 + + @classmethod + def from_csv_name(cls, fn: str) -> Optional["QCStatus"]: + """Try to determine CSV status from given name of CSV file.""" + for qc in cls: + if fn.endswith(qc.filename_extension): + return qc + return None + + @classmethod + def from_csv_path(cls, fp: PathLike) -> Optional["QCStatus"]: + """Try to determine CSV status from given path of CSV file.""" + return cls.from_csv_name(os.path.basename(fp)) # noqa: PTH119 + + @property + def filename_extension(self) -> str: # noqa: D102 + return f".qc{self.value}.csv" + + +def do_not_parse(*, path: PathLike, why: str, level: int = logging.DEBUG) -> None: + """Log a message about why a path can't be parsed.""" + logging.log( + level, + "%s, cannot be read as looptrace locus-specific points: %s", + why, + path, + ) + + +@doc( + summary="Read and display locus-specific spots from looptrace.", + parameters=dict(path="Path from which to parse layers"), + raises=dict(RuntimeError="If collection of QC statuses inferred from files doesn't make sense"), + returns="A function to create layers from folder path, if folder path is parsable", +) +def get_reader(path: PathOrPaths) -> Optional[Reader]: # noqa: D103 + if not isinstance(path, str | Path): + return None + if not os.path.isdir(path): # noqa: PTH112 + do_not_parse(path=path, why="Not a folder/directory") + return None + path_by_fov: dict[FieldOfViewFrom1, list[Path]] = find_multiple_paths_by_fov( + path, extensions=(".zarr", *(qc.filename_extension for qc in QCStatus)) + ) + if len(path_by_fov) != 1: + do_not_parse( + path=path, why=f"Not exactly 1 FOV found, but rather {len(path_by_fov)}, found" + ) + return None + fov, files = next(iter(path_by_fov.items())) + if len(files) != 3: # noqa: PLR2004 + do_not_parse( + path=path, why=f"Not exactly 3 files, but rather {len(files)}, found for {fov}" + ) + return None + path_by_status = {} + for fp in files: + qc = QCStatus.from_csv_path(fp) + if qc is not None: + path_by_status[qc] = fp + try: + fail_path = path_by_status.pop(QCStatus.FAIL) + pass_path = path_by_status.pop(QCStatus.PASS) + except KeyError: + do_not_parse(path=path, why="Could not find 1 each of QC status (pass/fail)") + return None + if len(path_by_status) != 0: + raise RuntimeError(f"Extra QC status/path pairs! {path_by_status}") + left_to_match = [f for f in files if f not in [fail_path, pass_path]] + if len(left_to_match) != 1: + raise RuntimeError( + f"Nonsense! After finding 2 QC files among 3 files of interest, only 1 should remain but got {len(left_to_match)}: {left_to_match}" + ) + potential_zarr = left_to_match[0] + if ( + potential_zarr.suffix != ".zarr" + or get_fov_sort_key(potential_zarr, extension=".zarr") != fov + ): + do_not_parse(path=path, why=f"Could not find ZARR for FOV {fov}") + return None + + def parse(_): # type: ignore[no-untyped-def] # noqa: ANN202 ANN001 + image_layer: ImageLayer = (read_zarr(potential_zarr), {}, "image") + failures_layer: PointsLayer = build_single_file_points_layer(fail_path) + successes_layer: PointsLayer = build_single_file_points_layer(pass_path) + return [image_layer, failures_layer, successes_layer] + + return parse + + +def build_single_file_points_layer(path: PathLike) -> PointsLayer: + """Build the parser for a single file (ZARR or CSV relevant for locus points viewing).""" + static_params = { + "edge_width": 0.1, + "edge_width_is_relative": True, + "n_dimensional": False, + } + + # Determine how to read and display the points layer to be parsed. + qc = QCStatus.from_csv_path(path) + if qc == QCStatus.PASS: + logging.debug("Will parse sas QC-pass: %s", path) + color = GOLDENROD + read_rows = parse_passed + elif qc == QCStatus.FAIL: + logging.debug("Will parse as QC-fail: %s", path) + color = DEEP_SKY_BLUE + read_rows = parse_failed + else: + do_not_parse(path=path, why="Could not infer QC status", level=logging.ERROR) + raise ValueError( + f"Despite undertaking parse, file from which QC status could not be parsed was encountered: {path}" + ) + + base_meta = {"edge_color": color, "face_color": color} + + with open(path, newline="") as fh: # noqa: PTH123 + rows = list(csv.reader(fh)) + point_records, center_flags, extra_meta = read_rows(rows) + if not point_records: + logging.warning("No data rows parsed!") + shape_meta = { + "symbol": ["*" if is_center else "o" for is_center in center_flags], + } + params = {**static_params, **base_meta, **extra_meta, **shape_meta} + + return [pt_rec.flatten() for pt_rec in point_records], params, "points" + + +@doc( + summary="Parse records from points which passed QC.", + parameters=dict(rows="Records to parse"), + returns=""" + A pair in which the first element is the array-like of points coordinates, + and the second element is the mapping from attribute name to list of values (1 per point). + """, + notes="https://napari.org/stable/plugins/guides.html#layer-data-tuples", +) +def parse_passed( # noqa: D103 + rows: list[CsvRow], +) -> tuple[list["PointRecord"], list[bool], LayerParams]: + records = [parse_simple_record(r, exp_num_fields=5) for r in rows] + max_z = max(r.get_z_coordinate() for r in records) + points: list["PointRecord"] = [] + center_flags: list[bool] = [] + for rec in records: + new_points, new_flags = expand_along_z(rec, z_max=max_z) + points.extend(new_points) + center_flags.extend(new_flags) + sizes = [1.5 if is_center else 1.0 for is_center in center_flags] + return points, center_flags, {"size": sizes} + + +@doc( + summary="Parse records from points which failed QC.", + parameters=dict(rows="Records to parse"), + returns=""" + A pair in which the first element is the array-like of points coordinates, + and the second element is the mapping from attribute name to list of values (1 per point). + """, + notes="https://napari.org/stable/plugins/guides.html#layer-data-tuples", +) +def parse_failed( # noqa: D103 + rows: list[CsvRow], +) -> tuple[list["PointRecord"], list[bool], LayerParams]: + record_qc_pairs: list[tuple[PointRecord, QCFailReasons]] = [] + for row in rows: + try: + qc = row[InputFileColumn.QC.get] + rec = parse_simple_record(row, exp_num_fields=6) + except IndexError: + logging.exception("Bad row: %s", row) + raise + record_qc_pairs.append((rec, qc)) + max_z = max(r.get_z_coordinate() for r, _ in record_qc_pairs) + points: list["PointRecord"] = [] + center_flags: list[bool] = [] + codes: list[QCFailReasons] = [] + for rec, qc in record_qc_pairs: + new_points, new_flags = expand_along_z(rec, z_max=max_z) + points.extend(new_points) + center_flags.extend(new_flags) + codes.extend([qc] * len(new_points)) + params = { + "size": 0, # Make the point invisible and just use text. + "text": { + "string": "{failCodes}", + "color": DEEP_SKY_BLUE, + }, + "properties": {"failCodes": codes}, + } + return points, center_flags, params + + +@doc( + summary="Parse single-point from a single record (e.g., row from a CSV file).", + parameters=dict( + r="Record (e.g. CSV row) to parse", + exp_num_fields=("The expected number of data fields (e.g., columns) in the record"), + ), + returns=""" + A pair of values in which the first element represents a locus spot's trace ID and timepoint, + and the second element represents the (z, y, x) coordinates of the centroid of the spot fit. + """, +) +def parse_simple_record(r: CsvRow, *, exp_num_fields: int) -> "PointRecord": + """Parse a single line from an input CSV file.""" + if not isinstance(r, list): + raise TypeError(f"Record to parse must be list, not {type(r).__name__}") + if len(r) != exp_num_fields: + raise ValueError(f"Expected record of length {exp_num_fields} but got {len(r)}: {r}") + trace = TraceId(int(r[InputFileColumn.TRACE.get])) + timepoint = Timepoint(int(r[InputFileColumn.TIMEPOINT.get])) + z = float(r[InputFileColumn.Z.get]) + y = float(r[InputFileColumn.Y.get]) + x = float(r[InputFileColumn.X.get]) + point = ImagePoint3D(z=z, y=y, x=x) + return PointRecord(trace_id=trace, timepoint=timepoint, point=point) + + +@doc( + summary="", + parameters=dict( + trace_id="ID of the trace with which the locus spot is associated", + timepoint="Imaging timepoint in from which the point is coming", + point="Coordinates of the centroid of the Gaussian fit to the spot image pixel data", + ), +) +@dataclasses.dataclass(frozen=True, kw_only=True) +class PointRecord(LocatableXY, LocatableZ): # noqa: D101 + trace_id: TraceId + timepoint: Timepoint + point: ImagePoint3D + + def __post_init__(self) -> None: + bads: dict[str, object] = {} + if not isinstance(self.trace_id, TraceId): + bads["trace ID"] = self.trace_id # type: ignore[unreachable] + if not isinstance(self.timepoint, Timepoint): + bads["timepoint"] = self.timepoint # type: ignore[unreachable] + if not isinstance(self.point, ImagePoint3D): + bads["point"] = self.point # type: ignore[unreachable] + if bads: + messages = "; ".join(f"Bad type ({type(v).__name__}) for {k}" for k, v in bads.items()) + raise TypeError(f"Cannot create point record: {messages}") + + @doc(summary="Flatten") + def flatten(self) -> FlatPointRecord: + """Create a simple list of components, as a row of layer data.""" + return [ + self.trace_id.get, + self.timepoint.get, + self.get_z_coordinate(), + self.get_y_coordinate(), + self.get_x_coordinate(), + ] + + def get_x_coordinate(self) -> float: # noqa: D102 + return self.point.x + + def get_y_coordinate(self) -> float: # noqa: D102 + return self.point.y + + def get_z_coordinate(self) -> ZCoordinate: # noqa: D102 + return self.point.z + + @doc(summary="Round point position to nearest z-slice") + def with_truncated_z(self) -> "PointRecord": # noqa: D102 + new_z: int = floor(self.get_z_coordinate()) + result: PointRecord = self.with_new_z(new_z) + return result + + @doc( + summary="Replace this instance's point with a copy with updated z.", + parameters=dict(z="New z-coordinate value"), + ) + def with_new_z(self, z: int) -> "PointRecord": # noqa: D102 + pt = ImagePoint3D(x=self.point.x, y=self.point.y, z=z) + return dataclasses.replace(self, point=pt) + + +@doc( + summary="Create ancillary points from main point", + parameters=dict( + r="The record to expand along z-axis", + z_max="The maximum z-coordinate", + ), + returns=""" + List of layer data rows to represent the original point along + entire length of z-axis, paired with flag for each row + indicating whether it's true center or not + """, +) +def expand_along_z( # noqa: D103 + r: PointRecord, *, z_max: Union[float, np.float64] +) -> tuple[list[PointRecord], list[bool]]: + if not isinstance(z_max, int | float | np.float64): + raise TypeError(f"Bad type for z_max: {type(z_max).__name__}") + + r = r.with_truncated_z() + z_center = int(r.get_z_coordinate()) + z_max = int(floor(z_max)) + if not isinstance(z_center, int) or not isinstance(z_max, int): + raise TypeError( + f"Z center and Z max must be int; got {type(z_center).__name__} and" + f" {type(z_max).__name__}" + ) + + # Check that max z and center z make sense together. + if z_max < z_center: + raise ValueError(f"Max z must be at least as great as central z ({z_center})") + + # Build the records and flags of where the center in z really is. + predecessors = [(r.with_new_z(i), False) for i in range(z_center)] + successors = [(r.with_new_z(i), False) for i in range(z_center + 1, z_max + 1)] + points, params = zip(*[*predecessors, (r, True), *successors], strict=False) + + # Each record should give rise to a total of 1 + z_max records, since numbering from 0. + if len(points) != 1 + z_max: + raise RuntimeError( + f"Number of points generated from single spot center isn't as expected! Point={r}, z_max={z_max}, len(points)={len(points)}" + ) + return points, params # type: ignore[return-value] + + +class InputFileColumn(Enum): + """Indices of the different columns to parse as particular fields""" + + TRACE = 0 + TIMEPOINT = 1 + Z = 2 + Y = 3 + X = 4 + QC = 5 + + @property + def get(self) -> int: + """Alias for the value of this enum member""" + return self.value diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2928517 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,168 @@ +[project] +name = "looptrace-loci-vis" +dynamic = ["version"] +requires-python = ">= 3.10, < 3.12" +authors = [ + {name = "Vince Reuter"} +] +description = "This project facilitates viewing, in `napari`, locus-specific spots from `looptrace`." +readme = "README.md" +license = {file = "LICENSE"} +keywords = ["visualisation", "bioinformatics", "chromatin", "napari", "FISH"] +classifiers = [ + "Development Status :: 1 - Planning", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", +] +dependencies = [ + "gertils @ git+https://github.com/gerlichlab/gertils@v0.4.4", + "napari >= 0.4.19; sys_platform == 'darwin'", + "napari[all] >= 0.4.19; sys_platform != 'darwin'", + "numpy", + "numpydoc_decorator @ git+https://github.com/vreuter/numpydoc_decorator@v2.2.1", + "pandas", + "pyqt6 >= 6.5.0; sys_platform == 'darwin'", + "zarr >= 2.4.12", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project.urls] +Homepage = "https://github.com/gerlichlab/looptrace-loci-vis" +Repository = "https://github.com/gerlichlab/looptrace-loci-vis.git" +Issues = "https://github.com/gerlichlab/looptrace-loci-vis/issues" + +[tool.setuptools.dynamic] +version = { attr = "looptrace_loci_vis.__version__" } + +[tool.setuptools.package-data] +looptrace_loci_vis = [ "napari.yaml", "examples/*.csv" ] + +[project.entry-points."napari.manifest"] +looptrace-loci-vis = "looptrace_loci_vis:napari.yaml" + +[project.optional-dependencies] +formatting = [ + "codespell >= 2.2.4", + "ruff >= 0.3", +] +linting = [ + "mypy >= 1.0.1", + "pandas-stubs", + "ruff >= 0.3", +] +testsuite = [ + "pytest >= 7.1.0", + "pytest-cov >= 5.0.0", +] + +[tool.codespell] +skip = ".git,.mypy_cache,.nox,.vscode,__pycache__,poetry.lock" +builtin = "clear,rare,informal,usage,code,names" +ignore-words-list = "jupyter,iff" +check-filenames = true +uri-ignore-words-list = "*" + +[tool.mypy] +files = ['looptrace_loci_vis/*.py'] +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +warn_unreachable = true + +[[tool.mypy.overrides]] +module = "looptrace_loci_vis.*" +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_any_unimported = true +disallow_any_explicit = true +disallow_any_generics = true +disallow_subclassing_any = true +implicit_optional = false +strict_optional = true + +[tool.ruff] +# Black uses line-length = 88, but allows exceptions when breaking the line +# would lead to other rule violations. Use 100 as a maximum hard limit: +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # Missing members + "ANN10", # Deprecated: Missing type annotation for self/cls in (class)method + "D105", # Missing docstring in magic method + "ANN204", # Missing return type annotation for special method + + # Opinionated syntax + "D203", # Ignore this to instead opt for class body or docstring to possibly start right away under class. + "D213", # Ignore this to instead opt for summary part of docstring to be on first physical line. + "TRY003", # Avoid specifying long messages outside the exception class + "C408", # Unnecessary `dict` call (rewrite as a literal) + "EM", # Exception must not use a (f-)string literal, assign to variable first + "FIX002", # Line contains TODO, consider resolving the issue + "D400", # First line should end with a period + "D415", # First line should end with a period, question mark, or exclamation point + "N818", # Exception should be named with an Error suffix + "PLR0913", # Too many arguments in function definition + "D401", # First line of docstring should be in imperative mood + + # Imports and type annotations + "ANN003", # Missing type annotation for kwargs + "FA100", # Missing `from __future__ import annotations`, but uses `typing.*` + "TCH002", # Move third-party import `...` into a type-checking block + "TCH003", # Move standard library import `...` into a type-checking block + "UP007", # Use `X | Y` for type annotations + + # Ruff recommends avoiding these checks when using `ruff format`. Since + # `ruff format` is a drop-in replacement for `black`, we avoid the same + # checks here (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + # has more details): + "W191", # tab-indentation + "E111", # indentation-with-invalid-multiple + "E114", # indentation-with-invalid-multiple-comment + "E117", # over-indented + "D206", # indent-with-spaces + "D300", # triple-single-quotes + "Q000", # bad-quotes-inline-string + "Q001", # bad-quotes-multiline-string + "Q002", # bad-quotes-docstring + "Q003", # avoidable-escaped-quote + "COM812", # missing-trailing-comma + "COM819", # prohibited-trailing-comma + "ISC001", # single-line-implicit-string-concatenation + "ISC002", # multi-line-implicit-string-concatenation + "E501", # line-too-long + + # Other + "PLR0912", # too many branches (of a conditional) + "TD002", # missing author in TODO + "C901", # function too complex + "PLR0911", # too many return statements +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + # Ignore some groups of checks in our test code. + "ANN", # Type annotations + "D10", # Missing docstrings + "TCH00", # Imports and type checking blocks + "INP001", # File `...` is part of an implicit namespace package. Add an `__init__.py`. + + # Ignore some more specific checks in our test code. + "D401", # First line of docstring should be in imperative mood + "N802", # Function name `...` should be lowercase + "S101", # Use of `assert` detected +] + diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..f641dd4 --- /dev/null +++ b/shell.nix @@ -0,0 +1,22 @@ +{ + pkgs ? import (builtins.fetchGit { + url = "https://github.com/NixOS/nixpkgs/"; + ref = "refs/tags/23.11"; + }) {}, + dev ? true, +}: +let + pyenv = pkgs.python311.withPackages (pp: with pp; [ pip wheel ]); + pipInstallExtras = if dev then "\"[formatting,linting,testsuite]\"" else ""; +in +pkgs.mkShell { + name = "looptrace-loci-vis-env"; + buildInputs = [ pyenv ]; + shellHook = '' + [[ -d .venv ]] || python3.11 -m venv .venv + source .venv/bin/activate + installcmd='pip install -e .${pipInstallExtras}' + echo "Running installation command: $installcmd" + eval "$installcmd" + ''; +} diff --git a/tests/test_locus_points_smoke.py b/tests/test_locus_points_smoke.py new file mode 100644 index 0000000..851afd4 --- /dev/null +++ b/tests/test_locus_points_smoke.py @@ -0,0 +1,35 @@ +"""Smoketests for locus-specific points""" + +from math import ceil + +import pytest + +from looptrace_loci_vis.reader import parse_failed + +FAIL_LINES_SAMPLE = """0,13,5.880338307654485,12.20211975317036,10.728294496728491,S +0,17,10.594366532607864,10.95875680073854,20.711938561802768,R;S;xy;z +0,47,10.198132167665957,14.398450914314138,15.378219719077295,R +0,48,0.7173811741523715,13.999908344240598,2.011625563698183,R;z +0,49,6.274792365451074,14.440034853392085,8.81613597404698,S +0,69,10.03907938905064,18.453449673327853,7.594187495036839,R;S;xy;z +0,71,8.157075382512406,12.780500232574296,3.1916456736466685,R +0,74,7.03360935199292,15.324188332927145,3.6995859572616823,xy +0,76,2.426576702500344,20.546530442060508,2.151493771689803,R;S;xy;O +0,77,6.0415531254567885,13.910733825016758,10.238202728231837,S +""" + + +@pytest.mark.parametrize("keep_line_ends", [False, True]) +def test_failed_sample_line_count(keep_line_ends): + lines = FAIL_LINES_SAMPLE.splitlines(keepends=keep_line_ends) + exp_line_count = 10 + assert len(lines) == exp_line_count, f"Expected {exp_line_count} lines but got {len(lines)}" + z_field = 2 + obs_z_ceil = ceil(max(float(l.split(",")[z_field]) for l in lines)) # noqa: E741 + exp_z_ceil = 11 + assert obs_z_ceil == exp_z_ceil, f"Expected max Z of {exp_z_ceil} but got {obs_z_ceil}" + exp_record_count = exp_z_ceil * exp_line_count + records, _, _ = parse_failed([l.split(",") for l in lines]) # noqa: E741 + assert ( + len(records) == exp_record_count + ), f"Expected {exp_record_count} records but got {len(records)}"