diff --git a/noxfile.py b/noxfile.py index 8f6cae0..025d7c7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,7 +14,7 @@ def tests(session: nox.Session) -> None: "pytest-cov", "pytest-markdown-docs", "six", # centrosome runtime dep, not declared in its metadata - ".", + ".[numba]", # exercise the numba backend + correctness harness in CI ) except Exception: session.skip( diff --git a/pyproject.toml b/pyproject.toml index a89d1ed..f7b4e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ ] [project.optional-dependencies] +numba = [ + "numba>=0.59", +] test = [ "pytest>=8.4.2", "pytest-cov", diff --git a/src/cp_measure/_detect.py b/src/cp_measure/_detect.py new file mode 100644 index 0000000..9dd8f9f --- /dev/null +++ b/src/cp_measure/_detect.py @@ -0,0 +1,14 @@ +"""Backend capability detection. + +Detected ONCE at import via ``importlib.util.find_spec`` — availability is +checked without importing the package or catching ImportErrors. Dispatch reads +the flag; the resolved backend path is then called directly and unguarded +(a backend that is flagged present but raises is a real bug and must surface, +not be papered over by a try/except fallback). + +Other backends (jax, ...) add their own flags here as they are wired. +""" + +import importlib.util + +HAS_NUMBA: bool = importlib.util.find_spec("numba") is not None diff --git a/src/cp_measure/bulk.py b/src/cp_measure/bulk.py index 1f2dc94..60450a8 100644 --- a/src/cp_measure/bulk.py +++ b/src/cp_measure/bulk.py @@ -45,6 +45,41 @@ _3D_FEATURES = ("intensity", "sizeshape", "texture", "granularity") +def _numba_registries() -> dict[str, dict[str, Callable]]: + """Registries for the 'numba' accelerator. + + Composes the numba implementations (``intensity`` and the ``pearson`` / + ``manders_fold`` / ``rwc`` / ``overlap`` colocalization features) with the + numpy implementations of every other feature — a single global "numba" + selection still yields a full, working feature set, accelerated where a + numba backend exists. This is explicit per-function composition, NOT an + error-driven fallback. + + Note: ``overlap`` is not in the numpy ``_CORRELATION`` registry, so the numba + correlation registry intentionally exposes one feature the numpy one does not + (the numba ``overlap`` backend exists and is cheap to surface). Adding + ``overlap`` to the numpy ``_CORRELATION`` for symmetry is a separate call. + """ + from cp_measure.core.numba import ( + get_correlation_manders_fold as _numba_manders_fold, + get_correlation_overlap as _numba_overlap, + get_correlation_pearson as _numba_pearson, + get_correlation_rwc as _numba_rwc, + get_intensity as _numba_intensity, + ) + + return { + "core": {**_CORE, "intensity": _numba_intensity}, + "correlation": { + **_CORRELATION, + "pearson": _numba_pearson, + "manders_fold": _numba_manders_fold, + "rwc": _numba_rwc, + "overlap": _numba_overlap, + }, + } + + def _dispatch(name: str) -> dict[str, Callable]: from cp_measure import _ACCELERATOR @@ -55,9 +90,14 @@ def _dispatch(name: str) -> dict[str, Callable]: f"'jax' accelerator not yet wired for {name} measurements" ) if _ACCELERATOR == "numba": - raise NotImplementedError( - f"'numba' accelerator not yet wired for {name} measurements" - ) + from cp_measure._detect import HAS_NUMBA + + if not HAS_NUMBA: + raise RuntimeError( + "accelerator 'numba' selected but numba is not installed; " + "you can install it via `pip install cp_measure[numba]`" + ) + return _numba_registries()[name] if _ACCELERATOR == "fastest": raise NotImplementedError("'fastest' logic not yet implemented") raise ValueError( diff --git a/src/cp_measure/core/numba/__init__.py b/src/cp_measure/core/numba/__init__.py new file mode 100644 index 0000000..48c00d8 --- /dev/null +++ b/src/cp_measure/core/numba/__init__.py @@ -0,0 +1,27 @@ +"""Numba-accelerated backend. + +Selected explicitly by import (``from cp_measure.core.numba import get_intensity``) +or globally via ``cp_measure.set_accelerator("numba")``. Requires the optional +``numba`` extra; availability is gated by ``cp_measure._detect.HAS_NUMBA``. + +This backend accelerates ``intensity`` and the colocalization features +``pearson``/``manders_fold``/``rwc``/``overlap``; the global "numba" accelerator +composes them with the numpy implementations of every other feature (see +``cp_measure.bulk``). +""" + +from cp_measure.core.numba.measurecolocalization import ( + get_correlation_manders_fold, + get_correlation_overlap, + get_correlation_pearson, + get_correlation_rwc, +) +from cp_measure.core.numba.measureobjectintensity import get_intensity + +__all__ = [ + "get_correlation_manders_fold", + "get_correlation_overlap", + "get_correlation_pearson", + "get_correlation_rwc", + "get_intensity", +] diff --git a/src/cp_measure/core/numba/_colocalization.py b/src/cp_measure/core/numba/_colocalization.py new file mode 100644 index 0000000..fc896eb --- /dev/null +++ b/src/cp_measure/core/numba/_colocalization.py @@ -0,0 +1,163 @@ +"""Fused per-object colocalization kernel (single-threaded, cached). + +One pass-set over the per-object value blocks produced by +:func:`cp_measure.primitives._segment_numba.flatten_pairs_grouped` yields every +PR-A colocalization feature — Pearson correlation + slope, Manders M1/M2, +Overlap + K1/K2, and (optionally) the rank-weighted RWC1/RWC2 — for all objects. + +The math is the per-object reduction of +:mod:`cp_measure.core.measurecolocalization`, where each reference ``_ind`` call +processes a single object via ``scipy.ndimage`` over an all-ones label +(``lrange=[1]``, hence the ``[0]`` indexing throughout). Here that becomes a +plain loop over each object's contiguous block. + +Serial by construction: objects are iterated in a single ``for``; no +``prange``/``nogil``. Parallelism is the job of a future batch layer over images, +never the kernel. +""" + +import numpy as np +from numba import njit + + +@njit(cache=True) +def _dense_rank(vals): + """Per-element 0-based dense rank (ties share a rank), plus the max rank. + + Bit-reproduces the reference's ``lexsort`` + unique-diff + ``cumsum``: the + rank of an element is the number of DISTINCT smaller values, so it is + independent of the tie-break order ``argsort`` happens to pick. + """ + m = vals.shape[0] + ranks = np.empty(m, np.int64) + order = np.argsort(vals) + r = 0 + ranks[order[0]] = 0 + for j in range(1, m): + if vals[order[j]] != vals[order[j - 1]]: + r += 1 + ranks[order[j]] = r + return ranks, r + + +@njit(cache=True) +def coloc_per_object(g1, g2, offsets, n, thr_frac, compute_rwc): + """Per-object colocalization features over grouped value blocks. + + ``g1``/``g2`` hold both channels' intensities with object ``k`` in + ``[offsets[k] : offsets[k + 1]]``. ``thr_frac`` is the threshold as a + fraction of each object's per-channel max (reference ``thr/100``). The rank + sort for RWC is gated by ``compute_rwc`` so the three rank-free features skip + it. Returns nine length-``n`` arrays: + ``(corr, slope, m1, m2, overlap, k1, k2, rwc1, rwc2)``. + + Threshold-derived features default to ``0.0`` for an object with no pixel + above the combined threshold (matching the reference's initialised values); + Pearson is threshold-free and is always computed (``NaN`` when undefined). + """ + corr = np.zeros(n) + slope = np.zeros(n) + m1 = np.zeros(n) + m2 = np.zeros(n) + overlap = np.zeros(n) + k1 = np.zeros(n) + k2 = np.zeros(n) + rwc1 = np.zeros(n) + rwc2 = np.zeros(n) + + for k in range(n): + lo = offsets[k] + hi = offsets[k + 1] + cnt = hi - lo + if cnt == 0: + corr[k] = np.nan + slope[k] = np.nan + continue + + # Pass 1: means + per-channel maxima. + s1 = 0.0 + s2 = 0.0 + mx1 = -np.inf + mx2 = -np.inf + for i in range(lo, hi): + v1 = g1[i] + v2 = g2[i] + s1 += v1 + s2 += v2 + if v1 > mx1: + mx1 = v1 + if v2 > mx2: + mx2 = v2 + mean1 = s1 / cnt + mean2 = s2 / cnt + + tff = thr_frac * mx1 + tss = thr_frac * mx2 + + # Per-pixel RWC weights need the object's dense ranks up front. + if compute_rwc: + r1, r1max = _dense_rank(g1[lo:hi]) + r2, r2max = _dense_rank(g2[lo:hi]) + rmax = r1max if r1max > r2max else r2max + R = rmax + 1 + + # Pass 2: the centred second moments (Pearson r + slope) and the + # threshold-gated accumulations are INDEPENDENT given pass 1's means and + # maxima, so they share one sweep over the block (each accumulator's + # add-order is unchanged -> bit-identical to two separate passes). + c11 = 0.0 + c22 = 0.0 + c12 = 0.0 + tot_fi = 0.0 + tot_si = 0.0 + sum_fi_c = 0.0 + sum_si_c = 0.0 + sum_fi2_c = 0.0 + sum_si2_c = 0.0 + sum_fisi_c = 0.0 + sum_fiw_c = 0.0 + sum_siw_c = 0.0 + n_comb = 0 + for i in range(lo, hi): + v1 = g1[i] + v2 = g2[i] + d1 = v1 - mean1 + d2 = v2 - mean2 + c11 += d1 * d1 + c22 += d2 * d2 + c12 += d1 * d2 + above1 = v1 >= tff + above2 = v2 >= tss + if above1: + tot_fi += v1 + if above2: + tot_si += v2 + if above1 and above2: + n_comb += 1 + sum_fi_c += v1 + sum_si_c += v2 + sum_fi2_c += v1 * v1 + sum_si2_c += v2 * v2 + sum_fisi_c += v1 * v2 + if compute_rwc: + j = i - lo + diff = r1[j] - r2[j] + if diff < 0: + diff = -diff + w = (R - diff) / R + sum_fiw_c += v1 * w + sum_siw_c += v2 * w + corr[k] = c12 / np.sqrt(c11 * c22) + slope[k] = c12 / c11 + + if n_comb > 0: + m1[k] = sum_fi_c / tot_fi + m2[k] = sum_si_c / tot_si + overlap[k] = sum_fisi_c / np.sqrt(sum_fi2_c * sum_si2_c) + k1[k] = sum_fisi_c / sum_fi2_c + k2[k] = sum_fisi_c / sum_si2_c + if compute_rwc: + rwc1[k] = sum_fiw_c / tot_fi + rwc2[k] = sum_siw_c / tot_si + + return corr, slope, m1, m2, overlap, k1, k2, rwc1, rwc2 diff --git a/src/cp_measure/core/numba/measurecolocalization.py b/src/cp_measure/core/numba/measurecolocalization.py new file mode 100644 index 0000000..17cdeea --- /dev/null +++ b/src/cp_measure/core/numba/measurecolocalization.py @@ -0,0 +1,152 @@ +"""Numba-backed MeasureColocalization (Pearson, Manders, RWC, Overlap). + +Drop-in replacements for the like-named functions in +:mod:`cp_measure.core.measurecolocalization`, producing the identical per-object +feature dicts. The reference iterates ``labels_to_binmasks`` — materialising an +``(N, H, W)`` boolean stack — and re-indexes the whole image per object through +``scipy.ndimage`` (single-label reductions, hence the ``[0]`` everywhere). Here a +single grouped flatten plus one fused per-object kernel +(:func:`cp_measure.core.numba._colocalization.coloc_per_object`) replaces all of +it. + +Input is normalised through :func:`cp_measure.primitives.shapes.to_bzyx`, exactly +like the numba ``intensity``/``zernike`` backends. Colocalization takes a +``(pixels_1, pixels_2, masks)`` triple, so ``to_bzyx`` is called twice against the +shared mask and the single ``unwrap`` is reused. Every feature here is a function +of the per-object value vectors ONLY (no pixel coordinates), so the kernels never +branch on 2D vs 3D and the ``(1, Y, X)`` vs ``(H, W)`` divergence that affects the +intensity backend cannot occur. + +Pixels are upcast to float64 before reduction. This is strictly more accurate +than the numpy reference on integer-dtype input — where ``fi*si`` overflows in +uint8 (Overlap/K can exceed 1) and the Pearson slope's ``lstsq`` runs in float32 +— but means the two backends can differ on genuine integer images. Real (float) +intensity images are unaffected. + +``costes`` lives in a stacked follow-up; it is not part of this module. +""" + +import numpy +from numpy.typing import NDArray + +from cp_measure.core.measurecolocalization import ( + F_CORRELATION_FORMAT, + F_K_FORMAT, + F_MANDERS_FORMAT, + F_OVERLAP_FORMAT, + F_RWC_FORMAT, + F_SLOPE_FORMAT, +) +from cp_measure.core.numba._colocalization import coloc_per_object +from cp_measure.primitives._segment_numba import flatten_pairs_grouped +from cp_measure.primitives.segment import labels_to_offsets +from cp_measure.primitives.shapes import to_bzyx + + +def _run(masks_zyx, pixels_1_zyx, pixels_2_zyx, thr_frac, compute_rwc): + """Flatten one ``(Z, Y, X)`` image triple and run the fused kernel. + + Returns the nine per-object arrays from ``coloc_per_object``, or ``None`` when + the image holds no objects (the callers then emit empty feature arrays). + """ + masks = numpy.ascontiguousarray(masks_zyx) + if not numpy.issubdtype(masks.dtype, numpy.integer): + masks = masks.astype(numpy.intp) + lut, n, offsets = labels_to_offsets(masks) + if n == 0: + return None + g1, g2 = flatten_pairs_grouped( + masks, + numpy.ascontiguousarray(pixels_1_zyx, dtype=numpy.float64), + numpy.ascontiguousarray(pixels_2_zyx, dtype=numpy.float64), + lut, + offsets, + ) + return coloc_per_object(g1, g2, offsets, n, thr_frac, compute_rwc) + + +def _featurize(pixels_1, pixels_2, masks, thr, compute_rwc, build): + """Normalise the triple via ``to_bzyx`` and map each image through ``build``. + + ``build(result)`` turns the kernel tuple (or ``None`` for an empty image) into + one image's feature dict. ``unwrap`` then collapses a single image to that + dict, or returns the list for a batch — matching the numba intensity backend. + """ + frac = thr / 100.0 + masks_list, pixels_1_list, unwrap = to_bzyx(masks, pixels_1) + _, pixels_2_list, _ = to_bzyx(masks, pixels_2) + results = [ + build(_run(m, p1, p2, frac, compute_rwc)) + for m, p1, p2 in zip(masks_list, pixels_1_list, pixels_2_list) + ] + return unwrap(results) + + +_EMPTY = numpy.empty(0) + + +def get_correlation_pearson( + pixels_1: NDArray[numpy.floating], + pixels_2: NDArray[numpy.floating], + masks: NDArray[numpy.integer], +) -> dict[str, NDArray[numpy.floating]]: + def build(res): + if res is None: + return {F_CORRELATION_FORMAT: _EMPTY, F_SLOPE_FORMAT: _EMPTY} + corr, slope = res[0], res[1] + return {F_CORRELATION_FORMAT: corr, F_SLOPE_FORMAT: slope} + + return _featurize(pixels_1, pixels_2, masks, 15, False, build) + + +def get_correlation_manders_fold( + pixels_1: NDArray[numpy.floating], + pixels_2: NDArray[numpy.floating], + masks: NDArray[numpy.integer], + thr: int = 15, +) -> dict[str, NDArray[numpy.floating]]: + def build(res): + if res is None: + return {f"{F_MANDERS_FORMAT}_1": _EMPTY, f"{F_MANDERS_FORMAT}_2": _EMPTY} + m1, m2 = res[2], res[3] + return {f"{F_MANDERS_FORMAT}_1": m1, f"{F_MANDERS_FORMAT}_2": m2} + + return _featurize(pixels_1, pixels_2, masks, thr, False, build) + + +def get_correlation_rwc( + pixels_1: NDArray[numpy.floating], + pixels_2: NDArray[numpy.floating], + masks: NDArray[numpy.integer], + thr: int = 15, +) -> dict[str, NDArray[numpy.floating]]: + def build(res): + if res is None: + return {f"{F_RWC_FORMAT}_1": _EMPTY, f"{F_RWC_FORMAT}_2": _EMPTY} + rwc1, rwc2 = res[7], res[8] + return {f"{F_RWC_FORMAT}_1": rwc1, f"{F_RWC_FORMAT}_2": rwc2} + + return _featurize(pixels_1, pixels_2, masks, thr, True, build) + + +def get_correlation_overlap( + pixels_1: NDArray[numpy.floating], + pixels_2: NDArray[numpy.floating], + masks: NDArray[numpy.integer], + thr: int = 15, +) -> dict[str, NDArray[numpy.floating]]: + def build(res): + if res is None: + return { + F_OVERLAP_FORMAT: _EMPTY, + f"{F_K_FORMAT}_1": _EMPTY, + f"{F_K_FORMAT}_2": _EMPTY, + } + overlap, k1, k2 = res[4], res[5], res[6] + return { + F_OVERLAP_FORMAT: overlap, + f"{F_K_FORMAT}_1": k1, + f"{F_K_FORMAT}_2": k2, + } + + return _featurize(pixels_1, pixels_2, masks, thr, False, build) diff --git a/src/cp_measure/core/numba/measureobjectintensity.py b/src/cp_measure/core/numba/measureobjectintensity.py new file mode 100644 index 0000000..bd058c5 --- /dev/null +++ b/src/cp_measure/core/numba/measureobjectintensity.py @@ -0,0 +1,199 @@ +"""Numba-backed MeasureObjectIntensity. + +Drop-in for :func:`cp_measure.core.measureobjectintensity.get_intensity`, +producing the identical dict of features for 2D and 3D input. The per-label +reductions — including ``Location_MaxIntensity_*`` — run entirely as fused +single-pass numba kernels over a flat segment representation +(:mod:`cp_measure.primitives`). The max position uses a deterministic +``>=``-last rule that is bit-identical to ``scipy.ndimage.maximum_position`` on +real (tie-free) data; only exact-value ties can differ (scipy's tie pick is +quicksort-dependent and not stable across numpy versions). +""" + +import numpy +import skimage.segmentation +from numpy.typing import NDArray + +from cp_measure.core.measureobjectintensity import ( + C_LOCATION, + INTEGRATED_INTENSITY, + INTEGRATED_INTENSITY_EDGE, + INTENSITY, + LOC_CMI_X, + LOC_CMI_Y, + LOC_CMI_Z, + LOC_MAX_X, + LOC_MAX_Y, + LOC_MAX_Z, + LOWER_QUARTILE_INTENSITY, + MAD_INTENSITY, + MASS_DISPLACEMENT, + MAX_INTENSITY, + MAX_INTENSITY_EDGE, + MEAN_INTENSITY, + MEAN_INTENSITY_EDGE, + MEDIAN_INTENSITY, + MIN_INTENSITY, + MIN_INTENSITY_EDGE, + STD_INTENSITY, + STD_INTENSITY_EDGE, + UPPER_QUARTILE_INTENSITY, +) +from cp_measure.primitives.segment import label_to_idx_lut +from cp_measure.primitives._segment_numba import ( + flatten_numba, + inner_boundary, + segment_moments, + segment_quantiles, + segment_resid_sumsq, + segment_stats, +) + + +def get_intensity( + masks: NDArray[numpy.integer], + pixels: NDArray[numpy.floating], + edge_measurements: bool = True, +) -> dict[str, NDArray[numpy.floating]]: + """masks is a labeled array where 0 are background.""" + orig_ndim = pixels.ndim + + masked_image = pixels + if pixels.ndim == 2: + masked_image = pixels.reshape(1, *pixels.shape) + if masks.ndim == 2: + masks = masks.reshape(1, *masks.shape) + elif pixels.ndim == 3 and masks.ndim == 2: # 3D image, 2D mask + masks = masks.reshape(1, *masks.shape) + + lut, nobjects = label_to_idx_lut(masks) + + integrated_intensity = numpy.zeros(nobjects) + mean_intensity = numpy.zeros(nobjects) + std_intensity = numpy.zeros(nobjects) + min_intensity = numpy.zeros(nobjects) + max_intensity = numpy.zeros(nobjects) + mass_displacement = numpy.zeros(nobjects) + lower_quartile_intensity = numpy.zeros(nobjects) + median_intensity = numpy.zeros(nobjects) + mad_intensity = numpy.zeros(nobjects) + upper_quartile_intensity = numpy.zeros(nobjects) + cmi_x = numpy.zeros(nobjects) + cmi_y = numpy.zeros(nobjects) + cmi_z = numpy.zeros(nobjects) + max_x = numpy.zeros(nobjects) + max_y = numpy.zeros(nobjects) + max_z = numpy.zeros(nobjects) + + values, seg0, xc, yc, zc = flatten_numba( + numpy.ascontiguousarray(masks), + numpy.ascontiguousarray(masked_image), + lut, + ) + has_objects = values.size > 0 + + if has_objects: + ( + count, + sumI, + minI, + maxI, + max_x, + max_y, + max_z, + sx, + sy, + sz, + sxI, + syI, + szI, + ) = segment_moments(values, seg0, xc, yc, zc, nobjects) + cnt = count.astype(numpy.float64) + with numpy.errstate(invalid="ignore", divide="ignore"): + integrated_intensity = sumI + mean_intensity = sumI / cnt + ss = segment_resid_sumsq(values, seg0, nobjects, mean_intensity) + std_intensity = numpy.sqrt(ss / cnt) + min_intensity = minI + max_intensity = maxI + + cm_x = sx / cnt + cm_y = sy / cnt + cm_z = sz / cnt + cmi_x = sxI / sumI + cmi_y = syI / sumI + cmi_z = szI / sumI + mass_displacement = numpy.sqrt( + (cm_x - cmi_x) ** 2 + (cm_y - cmi_y) ** 2 + (cm_z - cmi_z) ** 2 + ) + + ( + lower_quartile_intensity, + median_intensity, + upper_quartile_intensity, + mad_intensity, + ) = segment_quantiles(values, seg0, count, nobjects, 1.0 / orig_ndim) + + if edge_measurements: + integrated_intensity_edge = numpy.zeros(nobjects) + mean_intensity_edge = numpy.zeros(nobjects) + std_intensity_edge = numpy.zeros(nobjects) + min_intensity_edge = numpy.zeros(nobjects) + max_intensity_edge = numpy.zeros(nobjects) + + # 2D plane (Z==1): numba inner-boundary kernel, bit-identical to skimage + # mode="inner" but ~12-27x faster. True 3D keeps skimage (6-neighbourhood). + if masks.shape[0] == 1: + emask = inner_boundary(numpy.ascontiguousarray(masks[0]))[numpy.newaxis] > 0 + else: + emask = skimage.segmentation.find_boundaries(masks, mode="inner") > 0 + e_values = masked_image[emask].astype(numpy.float64) + e_seg0 = lut[masks[emask]] + + if e_values.size > 0: + ecount, esum, emin, emax = segment_stats(e_values, e_seg0, nobjects) + edge_obj = ecount > 0 + ecnt = ecount.astype(numpy.float64) + with numpy.errstate(invalid="ignore", divide="ignore"): + emean = esum / ecnt + ess = segment_resid_sumsq(e_values, e_seg0, nobjects, emean) + estd = numpy.sqrt(ess / ecnt) + integrated_intensity_edge[edge_obj] = esum[edge_obj] + mean_intensity_edge[edge_obj] = emean[edge_obj] + std_intensity_edge[edge_obj] = estd[edge_obj] + min_intensity_edge[edge_obj] = emin[edge_obj] + max_intensity_edge[edge_obj] = emax[edge_obj] + + measurement_names = [ + (INTENSITY, INTEGRATED_INTENSITY, integrated_intensity), + (INTENSITY, MEAN_INTENSITY, mean_intensity), + (INTENSITY, STD_INTENSITY, std_intensity), + (INTENSITY, MIN_INTENSITY, min_intensity), + (INTENSITY, MAX_INTENSITY, max_intensity), + (INTENSITY, MASS_DISPLACEMENT, mass_displacement), + (INTENSITY, LOWER_QUARTILE_INTENSITY, lower_quartile_intensity), + (INTENSITY, MEDIAN_INTENSITY, median_intensity), + (INTENSITY, MAD_INTENSITY, mad_intensity), + (INTENSITY, UPPER_QUARTILE_INTENSITY, upper_quartile_intensity), + (C_LOCATION, LOC_CMI_X, cmi_x), + (C_LOCATION, LOC_CMI_Y, cmi_y), + (C_LOCATION, LOC_CMI_Z, cmi_z), + (C_LOCATION, LOC_MAX_X, max_x), + (C_LOCATION, LOC_MAX_Y, max_y), + (C_LOCATION, LOC_MAX_Z, max_z), + ] + if edge_measurements: + measurement_names.extend( + [ + (INTENSITY, INTEGRATED_INTENSITY_EDGE, integrated_intensity_edge), + (INTENSITY, MEAN_INTENSITY_EDGE, mean_intensity_edge), + (INTENSITY, STD_INTENSITY_EDGE, std_intensity_edge), + (INTENSITY, MIN_INTENSITY_EDGE, min_intensity_edge), + (INTENSITY, MAX_INTENSITY_EDGE, max_intensity_edge), + ] + ) + + return { + "{}_{}".format(category, feature_name): measurement + for category, feature_name, measurement in measurement_names + } diff --git a/src/cp_measure/primitives/__init__.py b/src/cp_measure/primitives/__init__.py new file mode 100644 index 0000000..3619525 --- /dev/null +++ b/src/cp_measure/primitives/__init__.py @@ -0,0 +1,17 @@ +"""Shared primitive layer. + +Backend-agnostic building blocks that the per-backend feature implementations +(``cp_measure.core`` = numpy, ``cp_measure.core.numba`` = numba, ...) compose. + +A labeled image is reduced to a flat *segment* representation (values + 0-based +segment index + per-axis coordinates) which the segment kernels reduce. All +spatial structure (2D vs 3D) and any future batch/image axis are encoded in that +flat segment index, so a single set of segment kernels covers every case without +a rewrite. The numpy ``label_to_idx_lut`` (in ``segment``) builds the +label→index lookup; the flattening + reductions themselves live in +``_segment_numba``. + +This is an internal layer with no curated public API: import its building +blocks directly from the concrete modules (``primitives.segment``, +``primitives._segment_numba``) rather than re-exporting them here. +""" diff --git a/src/cp_measure/primitives/_segment_numba.py b/src/cp_measure/primitives/_segment_numba.py new file mode 100644 index 0000000..3c884cd --- /dev/null +++ b/src/cp_measure/primitives/_segment_numba.py @@ -0,0 +1,261 @@ +"""Numba segment kernels (single-threaded, cached). + +These are the numba implementation of the segment-reduce / segment-quantile +primitives. They loop over the flat ``(values, seg0, coords)`` arrays produced +by :mod:`cp_measure.primitives.segment` — no image shape, no batch axis — so one +kernel set covers 2D, 3D, and (future) batched inputs unchanged. + +All kernels are ``@njit(cache=True)`` and serial: no ``prange``/``nogil``. +Parallelism is the job of the (future) batch layer over images, not the kernel. +""" + +import numpy as np +from numba import njit + + +@njit(cache=True) +def flatten_numba(masks, pixels, lut): + """Flatten a labeled (Z, Y, X) image to ``(values, seg0, xc, yc, zc)``. + + Two grid scans (count, then fill) replace the numpy ``(masks>0)&isfinite`` + mask + ``numpy.nonzero`` + fancy-index gathers; coordinates are the loop + indices. Background and non-finite pixels are dropped, in C (raster) order. + ``masks`` and ``pixels`` must be C-contiguous; ``pixels`` may be any float + dtype (kept values are upcast into the float64 ``values`` output). + """ + Z, Y, X = masks.shape + M = 0 + for z in range(Z): + for y in range(Y): + for x in range(X): + if masks[z, y, x] > 0 and np.isfinite(pixels[z, y, x]): + M += 1 + values = np.empty(M, np.float64) + seg0 = np.empty(M, np.int64) + xc = np.empty(M, np.float64) + yc = np.empty(M, np.float64) + zc = np.empty(M, np.float64) + i = 0 + for z in range(Z): + for y in range(Y): + for x in range(X): + L = masks[z, y, x] + if L <= 0: + continue + v = pixels[z, y, x] + if not np.isfinite(v): + continue + values[i] = v + seg0[i] = lut[L] + xc[i] = x + yc[i] = y + zc[i] = z + i += 1 + return values, seg0, xc, yc, zc + + +@njit(cache=True) +def segment_moments(values, seg0, xc, yc, zc, n): + """One pass over the flat pixels accumulating, per segment: + + count, sum, min, max, the max-pixel coordinates, and the six centroid + cross-sums (Sx, Sy, Sz, Sx*I, Sy*I, Sz*I). + + The max position uses ``>=`` so the LAST pixel (in the flat raster order the + host builds the arrays in) attaining the maximum wins. On real, continuous + data the max is unique, so this is bit-identical to the numpy backend's + ``scipy.ndimage.maximum_position``; on exact-value ties it is a deterministic + rule rather than scipy's quicksort-dependent (version-unstable) pick. + """ + count = np.zeros(n, np.int64) + sumI = np.zeros(n, np.float64) + minI = np.full(n, np.inf) + maxI = np.full(n, -np.inf) + mx = np.zeros(n, np.float64) + my = np.zeros(n, np.float64) + mz = np.zeros(n, np.float64) + sx = np.zeros(n, np.float64) + sy = np.zeros(n, np.float64) + sz = np.zeros(n, np.float64) + sxI = np.zeros(n, np.float64) + syI = np.zeros(n, np.float64) + szI = np.zeros(n, np.float64) + M = values.shape[0] + for i in range(M): + k = seg0[i] + v = values[i] + x = xc[i] + y = yc[i] + z = zc[i] + count[k] += 1 + sumI[k] += v + if v < minI[k]: + minI[k] = v + if v >= maxI[k]: # >= -> keep LAST max in raster order + maxI[k] = v + mx[k] = x + my[k] = y + mz[k] = z + sx[k] += x + sy[k] += y + sz[k] += z + sxI[k] += x * v + syI[k] += y * v + szI[k] += z * v + return count, sumI, minI, maxI, mx, my, mz, sx, sy, sz, sxI, syI, szI + + +@njit(cache=True) +def segment_stats(values, seg0, n): + """One pass accumulating per-segment count, sum, min, max only. + + The lightweight reduce for callers (e.g. edge measurements) that need + neither the max position nor the centroid cross-sums. + """ + count = np.zeros(n, np.int64) + sumI = np.zeros(n, np.float64) + minI = np.full(n, np.inf) + maxI = np.full(n, -np.inf) + M = values.shape[0] + for i in range(M): + k = seg0[i] + v = values[i] + count[k] += 1 + sumI[k] += v + if v < minI[k]: + minI[k] = v + if v > maxI[k]: + maxI[k] = v + return count, sumI, minI, maxI + + +@njit(cache=True) +def inner_boundary(masks2d): + """Labeled inner boundary of a 2D label plane (0 = not a boundary pixel). + + A foreground pixel is a boundary pixel iff any in-bounds 4-neighbour has a + different label; out-of-bounds neighbours are ignored. Bit-identical to + ``skimage.segmentation.find_boundaries(mode="inner")`` on a single 2D plane, + ~12-27x faster (one pass, no morphology). ``masks2d`` must be C-contiguous. + """ + H, W = masks2d.shape + out = np.zeros((H, W), masks2d.dtype) + for r in range(H): + for c in range(W): + L = masks2d[r, c] + if L <= 0: + continue + if ( + (r > 0 and masks2d[r - 1, c] != L) + or (r < H - 1 and masks2d[r + 1, c] != L) + or (c > 0 and masks2d[r, c - 1] != L) + or (c < W - 1 and masks2d[r, c + 1] != L) + ): + out[r, c] = L + return out + + +@njit(cache=True) +def segment_resid_sumsq(values, seg0, n, mean): + """Second pass: per-segment sum of squared residuals (for population std).""" + ss = np.zeros(n, np.float64) + M = values.shape[0] + for i in range(M): + k = seg0[i] + d = values[i] - mean[k] + ss[k] += d * d + return ss + + +@njit(cache=True) +def _interp(sorted_seg, n, frac): + """Linear interpolation at position ``n * frac`` within a sorted segment. + + Matches the reference's ``cumsum(areas)``-offset interpolation: the local + position is ``count * frac``; clamp the upper neighbour at the last element. + """ + pos = n * frac + lo = int(pos) + if lo > n - 1: + lo = n - 1 + hi = lo + 1 + if hi > n - 1: + hi = n - 1 + f = pos - lo + return sorted_seg[lo] * (1.0 - f) + sorted_seg[hi] * f + + +@njit(cache=True) +def segment_quantiles(values, seg0, counts, n, mad_frac): + """Per-segment quartiles/median + MAD via scatter-into-segments + sort. + + Builds CSR offsets from ``counts``, scatters values into one flat buffer, + sorts each segment slice in place, and interpolates. MAD reuses the sorted + absolute deviations from the median at ``mad_frac`` (= 1 / original ndim). + """ + starts = np.zeros(n, np.int64) + acc = 0 + for k in range(n): + starts[k] = acc + acc += counts[k] + total = acc + + buf = np.empty(total, np.float64) + cursor = starts.copy() + M = values.shape[0] + for i in range(M): + k = seg0[i] + buf[cursor[k]] = values[i] + cursor[k] += 1 + + lq = np.zeros(n, np.float64) + med = np.zeros(n, np.float64) + uq = np.zeros(n, np.float64) + mad = np.zeros(n, np.float64) + for k in range(n): + cnt = counts[k] + if cnt == 0: + continue + s = starts[k] + seg = buf[s : s + cnt] + seg.sort() + lq[k] = _interp(seg, cnt, 0.25) + med[k] = _interp(seg, cnt, 0.5) + uq[k] = _interp(seg, cnt, 0.75) + ad = np.abs(seg - med[k]) + ad.sort() + mad[k] = _interp(ad, cnt, mad_frac) + return lq, med, uq, mad + + +@njit(cache=True) +def flatten_pairs_grouped(masks, pix1, pix2, lut, offsets): + """Flatten two co-registered ``(Z, Y, X)`` channels to per-object blocks. + + Returns ``(g1, g2)`` where object ``k`` owns the contiguous slices + ``g1[offsets[k] : offsets[k + 1]]`` and ``g2[...]``. Both channels are + gathered at every ``masks > 0`` pixel in one raster scan, so ``g1`` and + ``g2`` stay aligned. Unlike :func:`flatten_numba`, non-finite pixels are + KEPT — this mirrors the colocalization reference's ``pixels[mask]`` + extraction, which applies no finiteness filter. + + A SINGLE scatter scan: ``offsets`` (the CSR block bounds, length ``n + 1``) + is precomputed by :func:`cp_measure.primitives.segment.labels_to_offsets` + from a ``bincount``, so the count scan this kernel used to do is gone. + ``masks`` must be C-contiguous integer; ``lut`` the ``label -> 0..n-1`` map. + """ + Z, Y, X = masks.shape + g1 = np.empty(offsets[-1], np.float64) + g2 = np.empty(offsets[-1], np.float64) + fill = offsets[:-1].copy() + for z in range(Z): + for y in range(Y): + for x in range(X): + L = masks[z, y, x] + if L > 0: + k = lut[L] + p = fill[k] + g1[p] = pix1[z, y, x] + g2[p] = pix2[z, y, x] + fill[k] = p + 1 + return g1, g2 diff --git a/src/cp_measure/primitives/segment.py b/src/cp_measure/primitives/segment.py new file mode 100644 index 0000000..bd2cc0a --- /dev/null +++ b/src/cp_measure/primitives/segment.py @@ -0,0 +1,66 @@ +"""Host-side segment helpers (numpy, backend-agnostic). + +A labeled image is reduced to a flat *segment* form — ``values[M]`` intensities, +``seg0[M]`` 0-based segment indices, and ``xc/yc/zc[M]`` per-pixel coordinates — +which the segment kernels then reduce. The flattening itself lives in the numba +layer (:func:`cp_measure.primitives._segment_numba.flatten_numba`); this module +holds only the numpy label→index lookup that feeds it. +""" + +import numpy +import scipy.ndimage +from numpy.typing import NDArray + + +def label_to_idx_lut( + masks: NDArray[numpy.integer], +) -> tuple[NDArray[numpy.int64], int]: + """Build a ``label -> 0..n-1`` lookup over the sorted positive labels. + + Returns ``(lut, n)`` where ``lut[label]`` is the segment index (and ``-1`` + for absent labels / background) and ``n`` is the object count. Output arrays + are indexed by this segment rank, which equals ``label - 1`` when labels are + the contiguous ``1..n`` that cp_measure expects. + + Present labels are enumerated with ``scipy.ndimage.find_objects`` (one pass) + rather than ``numpy.unique`` (a full-image sort): same ascending label set, + identical LUT, ~3-5x faster on large/3D masks. + """ + if not numpy.issubdtype(masks.dtype, numpy.integer): + masks = masks.astype(numpy.intp, copy=False) + bboxes = scipy.ndimage.find_objects(masks) + labels = numpy.array( + [i + 1 for i, sl in enumerate(bboxes) if sl is not None], dtype=numpy.int64 + ) + n = int(labels.size) + max_label = int(labels[-1]) if n else 0 + lut = numpy.full(max_label + 1, -1, dtype=numpy.int64) + lut[labels] = numpy.arange(n, dtype=numpy.int64) + return lut, n + + +def labels_to_offsets( + masks: NDArray[numpy.integer], +) -> tuple[NDArray[numpy.int64], int, NDArray[numpy.int64]]: + """Build ``(lut, n, offsets)`` from a single ``np.bincount`` pass. + + Like :func:`label_to_idx_lut` but also returns the CSR ``offsets`` (length + ``n + 1``) giving each object's pixel block in the grouped flat order, so the + grouped flatten can scatter in a SINGLE scan instead of count-then-scatter. + + One ``bincount`` over the raster (~2x faster here than ``find_objects`` plus a + separate count scan) gives per-label pixel counts directly; ``cumsum`` of the + present-label counts is ``offsets``. ``lut`` and segment ordering (ascending + present labels) are identical to :func:`label_to_idx_lut`. + """ + if not numpy.issubdtype(masks.dtype, numpy.integer): + masks = masks.astype(numpy.intp, copy=False) + counts = numpy.bincount(masks.ravel()) + present = numpy.nonzero(counts[1:])[0] + 1 # positive labels actually present + n = int(present.size) + max_label = int(present[-1]) if n else 0 + lut = numpy.full(max_label + 1, -1, dtype=numpy.int64) + lut[present] = numpy.arange(n, dtype=numpy.int64) + offsets = numpy.zeros(n + 1, dtype=numpy.int64) + offsets[1:] = numpy.cumsum(counts[present]) + return lut, n, offsets diff --git a/src/cp_measure/primitives/shapes.py b/src/cp_measure/primitives/shapes.py new file mode 100644 index 0000000..0c94059 --- /dev/null +++ b/src/cp_measure/primitives/shapes.py @@ -0,0 +1,83 @@ +"""Canonical ``(B, Z, Y, X)`` input normalisation (numpy, backend-agnostic). + +Every optimised feature works on a batch of volumes. Rather than a single dense +``(B, Z, Y, X)`` array (which cannot hold ragged, differently-sized images), the +batch is represented as a **list of B ``(Z, Y, X)`` arrays** — equal-size and +ragged batches share one code path, and a single image is just ``B == 1``. + +Normalisation rules (the only public entry, :func:`to_bzyx`): + +================================ ============================ ======== +input yields batch? +================================ ============================ ======== +2D ``(H, W)`` ndarray ``[(1, H, W)]`` no +3D ``(Z, Y, X)`` ndarray ``[(Z, Y, X)]`` (one volume) no +4D ``(B, Z, Y, X)`` ndarray ``[(Z, Y, X)] * B`` yes +list/tuple of 2D/3D arrays one ``(Z, Y, X)`` per item yes +================================ ============================ ======== + +A 3D ndarray is therefore ALWAYS one volume, never a batch — this preserves the +existing single-volume semantics. To pass a batch of 2D images as an array, use +``(B, 1, H, W)``; for ragged sizes, pass a list. ``unwrap`` then re-shapes the +per-image results back to a single dict (single input) or the list (batch). + +This is a pure batch normaliser: ``masks`` and ``pixels`` must share the same +batch/ndim structure. It does NOT broadcast a lower-dimensional mask over a +volume (e.g. a 2D mask applied to a 3D stack) — per-element ndim handling is the +caller's job (each backend dispatches its own 2D vs 3D path). +""" + +import numpy +from numpy.typing import NDArray + + +def _to_zyx(arr: NDArray) -> NDArray: + """Promote a single 2D/3D image to ``(Z, Y, X)`` (2D gets a unit Z axis).""" + a = numpy.asarray(arr) + if a.ndim == 2: + return a[numpy.newaxis] + if a.ndim == 3: + return a + raise ValueError(f"expected a 2D or 3D image, got ndim={a.ndim}") + + +def to_bzyx(masks, pixels): + """Normalise ``(masks, pixels)`` to the canonical batch-of-volumes form. + + Returns ``(masks_zyx, pixels_zyx, unwrap)`` where ``masks_zyx`` and + ``pixels_zyx`` are length-``B`` lists of ``(Z, Y, X)`` arrays (one per image), + and ``unwrap(results)`` maps a length-``B`` list of per-image results back to + a single result (non-batch input) or the list itself (batch input). + """ + masks_is_seq = isinstance(masks, (list, tuple)) + pixels_is_seq = isinstance(pixels, (list, tuple)) + if masks_is_seq != pixels_is_seq: + raise ValueError("masks and pixels must both be sequences, or both arrays") + + if masks_is_seq: + masks_zyx = [_to_zyx(m) for m in masks] + pixels_zyx = [_to_zyx(p) for p in pixels] + is_batch = True + else: + m = numpy.asarray(masks) + p = numpy.asarray(pixels) + if (m.ndim == 4) != (p.ndim == 4): + raise ValueError("masks and pixels must both be 4D for a stacked batch") + if m.ndim == 4: + masks_zyx = list(m) + pixels_zyx = list(p) + is_batch = True + else: + masks_zyx = [_to_zyx(m)] + pixels_zyx = [_to_zyx(p)] + is_batch = False + + if len(masks_zyx) != len(pixels_zyx): + raise ValueError( + f"batch size mismatch: {len(masks_zyx)} masks vs {len(pixels_zyx)} images" + ) + + def unwrap(results): + return results if is_batch else results[0] + + return masks_zyx, pixels_zyx, unwrap diff --git a/test/test_backend_correctness.py b/test/test_backend_correctness.py new file mode 100644 index 0000000..a5eea1e --- /dev/null +++ b/test/test_backend_correctness.py @@ -0,0 +1,100 @@ +"""Backend correctness harness: numba `intensity` must match the numpy backend. + +Compares ``cp_measure.core.numba.get_intensity`` against the reference +``cp_measure.core.get_intensity`` key-by-key on continuous random pixels (no +exact ties, so ``Location_MaxIntensity_*`` is unambiguous), for 2D and 3D input +with edge measurements on and off. Also checks the dispatch wiring: under +``set_accelerator("numba")`` the core registry composes the numba intensity with +the numpy implementations of every other feature, and an absent numba backend +raises rather than silently falling back. +""" + +import numpy as np +import pytest +from conftest import ( + DEPTH_3D, + SIZE_2D, + SIZE_3D, + _stamp_objects_2d, + _stamp_objects_3d, + get_rng, +) + +import cp_measure +import cp_measure._detect +import cp_measure.bulk +from cp_measure._detect import HAS_NUMBA +from cp_measure.core.measureobjectintensity import get_intensity as intensity_numpy + +requires_numba = pytest.mark.skipif(not HAS_NUMBA, reason="numba not installed") + + +def _mask_pixels_2d(): + mask = np.zeros((SIZE_2D, SIZE_2D), dtype=np.int32) + _stamp_objects_2d(mask, n_objects=3) + return mask, get_rng().random((SIZE_2D, SIZE_2D)) + + +def _mask_pixels_3d(): + mask = np.zeros((DEPTH_3D, SIZE_3D, SIZE_3D), dtype=np.int32) + _stamp_objects_3d(mask, n_objects=2) + return mask, get_rng().random((DEPTH_3D, SIZE_3D, SIZE_3D)) + + +def _assert_dicts_match(ref, got): + assert set(got) == set(ref), set(got).symmetric_difference(ref) + for key in ref: + np.testing.assert_allclose( + got[key], ref[key], rtol=1e-6, atol=1e-8, err_msg=f"feature {key!r}" + ) + + +@requires_numba +@pytest.mark.parametrize("edge", [True, False], ids=["edge", "noedge"]) +@pytest.mark.parametrize("dim", ["2d", "3d"]) +def test_numba_intensity_matches_numpy(dim, edge): + from cp_measure.core.numba import get_intensity as intensity_numba + + mask, pixels = _mask_pixels_2d() if dim == "2d" else _mask_pixels_3d() + ref = intensity_numpy(mask, pixels, edge_measurements=edge) + got = intensity_numba(mask, pixels, edge_measurements=edge) + _assert_dicts_match(ref, got) + + +@requires_numba +def test_set_accelerator_numba_composes_with_numpy(): + cp_measure.set_accelerator("numba") + try: + core = cp_measure.bulk.get_core_measurements() + assert core["intensity"].__module__ == ( + "cp_measure.core.numba.measureobjectintensity" + ) + # The colocalization features route to the numba backend too. + corr = cp_measure.bulk.get_correlation_measurements() + for feature in ("pearson", "manders_fold", "rwc", "overlap"): + assert corr[feature].__module__ == ( + "cp_measure.core.numba.measurecolocalization" + ), feature + # costes stays on numpy until its follow-up lands. + assert corr["costes"].__module__ == "cp_measure.core.measurecolocalization" + # Every other feature stays on the numpy backend. + assert core["sizeshape"].__module__ == "cp_measure.core.measureobjectsizeshape" + assert core["texture"].__module__ == "cp_measure.core.measuretexture" + finally: + cp_measure.set_accelerator(None) + + restored = cp_measure.bulk.get_core_measurements() + assert restored["intensity"].__module__ == "cp_measure.core.measureobjectintensity" + # overlap is numba-only; the default registry does not expose it. + assert "overlap" not in cp_measure.bulk.get_correlation_measurements() + + +def test_set_accelerator_numba_absent_raises(monkeypatch): + """When find_spec reports numba absent, selecting it raises (no silent fallback).""" + monkeypatch.setattr(cp_measure._detect, "HAS_NUMBA", False) + cp_measure.set_accelerator("numba") + try: + with pytest.raises(RuntimeError, match="numba is not installed"): + cp_measure.bulk.get_core_measurements() + finally: + cp_measure.set_accelerator(None) diff --git a/test/test_coloc_backend.py b/test/test_coloc_backend.py new file mode 100644 index 0000000..6b80b3c --- /dev/null +++ b/test/test_coloc_backend.py @@ -0,0 +1,118 @@ +"""Backend correctness: numba colocalization must match the numpy backend. + +Compares the numba ``pearson``/``manders_fold``/``rwc``/``overlap`` against the +reference ``cp_measure.core.measurecolocalization`` key-by-key. Two value regimes +run: continuous random floats (generic path) and integer-VALUED floats in a small +range (forces RWC dense-rank ties). 2D and 3D, single image and batch (4D array + +ragged list) all run, since every bzyx path must hold. + +Pixels stay float64 deliberately. Feeding a genuine integer dtype to the *numpy* +reference triggers two baseline artifacts the numba backend does not reproduce +(it upcasts to float64): ``fi*si`` overflows in uint8 (so Overlap/K can exceed 1), +and the Pearson slope's ``lstsq`` design matrix is uint8 → a float32 result. The +numba float64 path is strictly more accurate; matching those artifacts is not a +goal. (costes, the follow-up, will exercise true integer dtypes for its scale.) +""" + +import numpy as np +import pytest +from conftest import ( + DEPTH_3D, + SIZE_2D, + SIZE_3D, + _stamp_objects_2d, + _stamp_objects_3d, + get_rng, +) + +import cp_measure.core.measurecolocalization as ref +from cp_measure._detect import HAS_NUMBA + +requires_numba = pytest.mark.skipif(not HAS_NUMBA, reason="numba not installed") + +FUNCS = ["pearson", "manders_fold", "rwc", "overlap"] + + +def _numba_fn(name): + import cp_measure.core.numba.measurecolocalization as nb + + return getattr(nb, f"get_correlation_{name}") + + +def _pixels(shape, regime): + rng = get_rng() + if regime == "cont": + return rng.random(shape), rng.random(shape) + # Integer-valued floats in a small range -> many RWC rank ties, no dtype artifact. + return ( + rng.integers(0, 8, shape).astype(np.float64), + rng.integers(0, 8, shape).astype(np.float64), + ) + + +def _data_2d(regime="cont"): + masks = np.zeros((SIZE_2D, SIZE_2D), np.int32) + _stamp_objects_2d(masks, n_objects=3) + p1, p2 = _pixels((SIZE_2D, SIZE_2D), regime) + return masks, p1, p2 + + +def _data_3d(regime="cont"): + masks = np.zeros((DEPTH_3D, SIZE_3D, SIZE_3D), np.int32) + _stamp_objects_3d(masks, n_objects=2) + p1, p2 = _pixels((DEPTH_3D, SIZE_3D, SIZE_3D), regime) + return masks, p1, p2 + + +def _assert_match(got, expected): + assert set(got) == set(expected), set(got).symmetric_difference(expected) + for key in expected: + np.testing.assert_allclose( + got[key], expected[key], rtol=1e-6, atol=1e-8, err_msg=f"feature {key!r}" + ) + + +@requires_numba +@pytest.mark.parametrize("name", FUNCS) +@pytest.mark.parametrize("regime", ["cont", "ties"]) +def test_matches_numpy_2d(name, regime): + masks, p1, p2 = _data_2d(regime) + expected = getattr(ref, f"get_correlation_{name}")(p1, p2, masks) + got = _numba_fn(name)(p1, p2, masks) + _assert_match(got, expected) + + +@requires_numba +@pytest.mark.parametrize("name", FUNCS) +def test_matches_numpy_3d(name): + masks, p1, p2 = _data_3d(float) + expected = getattr(ref, f"get_correlation_{name}")(p1, p2, masks) + got = _numba_fn(name)(p1, p2, masks) + _assert_match(got, expected) + + +@requires_numba +@pytest.mark.parametrize("name", FUNCS) +def test_batch_list_matches_per_image(name): + imgs = [_data_2d(float), _data_3d(float)] + masks = [m for m, _, _ in imgs] + p1 = [a for _, a, _ in imgs] + p2 = [b for _, _, b in imgs] + got = _numba_fn(name)(p1, p2, masks) + assert isinstance(got, list) and len(got) == 2 + for (m, a, b), per_image in zip(imgs, got): + _assert_match(per_image, getattr(ref, f"get_correlation_{name}")(a, b, m)) + + +@requires_numba +@pytest.mark.parametrize("name", FUNCS) +def test_4d_batch_matches_per_image(name): + m0, a0, b0 = _data_2d("cont") + m1, a1, b1 = _data_2d("ties") + masks = np.stack([m0[np.newaxis], m1[np.newaxis]]) # (2, 1, H, W) + p1 = np.stack([a0[np.newaxis], a1[np.newaxis]]) + p2 = np.stack([b0[np.newaxis], b1[np.newaxis]]) + got = _numba_fn(name)(p1, p2, masks) + assert isinstance(got, list) and len(got) == 2 + for per_image, (m, a, b) in zip(got, [(m0, a0, b0), (m1, a1, b1)]): + _assert_match(per_image, getattr(ref, f"get_correlation_{name}")(a, b, m)) diff --git a/test/test_coloc_kernels.py b/test/test_coloc_kernels.py new file mode 100644 index 0000000..0239979 --- /dev/null +++ b/test/test_coloc_kernels.py @@ -0,0 +1,77 @@ +"""Unit tests for the colocalization numba primitives.""" + +import numpy as np +import pytest +import scipy.stats + +from cp_measure._detect import HAS_NUMBA + +requires_numba = pytest.mark.skipif(not HAS_NUMBA, reason="numba not installed") + + +@requires_numba +def test_flatten_pairs_grouped_blocks(): + from cp_measure.primitives._segment_numba import flatten_pairs_grouped + from cp_measure.primitives.segment import labels_to_offsets + + masks = np.array([[0, 1, 1], [2, 2, 0], [2, 0, 1]], np.int64) + p1 = np.arange(9, dtype=np.float64).reshape(3, 3) + p2 = (np.arange(9, dtype=np.float64) * 10).reshape(3, 3) + lut, n, offsets = labels_to_offsets(masks[np.newaxis]) + g1, g2 = flatten_pairs_grouped( + masks[np.newaxis], p1[np.newaxis], p2[np.newaxis], lut, offsets + ) + + assert n == 2 + assert list(offsets) == [0, 3, 6] # label 1 has 3 px, label 2 has 3 px + # Each object's block is exactly the (channel-aligned) masked pixels. + assert sorted(g1[0:3]) == sorted(p1[masks == 1]) + assert sorted(g2[3:6]) == sorted(p2[masks == 2]) + # g1/g2 stay co-registered: g2 == g1 * 10 elementwise everywhere. + np.testing.assert_array_equal(g2, g1 * 10) + + +@requires_numba +def test_flatten_pairs_grouped_keeps_nonfinite(): + """Reference extracts pixels[mask] with no finiteness filter — match it.""" + from cp_measure.primitives._segment_numba import flatten_pairs_grouped + from cp_measure.primitives.segment import labels_to_offsets + + masks = np.array([[1, 1, 1]], np.int64) + p1 = np.array([[1.0, np.nan, 3.0]]) + p2 = np.array([[1.0, 2.0, np.inf]]) + lut, n, offsets = labels_to_offsets(masks[np.newaxis]) + g1, g2 = flatten_pairs_grouped( + masks[np.newaxis], p1[np.newaxis], p2[np.newaxis], lut, offsets + ) + assert offsets[-1] == 3 # all three pixels kept + assert np.isnan(g1).sum() == 1 and np.isinf(g2).sum() == 1 + + +@pytest.mark.parametrize("labels", [[0, 1, 2, 3], [0, 2, 5, 5, 2], [0, 0, 0]]) +def test_labels_to_offsets_agrees_with_lut(labels): + """bincount-based (lut, n) == find_objects-based; offsets are the CSR counts.""" + from cp_measure.primitives.segment import label_to_idx_lut, labels_to_offsets + + masks = np.array(labels, np.int64).reshape(1, 1, -1) + lut_ref, n_ref = label_to_idx_lut(masks) + lut, n, offsets = labels_to_offsets(masks) + assert n == n_ref + np.testing.assert_array_equal(lut, lut_ref) + assert offsets[0] == 0 and offsets[-1] == int((masks > 0).sum()) + np.testing.assert_array_equal(np.diff(offsets), np.bincount(lut[masks[masks > 0]])) + + +@requires_numba +@pytest.mark.parametrize("seed", [0, 1, 2]) +def test_dense_rank_matches_scipy(seed): + """0-based dense rank == scipy.stats.rankdata(method='dense') - 1, with ties.""" + from cp_measure.core.numba._colocalization import _dense_rank + + rng = np.random.default_rng(seed) + # Small integer range forces ties. + vals = rng.integers(0, 5, size=40).astype(np.float64) + ranks, rmax = _dense_rank(vals) + expected = scipy.stats.rankdata(vals, method="dense") - 1 + np.testing.assert_array_equal(ranks, expected) + assert rmax == expected.max() diff --git a/test/test_primitives_shapes.py b/test/test_primitives_shapes.py new file mode 100644 index 0000000..0584e41 --- /dev/null +++ b/test/test_primitives_shapes.py @@ -0,0 +1,80 @@ +"""Tests for the canonical (B,Z,Y,X) input normalisation helper.""" + +import numpy +import pytest + +from cp_measure.primitives.shapes import to_bzyx + + +def _img(shape, seed): + rng = numpy.random.default_rng(seed) + return rng.random(shape) + + +def test_2d_is_single_unit_z(): + m, p = numpy.ones((4, 5), int), _img((4, 5), 0) + masks, pixels, unwrap = to_bzyx(m, p) + assert len(masks) == len(pixels) == 1 + assert masks[0].shape == (1, 4, 5) + assert pixels[0].shape == (1, 4, 5) + # single input -> unwrap returns the lone result, not a list + assert unwrap(["only"]) == "only" + + +def test_3d_is_single_volume_not_batch(): + m, p = numpy.ones((3, 4, 5), int), _img((3, 4, 5), 1) + masks, pixels, unwrap = to_bzyx(m, p) + assert len(masks) == 1 + assert masks[0].shape == (3, 4, 5) # one volume, Z preserved + assert unwrap(["d"]) == "d" + + +def test_4d_is_batch(): + m, p = numpy.ones((2, 3, 4, 5), int), _img((2, 3, 4, 5), 2) + masks, pixels, unwrap = to_bzyx(m, p) + assert len(masks) == len(pixels) == 2 + assert all(a.shape == (3, 4, 5) for a in masks) + out = unwrap([{"a": 1}, {"a": 2}]) + assert isinstance(out, list) and len(out) == 2 # batch -> list + + +def test_list_of_2d_is_batch_unit_z(): + imgs = [_img((4, 5), i) for i in range(3)] + masks = [numpy.ones((4, 5), int) for _ in range(3)] + m, p, unwrap = to_bzyx(masks, imgs) + assert len(m) == 3 + assert all(a.shape == (1, 4, 5) for a in m) + assert isinstance(unwrap([1, 2, 3]), list) + + +def test_list_ragged_sizes_ok(): + imgs = [_img((4, 5), 0), _img((7, 3), 1)] + masks = [numpy.ones((4, 5), int), numpy.ones((7, 3), int)] + m, p, _ = to_bzyx(masks, imgs) + assert m[0].shape == (1, 4, 5) + assert m[1].shape == (1, 7, 3) + + +def test_list_of_3d_volumes_batch(): + vols = [_img((2, 4, 5), 0), _img((3, 4, 5), 1)] + masks = [numpy.ones((2, 4, 5), int), numpy.ones((3, 4, 5), int)] + m, _, _ = to_bzyx(masks, vols) + assert m[0].shape == (2, 4, 5) and m[1].shape == (3, 4, 5) + + +@pytest.mark.parametrize( + "masks, pixels, match", + [ + ( + [numpy.ones((4, 5), int)], + [_img((4, 5), 0), _img((4, 5), 1)], + "batch size mismatch", + ), + ([numpy.ones((4, 5), int)], _img((4, 5), 0), "both be sequences"), + (numpy.ones((2, 3, 4, 5), int), _img((3, 4, 5), 0), "both be 4D"), + (numpy.ones((5,), int), numpy.ones((5,), float), "2D or 3D"), + ], +) +def test_to_bzyx_raises(masks, pixels, match): + with pytest.raises(ValueError, match=match): + to_bzyx(masks, pixels) diff --git a/uv.lock b/uv.lock index da478cd..9b66ae0 100644 --- a/uv.lock +++ b/uv.lock @@ -537,6 +537,9 @@ benchmark = [ { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "tifffile", version = "2025.10.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +numba = [ + { name = "numba" }, +] test = [ { name = "pytest" }, { name = "pytest-cov" }, @@ -558,6 +561,7 @@ dev = [ requires-dist = [ { name = "centrosome", specifier = ">=1.3.3" }, { name = "mahotas", specifier = ">=1.4.13,<2.0.0" }, + { name = "numba", marker = "extra == 'numba'", specifier = ">=0.59" }, { name = "numpy", specifier = ">=1.22.1" }, { name = "pyarrow", marker = "extra == 'benchmark'", specifier = ">=14.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.2" }, @@ -567,7 +571,7 @@ requires-dist = [ { name = "scipy", specifier = ">=1.9.1" }, { name = "tifffile", marker = "extra == 'benchmark'", specifier = ">=2025.5.10" }, ] -provides-extras = ["test", "benchmark"] +provides-extras = ["numba", "test", "benchmark"] [package.metadata.requires-dev] dev = [ @@ -1324,6 +1328,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, ] +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/f5/a1bde3aa8c43524b0acaf3f72fb3d80a32dd29dbb42d7dc434f84584cdcc/llvmlite-0.47.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41270b0b1310717f717cf6f2a9c68d3c43bd7905c33f003825aebc361d0d1b17", size = 37232772, upload-time = "2026-03-31T18:28:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fb/76d88fc05ee1f9c1a6efe39eb493c4a727e5d1690412469017cd23bcb776/llvmlite-0.47.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f9d118bc1dd7623e0e65ca9ac485ec6dd543c3b77bc9928ddc45ebd34e1e30a7", size = 56275179, upload-time = "2026-03-31T18:28:15.725Z" }, + { url = "https://files.pythonhosted.org/packages/4d/08/29da7f36217abd56a0c389ef9a18bea47960826e691ced1a36c92c6ce93c/llvmlite-0.47.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea5cfb04a6ab5b18e46be72b41b015975ba5980c4ddb41f1975b83e19031063", size = 55128632, upload-time = "2026-03-31T18:28:19.946Z" }, + { url = "https://files.pythonhosted.org/packages/df/f8/5e12e9ed447d65f04acf6fcf2d79cded2355640b5131a46cee4c99a5949d/llvmlite-0.47.0-cp310-cp310-win_amd64.whl", hash = "sha256:166b896a2262a2039d5fc52df5ee1659bd1ccd081183df7a2fba1b74702dd5ea", size = 38138402, upload-time = "2026-03-31T18:28:23.327Z" }, + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + [[package]] name = "mahotas" version = "1.4.18" @@ -1683,6 +1719,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/95/4df134a100b5a9a12378d5301b934366686ef6fbdaffcd21211d5654970e/nox-2026.4.10-py3-none-any.whl", hash = "sha256:082c117627590d9b90aa21f86df89b310b07c5842539524203bcb3c719f116c1", size = 75536, upload-time = "2026-04-10T17:42:40.664Z" }, ] +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1b/3c5a7daf683a95465bf23504bcd1a2d5db8cd5e5e276ca87505d020dffe9/numba-0.65.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:9d993ed0a257aa4116e6f553f114004bcfdee540c7276ab8ea48f650d514c452", size = 2680870, upload-time = "2026-04-24T02:02:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/1831836814018a898e7d252aebe09c0f3ce1f26d145b68264b4ae0be6822/numba-0.65.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f098109f361681e57295f7e84d8ab2426902539a141811de0703ace52826981", size = 3739780, upload-time = "2026-04-24T02:02:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1b/a813ddc81def09e257d2b1f67521982ce4b06204a87268796ffc8187271c/numba-0.65.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973fd8173f2312815e6b7aaae887c4ce8a817eeff46a4f8840b828305b75bc95", size = 3446722, upload-time = "2026-04-24T02:02:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/ee1d8b3becda384fe0552221641e05aa668a35e8a77470db4db7f6475000/numba-0.65.1-cp310-cp310-win_amd64.whl", hash = "sha256:c63aa0c4193694026452da55d0ef9d85156c1a7a333454c103bb30dec81b7bf8", size = 2747539, upload-time = "2026-04-24T02:02:16.79Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + [[package]] name = "numpy" version = "1.26.4"