Skip to content

Commit

Permalink
Merge pull request #10 from gerlichlab/vr/v2-more-granular-partition-…
Browse files Browse the repository at this point in the history
…of-spots

More granular spots partition, v0.2.0
  • Loading branch information
vreuter authored Oct 10, 2024
2 parents 210a22d + 6fe1847 commit b81adc9
Show file tree
Hide file tree
Showing 22 changed files with 467 additions and 570 deletions.
91 changes: 2 additions & 89 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,99 +8,12 @@ on:
workflow_dispatch:

jobs:
# See: https://hynek.me/articles/ditch-codecov-python/
test-with-coverage:
pytest:
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11" ]
os: [ ubuntu-latest ]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install project
run: python -Im pip install .[coverage,testsuite]
- name: Test and measure coverage, on Python ${{ matrix.python-version }} on ${{ matrix.os }}
run: coverage run --omit=tests/*.py --data-file=.coverage.$(echo ${{ matrix.python-version }} | tr -d .) -m pytest -vv
- name: Upload coverage data
uses: actions/upload-artifact@v4
with:
# To be kept in sync with combiner step to follow
name: coverage-data-${{ matrix.python-version }}
path: .coverage.*
if-no-files-found: ignore

# See: https://hynek.me/articles/ditch-codecov-python/
report-coverage:
name: Combine, report, and check coverage
needs: test-with-coverage # To be kept in sync with name of coverage-emitting job
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
# Only need to RUN with one Python here, and it shouldn't really matter which one.
python-version: "3.11"
cache: pip
- run: python -Im pip install coverage
- uses: actions/download-artifact@v4
with:
# To be kept in sync with upload/emission step already done
pattern: coverage-data-*
merge-multiple: true
- name: Combine coverage & fail if it's <100%.
run: |
python -Im coverage combine
#python -Im coverage html --skip-covered --skip-empty
python -Im coverage html
# Report and write to summary.
python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
# Report and write to JSON for parsing for badge.
# See: https://github.com/nedbat/coveragepy/blob/8ab9ff17409e3f6f3f5f2c0076d8b3250e8da4a0/coverage/jsonreport.py#L62-L67
python -Im coverage json --pretty-print
export TOTAL_COVERAGE_PERCENTAGE=$(python -c "import json;print(json.load(open('coverage.json'))['totals']['percent_covered_display'])")
export COVER_STATEMENTS=$(python -c "import json;print(json.load(open('coverage.json'))['totals']['covered_lines'])")
export TOTAL_STATEMENTS=$(python -c "import json;print(json.load(open('coverage.json'))['totals']['num_statements'])")
echo "cov_pct=$TOTAL_COVERAGE_PERCENTAGE" >> $GITHUB_ENV
echo "covered_statement_count=$COVER_STATEMENTS" >> $GITHUB_ENV
echo "total_statement_count=$TOTAL_STATEMENTS" >> $GITHUB_ENV
# Report again and fail if under 100%.
python -Im coverage report --fail-under=100
- name: Upload HTML report if check failed.
uses: actions/upload-artifact@v4
with:
name: html-report
path: htmlcov
if: ${{ failure() }}

# See: https://nedbatchelder.com/blog/202209/making_a_coverage_badge.html
- name: Create coverage status badge
uses: schneegans/[email protected]
with:
auth: ${{ secrets.GIST_BADGE_SECRET }}
gistID: ce02631685f3de397aee6ec83036cf34
filename: cov_badge__looptrace-regionals-vis.json
label: Coverage
message: ${{ env.cov_pct }}% (${{ env.covered_statement_count }} of ${{ env.total_statement_count }})
minColorRange: 50
maxColorRange: 90
valColorRange: ${{ env.cov_pct }}

other-tests:
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11" ]
os: [ macos-latest, ubuntu-20.04 ]
os: [ macos-latest, ubuntu-latest ]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ 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.2.0] - 2024-10-11

### Changed
* Patterns expected for input files:
* `*_rois.merge_contributors.csv`
* `*_rois.proximity_rejected.csv`
* `*_rois.proximity_accepted.nuclei_labeled.csv`
* Visualising spots as discrete (nonoverlapping) sets
* Visualising 5 sets of spots:
* Merge contributors
* Proximity rejects (spots too close together)
* Nuclear rejects (spots not in a nucleus)
* Accepted singletons
* Accepted mergers

## [v0.1.0] - 2024-04-28

### Added
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# looptrace-regionals-vis
[![CI - Test](https://github.com/gerlichlab/looptrace-regionals-vis/actions/workflows/tests.yaml/badge.svg)](https://github.com/gerlichlab/looptrace-regionals-vis/actions/workflows/tests.yaml)
![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/vreuter/ce02631685f3de397aee6ec83036cf34/raw/cov_badge__looptrace-regionals-vis.json)
[![Formatting](https://github.com/gerlichlab/looptrace-regionals-vis/actions/workflows/format.yaml/badge.svg)](https://github.com/gerlichlab/looptrace-regionals-vis/actions/workflows/format.yaml)
[![Linting](https://github.com/gerlichlab/looptrace-regionals-vis/actions/workflows/lint.yaml/badge.svg)](https://github.com/gerlichlab/looptrace-regionals-vis/actions/workflows/lint.yaml)

Expand Down
27 changes: 11 additions & 16 deletions docs/using-the-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,20 @@ The Napari window should have three sliders:
To support the possibility of detecting and visualising spots in multiple channels, we retain data from all imaging channels. We also keep all imaging timepoints for the moment (not just those with regional spot detection), though that may change.

The spots are color-coded:
* Indigo: all detected spots
* Pale sky blue: spots which passed proximity-based filtration
* Pale red clay: spots which passed proximity-based and nuclei-based filtration
* _Blue_: merge contributor
* _Purple_: discarded on account of being too close together
* _Orange_: discarded on account of not being in a nucleus
* _Pink_: individual (unmerged), and retained
* _Yellow_: resulting from a merge, and retained

Spot shape differs by $z$-slice; the $z$-slice closest to the truncated $z$-coordinate of the spot's centroid will be square while everywhere else will be circular. We do not add $z$ slices for spots for which the bounding box extends below $0$, but there can be $z$ slices "beyond" the true images, if a spot was detected close to the max $z$ depth. This may also change in a future release.

If you see no spots of a certain color even when you're viewing a timepoint in which regional spots were detected, it's possible that there really are no spots of the level of filtration corresponding to the color you're expecting to see (but not seeing); it's _more likely_, though, that the layers are just in an unfortunate order. Refer to the [FAQ](#faq) for a quick fix.

### Necessary data files
1. 1 ZARR per field of view you wish to view, named like `P0001.zarr`
1. 0 or 1 files of each of the following types, per field of view, organized into a folder that has the same field of view name as the ZARR, e.g. `P0001`. There must be at least 1 of these 3 files present:
- A `*_rois.csv` file: unfiltered regional spots
- A `*_rois.proximity_filtered.csv` file: regional spots after discarding those which are too close together
- A `*_rois.proximity_filtered.nuclei_filtered.csv` file: regional spots after proximity-based filtration and filtration for inclusion in nuclei
- A `*_rois.proximity_accepted.csv` file: regional spots after discarding those which are too close together
- A `*_rois.proximity_accepted.nuclei_labeled.csv` file: regional spots after proximity-based filtration and filtration for inclusion in nuclei

### File format notes
* For each spot, the following must be parsed:
Expand All @@ -45,12 +45,7 @@ If you see no spots of a certain color even when you're viewing a timepoint in w
* Detection timepoint
* Detection channel
* The center point's coordinates are read from `zc`, `yc` and `xc` columns.
* The bounding box is defined by columns suffixed `_min` and `_max` for each axis, e.g. `z_min`, `z_max`, etc.
* The timepoint is read from column `frame`.
* The channel is read from the `ch` column.

<a href="faq"></a>
### Frequently Asked Questions (FAQ)
1. __Why do I see only 1 or 2 colors of circles and boxes despite having dragged a folder containing data for more types (different filters) of spots?__\
It's _possible_ that one or more types of filters discarded _all_ the spots, and that therefore there's nothing to visualise for a particular filtration type/level (corresponding to a particular file and color). It's _more likely_, though, that the layers are simply in an unfavorable order. Since one set of spots (e.g., proximity-filtered) will often be a subset of ("nested within") another set of spots (e.g., unfiltered), the superset can completely cover the subset. You can click and drag the layers in the Napari window so that they're in descending order of level of filtration.

* The bounding box is defined by columns suffixed `Min` and `Max` for each axis, e.g. `zMin`, `zMax`, etc.
* The timepoint is read from column `timepoint`.
* The channel is read from the `channel` column.
* For the `*_rois.proximity_accepted.nuclei_labeled.csv` file, `mergeRois` and `nucleusNumber` columns are parsed.
2 changes: 1 addition & 1 deletion looptrace_regionals_vis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from numpydoc_decorator import doc # type: ignore[import-untyped]

__version__ = "0.2dev"
__version__ = "0.2.0"

_PACKAGE_NAME = package = Path(__file__).parent.name

Expand Down
108 changes: 54 additions & 54 deletions looptrace_regionals_vis/bounding_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,22 @@ class RectangleLike(Protocol):
"""Behaviors related to a rectangle"""

@abstractmethod
def get_x_min(self) -> float:
def get_xMin(self) -> float:
"""'Left' side of rectangle in x"""
raise NotImplementedError # pragma: no cover

@abstractmethod
def get_x_max(self) -> float:
def get_xMax(self) -> float:
"""'Right' side of rectangle in x"""
raise NotImplementedError # pragma: no cover

@abstractmethod
def get_y_min(self) -> float:
def get_yMin(self) -> float:
"""'Left' side of rectangle in y"""
raise NotImplementedError # pragma: no cover

@abstractmethod
def get_y_max(self) -> float:
def get_yMax(self) -> float:
"""'Right' side of rectangle in y"""
raise NotImplementedError # pragma: no cover

Expand All @@ -39,12 +39,12 @@ class RectangularPrismLike(RectangleLike):
"""Behaviors related to a rectangular prism"""

@abstractmethod
def get_z_min(self) -> float:
def get_zMin(self) -> float:
"""'Left' side of rectangle in z"""
raise NotImplementedError # pragma: no cover

@abstractmethod
def get_z_max(self) -> float:
def get_zMax(self) -> float:
"""'Right' side of rectangle in z"""
raise NotImplementedError # pragma: no cover

Expand All @@ -53,23 +53,23 @@ def get_z_max(self) -> float:
summary="A rectangular prism in 3D",
parameters=dict(
center="3D center point",
z_min="Lower bound in z",
z_max="Upper bound in z",
y_min="Lower bound in y",
y_max="Upper bound in y",
x_min="Lower bound in x",
x_max="Upper bound in x",
zMin="Lower bound in z",
zMax="Upper bound in z",
yMin="Lower bound in y",
yMax="Upper bound in y",
xMin="Lower bound in x",
xMax="Upper bound in x",
),
)
@dataclasses.dataclass(frozen=True, kw_only=True)
class BoundingBox3D(RectangularPrismLike): # noqa: D101
center: Point3D
z_min: float
z_max: float
y_min: float
y_max: float
x_min: float
x_max: float
zMin: float
zMax: float
yMin: float
yMax: float
xMin: float
xMax: float

@classmethod
def from_flat_arguments( # type: ignore[no-untyped-def] # noqa: D102
Expand All @@ -78,59 +78,59 @@ def from_flat_arguments( # type: ignore[no-untyped-def] # noqa: D102
zc, # noqa: ANN001
yc, # noqa: ANN001
xc, # noqa: ANN001
z_min, # noqa: ANN001
z_max, # noqa: ANN001
y_min, # noqa: ANN001
y_max, # noqa: ANN001
x_min, # noqa: ANN001
x_max, # noqa: ANN001
zMin, # noqa: ANN001
zMax, # noqa: ANN001
yMin, # noqa: ANN001
yMax, # noqa: ANN001
xMin, # noqa: ANN001
xMax, # noqa: ANN001
) -> "BoundingBox3D":
point = Point3D(z=zc, y=yc, x=xc)
return cls(
center=point,
z_min=z_min,
z_max=z_max,
y_min=y_min,
y_max=y_max,
x_min=x_min,
x_max=x_max,
zMin=zMin,
zMax=zMax,
yMin=yMin,
yMax=yMax,
xMin=xMin,
xMax=xMax,
)

@doc(summary="Left side in x dimension")
def get_x_min(self) -> float: # noqa: D102
return self.x_min
def get_xMin(self) -> float: # noqa: D102
return self.xMin

@doc(summary="Right side in x dimension")
def get_x_max(self) -> float: # noqa: D102
return self.x_max
def get_xMax(self) -> float: # noqa: D102
return self.xMax

@doc(summary="Left side in y dimension")
def get_y_min(self) -> float: # noqa: D102
return self.y_min
def get_yMin(self) -> float: # noqa: D102
return self.yMin

@doc(summary="Right side in y dimension")
def get_y_max(self) -> float: # noqa: D102
return self.y_max
def get_yMax(self) -> float: # noqa: D102
return self.yMax

@doc(summary="Left side in z dimension")
def get_z_min(self) -> float: # noqa: D102
return self.z_min
def get_zMin(self) -> float: # noqa: D102
return self.zMin

@doc(summary="Right side in z dimension")
def get_z_max(self) -> float: # noqa: D102
return self.z_max
def get_zMax(self) -> float: # noqa: D102
return self.zMax

def __post_init__(self) -> None:
"""Ensure that each field value is correctly typed and in proper relation to other fields."""
if self.x_min > self.x_max or self.y_min > self.y_max or self.z_min > self.z_max:
if self.xMin > self.xMax or self.yMin > self.yMax or self.zMin > self.zMax:
raise ValueError("For each dimension, min must be no more than max!")
if (
self.center.z < self.z_min
or self.center.z > self.z_max
or self.center.y < self.y_min
or self.center.y > self.y_max
or self.center.x < self.x_min
or self.center.x > self.x_max
self.center.z < self.zMin
or self.center.z > self.zMax
or self.center.y < self.yMin
or self.center.y > self.yMax
or self.center.x < self.xMin
or self.center.x > self.xMax
):
raise ValueError("For each dimension, center coordinate must be within min/max bounds!")
# Avoid repeated computation of the rounded value.
Expand All @@ -140,10 +140,10 @@ def iter_z_slices_nonnegative(
self,
) -> Iterable[tuple[Point3D, Point3D, Point3D, Point3D, bool]]:
"""Stack (1 slice per z) corner points with flag indicating if the slice corresponds to center in z."""
for z in range(max(0, floor(self.z_min)), max(0, floor(self.z_max) + 1)):
q1 = Point3D(z=float(z), y=self.y_min, x=self.x_max)
q2 = Point3D(z=float(z), y=self.y_min, x=self.x_min)
q3 = Point3D(z=float(z), y=self.y_max, x=self.x_min)
q4 = Point3D(z=float(z), y=self.y_max, x=self.x_max)
for z in range(max(0, floor(self.zMin)), max(0, floor(self.zMax) + 1)):
q1 = Point3D(z=float(z), y=self.yMin, x=self.xMax)
q2 = Point3D(z=float(z), y=self.yMin, x=self.xMin)
q3 = Point3D(z=float(z), y=self.yMax, x=self.xMin)
q4 = Point3D(z=float(z), y=self.yMax, x=self.xMax)
is_center = z == self._nearest_z_slice # type: ignore[attr-defined]
yield q1, q2, q3, q4, is_center
10 changes: 7 additions & 3 deletions looptrace_regionals_vis/colors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Colors used for data visualisation"""

INDIGO = "#332288" # for unfiltered
PALE_SKY_BLUE = "#88CCEE" # for too-proximal
PALE_RED_CLAY = "#CC6677" # for proximity-filtered, nuclei retained
__all__ = ["IBM_BLUE", "IBM_PURPLE", "IBM_PINK", "IBM_ORANGE", "IBM_YELLOW"]

IBM_BLUE = "#648FFF" # for merge contributors
IBM_PURPLE = "#785EF0" # for proximity discards
IBM_PINK = "#DC267F" # for accepted singletons
IBM_ORANGE = "#FE6100" # for non-nuclear
IBM_YELLOW = "FFB000" # for accepted mergers
Loading

0 comments on commit b81adc9

Please sign in to comment.