Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
uses: astral-sh/setup-uv@v7
- name: Run pytest
run: uv run --frozen pytest
coverage_and_badge:
coverage:
runs-on: ubuntu-24.04
steps:
- name: Checkout
Expand All @@ -57,14 +57,10 @@ jobs:
python-version: "3.13"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v7
- name: Install just
run: sudo apt-get update && sudo apt-get install -y just
- name: Run coverage check and update badge
run: just coverage-check
- name: Verify coverage badge is up to date
run: |
git diff --exit-code README.md || {
echo "README coverage badge is out of date.";
echo "Run 'just coverage-check' and commit changes.";
exit 1;
}
- name: Run pytest with coverage
run: uv run --frozen pytest --cov=zedprofiler --cov-report=xml
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ZEDprofiler [![Documentation](https://img.shields.io/badge/documentation-available-brightgreen)](https://zedprofiler.readthedocs.io/en/latest/) ![License](https://img.shields.io/badge/license-BSD%203--Clause-blue)[![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](#quality-gates)
# ZEDprofiler [![Documentation](https://img.shields.io/badge/documentation-available-brightgreen)](https://zedprofiler.readthedocs.io/en/latest/) ![License](https://img.shields.io/badge/license-BSD%203--Clause-blue)[![Coverage](https://codecov.io/gh/WayScience/ZedProfiler/branch/main/graph/badge.svg)](https://codecov.io/gh/WayScience/ZedProfiler)

[![ZEDprofiler](https://github.com/WayScience/ZEDprofiler/raw/main/logo/with-text-for-dark-bg.png)](https://github.com/WayScience/ZEDprofiler)

Expand Down
109 changes: 0 additions & 109 deletions scripts/update_coverage_badge.py

This file was deleted.

103 changes: 65 additions & 38 deletions src/zedprofiler/featurization/colocalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,6 @@ def linear_costes_threshold_calculation(
first_image_max = first_image.max()
second_image_max = second_image.max()

# Initialize without a threshold
costReg, _ = scipy.stats.pearsonr(first_image, second_image)
thr_first_image_c = i
thr_second_image_c = (a * i) + b
while i > first_image_max and (a * i) + b > second_image_max:
Expand Down Expand Up @@ -209,7 +207,11 @@ def bisection_costes_threshold_calculation(
valid = 1

while lastmid != mid:
thr_first_image_c = mid / scale_max
# Use raw pixel units (not normalised) so the threshold is comparable
# with linear_costes_threshold_calculation and with the outer dispatch's
# `image > thr` comparison. CellProfiler's library has the same
# mid/scale_max normalisation bug; this is an intentional divergence.
thr_first_image_c = float(mid)
thr_second_image_c = (a * thr_first_image_c) + b
combt = (first_image < thr_first_image_c) | (second_image < thr_second_image_c)
if numpy.count_nonzero(combt) <= MIN_PEARSON_POINTS:
Expand All @@ -234,7 +236,7 @@ def bisection_costes_threshold_calculation(
else:
mid = ((right - left) // 2) + left

thr_first_image_c = (valid - 1) / scale_max
thr_first_image_c = float(valid - 1)
thr_second_image_c = (a * thr_first_image_c) + b

return thr_first_image_c, thr_second_image_c
Expand Down Expand Up @@ -304,7 +306,7 @@ def prepare_two_images_for_colocalization( # noqa: PLR0913
return cropped_image_1, cropped_image_2


def calculate_colocalization( # noqa: PLR0912, PLR0915
def calculate_colocalization( # noqa: PLR0912, PLR0915, C901
cropped_image_1: numpy.ndarray,
cropped_image_2: numpy.ndarray,
thr: int = 15,
Expand All @@ -325,9 +327,13 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915
The threshold for the Manders' coefficients, by default 15
fast_costes : str, optional
The mode for Costes' threshold calculation, by default "Accurate".
Options are "Accurate" or "Fast".
"Accurate" uses a linear algorithm, while "Fast" uses a bisection algorithm.
The "Fast" mode is faster but less accurate.
Options are "Accurate", "Fast", or "Faster" (matching CellProfiler's
three Costes methods). "Accurate" tests every threshold value using a
linear scan (slowest, most precise). "Fast" uses the same linear scan
but skips candidate thresholds when the Pearson R is far from the
crossing point (faster, slightly less precise). "Faster" uses a
bisection algorithm and is substantially faster for 16-bit images
(least precise).

Returns
-------
Expand Down Expand Up @@ -361,6 +367,9 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915
################################################################################################

# Threshold as percentage of maximum intensity of objects in each channel
# Initialise before the try block so combined_thresh is always bound even
# when the except branch fires (numpy.max raises ValueError on empty arrays).
combined_thresh = numpy.zeros_like(cropped_image_1, dtype=bool)
try:
tff = (thr / 100) * numpy.max(cropped_image_1)
tss = (thr / 100) * numpy.max(cropped_image_2)
Expand Down Expand Up @@ -394,28 +403,30 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915
# Calculate the overlap coefficient
################################################################################################

fpsq = scipy.ndimage.sum(
cropped_image_1[combined_thresh] ** 2,
)
spsq = scipy.ndimage.sum(
cropped_image_2[combined_thresh] ** 2,
)
pdt = numpy.sqrt(numpy.array(fpsq) * numpy.array(spsq))
overlap = (
scipy.ndimage.sum(
cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh],
if numpy.any(combined_thresh):
fpsq = scipy.ndimage.sum(
cropped_image_1[combined_thresh] ** 2,
)
/ pdt
)
# leave in for now given they are not exported but still calculated
K1 = scipy.ndimage.sum(
cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh],
) / (numpy.array(fpsq))
K2 = scipy.ndimage.sum(
cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh],
) / (numpy.array(spsq))
if K1 == K2:
pass
spsq = scipy.ndimage.sum(
cropped_image_2[combined_thresh] ** 2,
)
pdt = numpy.sqrt(numpy.array(fpsq) * numpy.array(spsq))
overlap = (
scipy.ndimage.sum(
cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh],
)
/ pdt
)
K1 = scipy.ndimage.sum(
cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh],
) / (numpy.array(fpsq))
K2 = scipy.ndimage.sum(
cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh],
) / (numpy.array(spsq))
if K1 == K2:
pass
else:
overlap, K1, K2 = 0.0, 0.0, 0.0

# first_pixels, second_pixels = flattened image arrays
# combined_thresh = boolean mask of pixels above threshold in both channels
Expand Down Expand Up @@ -475,12 +486,24 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915
scale = UINT8_MAX

if fast_costes == "Accurate":
thr_first_image_c, thr_second_image_c = bisection_costes_threshold_calculation(
cropped_image_1, cropped_image_2, scale
thr_first_image_c, thr_second_image_c = linear_costes_threshold_calculation(
first_image=cropped_image_1,
second_image=cropped_image_2,
scale_max=scale,
fast_costes="Accurate",
)
else:
elif fast_costes == "Fast":
thr_first_image_c, thr_second_image_c = linear_costes_threshold_calculation(
cropped_image_1, cropped_image_2, scale, fast_costes
first_image=cropped_image_1,
second_image=cropped_image_2,
scale_max=scale,
fast_costes="Fast",
)
else: # "Faster"
thr_first_image_c, thr_second_image_c = bisection_costes_threshold_calculation(
first_image=cropped_image_1,
second_image=cropped_image_2,
scale_max=scale,
)

# Costes' thershold for entire image is applied to each object
Expand Down Expand Up @@ -537,9 +560,13 @@ def compute_colocalization( # noqa: C901, PLR0912
The threshold for the Manders' coefficients, by default 15
fast_costes : str, optional
The mode for Costes' threshold calculation, by default "Accurate".
Options are "Accurate" or "Fast".
"Accurate" uses a linear algorithm, while "Fast" uses a bisection algorithm.
The "Fast" mode is faster but less accurate.
Options are "Accurate", "Fast", or "Faster" (matching CellProfiler's
three Costes methods). "Accurate" tests every threshold value using a
linear scan (slowest, most precise). "Fast" uses the same linear scan
but skips candidate thresholds when the Pearson R is far from the
crossing point (faster, slightly less precise). "Faster" uses a
bisection algorithm and is substantially faster for 16-bit images
(least precise).
channel1 : str | None, optional
The name of the first channel, used for feature naming, by default None
channel2 : str | None, optional
Expand All @@ -566,8 +593,8 @@ def compute_colocalization( # noqa: C901, PLR0912
colocalization_features = calculate_colocalization(
cropped_image_1=cropped_image1,
cropped_image_2=cropped_image2,
thr=15,
fast_costes="Accurate",
thr=thr,
fast_costes=fast_costes,
)

# Build a simple dict row (avoid pandas dependency)
Expand Down
18 changes: 12 additions & 6 deletions src/zedprofiler/featurization/granularity.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ def _upsample_3d(
k, i, j = numpy.mgrid[
0 : original_shape[0], 0 : original_shape[1], 0 : original_shape[2]
].astype(float)
k *= float(subsampled_shape[0] - 1) / float(original_shape[0] - 1)
i *= float(subsampled_shape[1] - 1) / float(original_shape[1] - 1)
j *= float(subsampled_shape[2] - 1) / float(original_shape[2] - 1)
if original_shape[0] > 1:
k *= float(subsampled_shape[0] - 1) / float(original_shape[0] - 1)
if original_shape[1] > 1:
i *= float(subsampled_shape[1] - 1) / float(original_shape[1] - 1)
if original_shape[2] > 1:
j *= float(subsampled_shape[2] - 1) / float(original_shape[2] - 1)
return scipy.ndimage.map_coordinates(data, (k, i, j), order=1)


Expand Down Expand Up @@ -283,9 +286,12 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915
k, i, j = numpy.mgrid[
0 : new_shape[0], 0 : new_shape[1], 0 : new_shape[2]
].astype(float)
k *= float(back_shape[0] - 1) / float(new_shape[0] - 1)
i *= float(back_shape[1] - 1) / float(new_shape[1] - 1)
j *= float(back_shape[2] - 1) / float(new_shape[2] - 1)
if new_shape[0] > 1:
k *= float(back_shape[0] - 1) / float(new_shape[0] - 1)
if new_shape[1] > 1:
i *= float(back_shape[1] - 1) / float(new_shape[1] - 1)
if new_shape[2] > 1:
j *= float(back_shape[2] - 1) / float(new_shape[2] - 1)
back_pixels = scipy.ndimage.map_coordinates(back_pixels, (k, i, j), order=1)

# Subtract background
Expand Down
Loading
Loading