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..ce043e5 100644 --- a/src/cp_measure/bulk.py +++ b/src/cp_measure/bulk.py @@ -45,6 +45,32 @@ _3D_FEATURES = ("intensity", "sizeshape", "texture", "granularity") +def _numba_registries() -> dict[str, dict[str, Callable]]: + """Registries for the 'numba' accelerator. + + Composes the numba implementations (``intensity``, ``zernike``, + ``radial_zernikes``) 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. + """ + from cp_measure.core.numba import ( + get_intensity as _numba_intensity, + get_radial_zernikes as _numba_radial_zernikes, + get_zernike as _numba_zernike, + ) + + return { + "core": { + **_CORE, + "intensity": _numba_intensity, + "zernike": _numba_zernike, + "radial_zernikes": _numba_radial_zernikes, + }, + "correlation": _CORRELATION, + } + + def _dispatch(name: str) -> dict[str, Callable]: from cp_measure import _ACCELERATOR @@ -55,9 +81,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..c7198c7 --- /dev/null +++ b/src/cp_measure/core/numba/__init__.py @@ -0,0 +1,22 @@ +"""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``, ``zernike`` and ``radial_zernikes``; the +global "numba" accelerator composes them with the numpy implementations of every +other feature (see ``cp_measure.bulk``). +""" + +from cp_measure.core.numba.measureobjectintensity import get_intensity +from cp_measure.core.numba.measureobjectintensitydistribution import ( + get_radial_zernikes, +) +from cp_measure.core.numba.measureobjectsizeshape import get_zernike + +__all__ = [ + "get_intensity", + "get_radial_zernikes", + "get_zernike", +] diff --git a/src/cp_measure/core/numba/_zernike.py b/src/cp_measure/core/numba/_zernike.py new file mode 100644 index 0000000..d2a6e0f --- /dev/null +++ b/src/cp_measure/core/numba/_zernike.py @@ -0,0 +1,140 @@ +"""Numba Zernike-moment kernel (fused basis-eval + weighted-complex segment-sum). + +Both Zernike features — shape (`get_zernike`, weight=1) and intensity-weighted +(`get_radial_zernikes`, weight=pixel) — reduce to the same per-object +weighted-complex sum of the Zernike polynomial basis ``V_nm`` over the object's +pixels. This module fuses the basis evaluation and the segment-sum into one +single-pass ``@njit`` kernel (no ``(M, K)`` intermediate). + +The polynomial *coefficients* come from centrosome (``construct_zernike_lookuptable``, +degree-only, cheap) via :func:`zernike_coeffs`; the per-pixel evaluation replicates +``centrosome.zernike.construct_zernike_polynomials`` exactly: + +- radial part ``R_nm(r)/r^m`` via Horner over ``r² = xm²+ym²`` using the LUT row, + looping exactly ``(n-m)//2 + 1`` terms (forward order, LUT[k,0] = highest power), +- azimuthal factor ``z^m`` with ``z = ym + 1j*xm`` (ym = normalised row offset, + xm = normalised column offset), +- strict unit-disk cutoff: zero where ``r² > 1`` (keep ``== 1``). + +``minimum_enclosing_circle`` (center/radius) and label enumeration stay on the host. +""" + +import centrosome.cpmorphology +import centrosome.zernike +import numpy as np +from numba import njit +from numpy.typing import NDArray + +from cp_measure.primitives.segment import label_to_idx_lut + + +def zernike_coeffs( + zernike_indexes: NDArray[np.integer], +) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]: + """Repack centrosome's radial LUT for the Horner kernel. + + Returns ``(lut, nterms, m_arr)`` where ``lut[k, :]`` are the radial-polynomial + coefficients of index ``k`` (highest power first), ``nterms[k] = (n-m)//2 + 1`` + is exactly how many of them to consume in the Horner loop (trailing LUT entries + are zero and must NOT be iterated — they would spuriously multiply by r²), and + ``m_arr[k]`` is the azimuthal order ``m``. + """ + lut = centrosome.zernike.construct_zernike_lookuptable(zernike_indexes).astype( + np.float64 + ) + n = zernike_indexes[:, 0].astype(np.int64) + m_arr = zernike_indexes[:, 1].astype(np.int64) + nterms = ((n - m_arr) // 2 + 1).astype(np.int64) + return lut, nterms, m_arr + + +@njit(cache=True) +def zernike_moments(weights, xm, ym, seg0, lut, nterms, m_arr, n): + """Fused per-object weighted-complex Zernike sum (single pass, no (M,K) buffer). + + For each pixel: evaluate every polynomial's ``V_nm`` from ``lut``/``m_arr`` + (Horner over r² + ``z^m``, ``z = ym + 1j*xm``; skip the pixel when ``r² > 1``) + and scatter-add ``weight·Re/Im`` into ``vr``/``vi`` ``(n, K)``. ``seg0[i]`` is the + 0-based object index of pixel ``i`` (``< 0`` ⇒ skip). Returns ``(vr, vi)``. + """ + K = lut.shape[0] + M = xm.shape[0] + vr = np.zeros((n, K)) + vi = np.zeros((n, K)) + max_m = 0 + for k in range(K): + if m_arr[k] > max_m: + max_m = m_arr[k] + zr = np.empty(max_m + 1) + zi = np.empty(max_m + 1) + for i in range(M): + seg = seg0[i] + if seg < 0: + continue + x = xm[i] + y = ym[i] + r2 = x * x + y * y + if r2 > 1.0: # whole basis is 0 outside the unit disk (strict cutoff) + continue + w = weights[i] + zr[0] = 1.0 + zi[0] = 0.0 + for mm in range(1, max_m + 1): # z^mm = z^(mm-1) * (y + i x) + zr[mm] = zr[mm - 1] * y - zi[mm - 1] * x + zi[mm] = zr[mm - 1] * x + zi[mm - 1] * y + for k in range(K): + s = 0.0 + nt = nterms[k] + for t in range(nt): # Horner: R_nm(r)/r^m + s = s * r2 + lut[k, t] + m = m_arr[k] + vr[seg, k] += w * (s * zr[m]) + vi[seg, k] += w * (s * zi[m]) + return vr, vi + + +def zernike_moments_per_object(labels, pixels, coeffs): + """Per-object weighted-complex Zernike sum shared by both 2D backends. + + Does the part identical to shape and radial Zernikes: enumerate objects, + find the per-object minimum enclosing circle, normalise each labeled pixel's + coordinates into the unit disk (``ym`` from rows, ``xm`` from cols), and run the + fused kernel. ``pixels`` is the intensity image for the weighted (radial) case + or ``None`` for the unweighted (shape) case. ``coeffs`` is :func:`zernike_coeffs`. + + Returns ``(vr, vi, radii, seg0)``: ``vr``/``vi`` are ``(n, K)`` Re/Im sums, + ``radii`` ``(n,)`` the enclosing-circle radii (shape's ``π·r²`` denominator), + ``seg0`` ``(M,)`` the 0-based object index per pixel (radial's pixel-count + denominator via ``bincount``). ``n == vr.shape[0]`` is 0 when there are no objects. + """ + lut, nterms, m_arr = coeffs + K = lut.shape[0] + label_lut, n = label_to_idx_lut(labels) + if n == 0: + z = np.zeros((0, K)) + return z, z, np.zeros(0), np.zeros(0, np.int64) + + unique_labels = np.flatnonzero(label_lut >= 0) # present labels, ascending + centers, radii = centrosome.cpmorphology.minimum_enclosing_circle( + labels, unique_labels + ) + rows, cols = np.nonzero(labels) + seg0 = label_lut[labels[rows, cols]] # int64 (label_lut is int64), 0-based rank + ym = (rows - centers[seg0, 0]) / radii[seg0] # normalised row offset + xm = (cols - centers[seg0, 1]) / radii[seg0] # normalised column offset + weights = ( + np.ones(seg0.shape[0]) + if pixels is None + else pixels[rows, cols].astype(np.float64) + ) + vr, vi = zernike_moments( + weights, + np.ascontiguousarray(xm), + np.ascontiguousarray(ym), + seg0, + lut, + nterms, + m_arr, + n, + ) + return vr, vi, radii, seg0 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/core/numba/measureobjectintensitydistribution.py b/src/cp_measure/core/numba/measureobjectintensitydistribution.py new file mode 100644 index 0000000..b8255d1 --- /dev/null +++ b/src/cp_measure/core/numba/measureobjectintensitydistribution.py @@ -0,0 +1,69 @@ +"""Numba-accelerated radial Zernike backend. + +Mirrors :func:`cp_measure.core.measureobjectintensitydistribution.get_radial_zernikes` +but replaces the per-(n,m) ``scipy.ndimage.sum_labels`` AND centrosome's per-pixel +``construct_zernike_polynomials`` with a single fused numba kernel +(:func:`cp_measure.core.numba._zernike.zernike_moments`) that evaluates the basis +and the per-object weighted-complex sum in one pass. Centrosome still provides the +radial LUT (degree-only) and ``minimum_enclosing_circle`` (host). + +Batch-shaped via the canonical ``(B, Z, Y, X)`` form (single image = ``B == 1``); +2D-only (a ``Z > 1`` volume returns ``{}``, matching the baseline's ``ndim == 3``). +""" + +import centrosome.zernike +import numpy +from numpy.typing import NDArray + +from cp_measure.core.measureobjectintensitydistribution import M_CATEGORY +from cp_measure.core.numba._zernike import zernike_coeffs, zernike_moments_per_object +from cp_measure.primitives.shapes import to_bzyx + + +def get_radial_zernikes(masks, pixels, zernike_degree: int = 9): + """Radial Zernike magnitude/phase per object; single image/volume or batch.""" + masks_zyx, pixels_zyx, unwrap = to_bzyx(masks, pixels) + # degree-only work, hoisted out of the per-image loop (computed once per batch) + zernike_indexes = centrosome.zernike.get_zernike_indexes(zernike_degree + 1) + coeffs = zernike_coeffs(zernike_indexes) + results = [ + _radial_zernikes_2d(m, p, zernike_indexes, coeffs) + for m, p in zip(masks_zyx, pixels_zyx) + ] + return unwrap(results) + + +def _radial_zernikes_2d( + labels_zyx: NDArray[numpy.integer], + pixels_zyx: NDArray[numpy.floating], + zernike_indexes: NDArray[numpy.integer], + coeffs: tuple, +) -> dict[str, NDArray[numpy.floating]]: + if labels_zyx.shape[0] > 1: # Z > 1 -> 3D volume; baseline returns {} for ndim==3 + return {} + + vr, vi, _radii, seg0 = zernike_moments_per_object( + labels_zyx[0], pixels_zyx[0], coeffs + ) + if ( + vr.shape[0] == 0 + ): # no objects (fringe) -> empty arrays per key, matches baseline + return { + f"{M_CATEGORY}_Zernike{mag_or_phase}_{nn}_{mm}": numpy.zeros(0) + for mag_or_phase in ("Magnitude", "Phase") + for nn, mm in zernike_indexes + } + + areas = numpy.bincount(seg0, minlength=vr.shape[0]).astype( + numpy.float64 + ) # px/object + results = {} + for i, (nn, mm) in enumerate(zernike_indexes): + results[f"{M_CATEGORY}_ZernikeMagnitude_{nn}_{mm}"] = ( + numpy.sqrt(vr[:, i] ** 2 + vi[:, i] ** 2) / areas + ) + # baseline's (real, imag) arctan2 arg order + results[f"{M_CATEGORY}_ZernikePhase_{nn}_{mm}"] = numpy.arctan2( + vr[:, i], vi[:, i] + ) + return results diff --git a/src/cp_measure/core/numba/measureobjectsizeshape.py b/src/cp_measure/core/numba/measureobjectsizeshape.py new file mode 100644 index 0000000..10c8bca --- /dev/null +++ b/src/cp_measure/core/numba/measureobjectsizeshape.py @@ -0,0 +1,57 @@ +"""Numba-accelerated shape-Zernike backend (`get_zernike`). + +Inlines ``centrosome.zernike.zernike``'s flow (the baseline delegates wholesale to +it) but replaces the per-(n,m) ``scipy.ndimage.sum`` + centrosome's per-pixel +polynomial construction with the single fused kernel +(:func:`cp_measure.core.numba._zernike.zernike_moments`). This is the UNWEIGHTED +case (binary shape, ``weights ≡ 1``); the normalisation denominator is the +enclosing-circle area ``π·r²`` (vs the radial backend's pixel count), and only the +magnitude is emitted. + +Centrosome still supplies the radial LUT (degree-only) and ``minimum_enclosing_circle``. +Batch-shaped via ``to_bzyx`` (single = ``B == 1``); 2D-only (``Z > 1`` → ``{}``). +""" + +import centrosome.zernike +import numpy +from numpy.typing import NDArray + +from cp_measure.core.numba._zernike import zernike_coeffs, zernike_moments_per_object +from cp_measure.primitives.shapes import to_bzyx + + +def get_zernike(masks, pixels=None, zernike_numbers: int = 9): + """Shape Zernike magnitudes per object; single image/volume or batch. + + ``pixels`` is accepted for signature parity with the numpy baseline but unused + (shape Zernike is a binary-mask descriptor), so masks alone drive the batch + normalisation. + """ + masks_zyx, _, unwrap = to_bzyx(masks, masks) + # degree-only work, hoisted out of the per-image loop (computed once per batch) + zernike_indexes = centrosome.zernike.get_zernike_indexes(zernike_numbers + 1) + coeffs = zernike_coeffs(zernike_indexes) + results = [_zernike_2d(m, zernike_indexes, coeffs) for m in masks_zyx] + return unwrap(results) + + +def _zernike_2d( + labels_zyx: NDArray[numpy.integer], + zernike_indexes: NDArray[numpy.integer], + coeffs: tuple, +) -> dict[str, NDArray[numpy.floating]]: + if labels_zyx.shape[0] > 1: # Z > 1 -> 3D volume; baseline returns {} for ndim==3 + return {} + + # shape Zernike is unweighted (binary mask) -> pixels=None + vr, vi, radii, _seg0 = zernike_moments_per_object(labels_zyx[0], None, coeffs) + if vr.shape[0] == 0: # no objects (baseline crashes here; we return empty per key) + return {f"Zernike_{nn}_{mm}": numpy.zeros(0) for nn, mm in zernike_indexes} + + areas = numpy.pi * numpy.asarray(radii, dtype=float) ** 2 # enclosing-circle area + results = {} + for i, (nn, mm) in enumerate(zernike_indexes): + results[f"Zernike_{nn}_{mm}"] = ( + numpy.sqrt(vr[:, i] ** 2 + vi[:, i] ** 2) / areas + ) + return results 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..ffb9148 --- /dev/null +++ b/src/cp_measure/primitives/_segment_numba.py @@ -0,0 +1,228 @@ +"""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 diff --git a/src/cp_measure/primitives/segment.py b/src/cp_measure/primitives/segment.py new file mode 100644 index 0000000..e5b5894 --- /dev/null +++ b/src/cp_measure/primitives/segment.py @@ -0,0 +1,39 @@ +"""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 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..a7d5817 --- /dev/null +++ b/test/test_backend_correctness.py @@ -0,0 +1,96 @@ +"""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" + ) + assert core["zernike"].__module__ == ( + "cp_measure.core.numba.measureobjectsizeshape" + ) + assert core["radial_zernikes"].__module__ == ( + "cp_measure.core.numba.measureobjectintensitydistribution" + ) + # 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" + + +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_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/test/test_zernike_backend.py b/test/test_zernike_backend.py new file mode 100644 index 0000000..80bbf1d --- /dev/null +++ b/test/test_zernike_backend.py @@ -0,0 +1,117 @@ +"""Correctness of the numba Zernike backends vs the numpy baseline.""" + +import numpy +import pytest + +from cp_measure.core.measureobjectintensitydistribution import ( + get_radial_zernikes as rz_numpy, +) +from cp_measure.core.measureobjectsizeshape import get_zernike as z_numpy +from cp_measure.core.numba.measureobjectintensitydistribution import ( + get_radial_zernikes as rz_numba, +) +from cp_measure.core.numba.measureobjectsizeshape import get_zernike as z_numba + +TOL = dict(rtol=1e-6, atol=1e-8, equal_nan=True) + + +def _scene(H=80, W=80, n=4, seed=0): + rng = numpy.random.default_rng(seed) + pixels = rng.random((H, W)) + 0.3 * numpy.add.outer( + numpy.sin(numpy.arange(H) / 4), numpy.cos(numpy.arange(W) / 5) + ) + mask = numpy.zeros((H, W), dtype=numpy.int32) + step = H // (n + 1) + for k in range(n): + y0 = step * (k + 1) - 6 + x0 = 8 + 15 * k + mask[y0 : y0 + 12, x0 : x0 + 12] = k + 1 + return mask, pixels + + +def _assert_same(a, b): + assert a.keys() == b.keys() + for k in a: + numpy.testing.assert_allclose(a[k], b[k], **TOL, err_msg=f"key {k}") + + +def test_radial_zernikes_matches_baseline(): + mask, pixels = _scene() + _assert_same(rz_numba(mask, pixels), rz_numpy(mask, pixels)) + + +def test_radial_zernikes_single_object(): + mask = numpy.zeros((50, 50), numpy.int32) + mask[15:35, 15:35] = 1 + pixels = numpy.random.default_rng(1).random((50, 50)) + _assert_same(rz_numba(mask, pixels), rz_numpy(mask, pixels)) + + +def test_radial_zernikes_label_gaps_numba_is_robust(): + # cp_measure masks are contiguous 1..n; on a GAP the numpy baseline raises + # (it indexes the per-object center array by `label-1`). Our compacted + # searchsorted indexing handles gaps instead of crashing -- strictly more + # robust, so there's no baseline value to match here; just assert it works. + mask = numpy.zeros((60, 60), numpy.int32) + mask[5:20, 5:20] = 1 + mask[35:55, 35:55] = 3 # gap at label 2 + pixels = numpy.random.default_rng(2).random((60, 60)) + with pytest.raises(IndexError): + rz_numpy(mask, pixels) + out = rz_numba(mask, pixels) + assert ( + len(out["RadialDistribution_ZernikeMagnitude_0_0"]) == 2 + ) # two present objects + + +def test_radial_zernikes_single_returns_dict(): + mask, pixels = _scene() + assert isinstance(rz_numba(mask, pixels), dict) + + +def test_radial_zernikes_batch_matches_per_image(): + scenes = [_scene(seed=s) for s in range(3)] + masks = numpy.stack([m[numpy.newaxis] for m, _ in scenes]) + pixels = numpy.stack([p[numpy.newaxis] for _, p in scenes]) + out = rz_numba(masks, pixels) + assert isinstance(out, list) and len(out) == 3 + for (m, p), got in zip(scenes, out): + _assert_same(got, rz_numpy(m, p)) + + +def test_radial_zernikes_3d_returns_empty(): + mask = numpy.zeros((3, 40, 40), numpy.int32) + mask[:, 10:25, 10:25] = 1 + pixels = numpy.random.default_rng(5).random((3, 40, 40)) + assert rz_numba(mask, pixels) == {} + + +# --- shape zernike (get_zernike) --- + + +def test_zernike_matches_baseline(): + mask, pixels = _scene() + _assert_same(z_numba(mask, pixels), z_numpy(mask, pixels)) + + +def test_zernike_single_object(): + mask = numpy.zeros((50, 50), numpy.int32) + mask[15:35, 15:35] = 1 + pixels = numpy.random.default_rng(1).random((50, 50)) + _assert_same(z_numba(mask, pixels), z_numpy(mask, pixels)) + + +def test_zernike_batch_matches_per_image(): + scenes = [_scene(seed=s) for s in range(3)] + masks = numpy.stack([m[numpy.newaxis] for m, _ in scenes]) + pixels = numpy.stack([p[numpy.newaxis] for _, p in scenes]) + out = z_numba(masks, pixels) + assert isinstance(out, list) and len(out) == 3 + for (m, _p), got in zip(scenes, out): + _assert_same(got, z_numpy(m, None)) + + +def test_zernike_3d_returns_empty(): + mask = numpy.zeros((3, 40, 40), numpy.int32) + mask[:, 10:25, 10:25] = 1 + assert z_numba(mask, None) == {} diff --git a/test/test_zernike_kernels.py b/test/test_zernike_kernels.py new file mode 100644 index 0000000..eb3eec4 --- /dev/null +++ b/test/test_zernike_kernels.py @@ -0,0 +1,104 @@ +"""Lock the Zernike conventions: our basis eval must match centrosome exactly.""" + +import centrosome.zernike +import numpy +import pytest + +from cp_measure.core.numba._zernike import zernike_coeffs, zernike_moments + + +def _zernike_basis_numpy(xm, ym, lut, nterms, m_arr): + """Numpy reference for the per-pixel basis ``V_nm`` (mirrors centrosome). + + The independent re-derivation used to lock the conventions and validate the + fused kernel. ``xm``/``ym`` are flat ``(M,)`` normalised column/row offsets; + returns ``(M, K)`` complex. + """ + M = xm.shape[0] + K = lut.shape[0] + r2 = xm * xm + ym * ym + z = ym + 1j * xm + out = numpy.zeros((M, K), dtype=complex) + for k in range(K): + s = numpy.zeros(M) + for t in range(int(nterms[k])): + s = s * r2 + lut[k, t] + s[r2 > 1] = 0 + m = int(m_arr[k]) + out[:, k] = s if m == 0 else s * (z**m) + return out + + +@pytest.mark.parametrize("degree", [3, 6, 9]) +def test_basis_matches_centrosome(degree): + indexes = centrosome.zernike.get_zernike_indexes(degree + 1) + rng = numpy.random.default_rng(degree) + # normalised coords spanning inside, on, and outside the unit disk + xm = rng.uniform(-1.3, 1.3, size=400) + ym = rng.uniform(-1.3, 1.3, size=400) + + # centrosome: construct_zernike_polynomials(x=col, y=row) -> (M, K) complex + ref = centrosome.zernike.construct_zernike_polynomials(xm, ym, indexes) + + lut, nterms, m_arr = zernike_coeffs(indexes) + got = _zernike_basis_numpy(xm, ym, lut, nterms, m_arr) + + assert got.shape == ref.shape + numpy.testing.assert_array_equal(got, ref) # identical ops -> bit-exact + + +def test_disk_cutoff_strict(): + # r^2 == 1 exactly is KEPT (centrosome uses strict >), r^2 > 1 is zeroed + indexes = centrosome.zernike.get_zernike_indexes(4) + lut, nterms, m_arr = zernike_coeffs(indexes) + xm = numpy.array([1.0, 0.0, 0.8]) # r^2 = 1.0 (kept), 0 (kept), 0.64+? + ym = numpy.array([0.0, 1.0, 0.8]) # second r^2=1 kept; third r^2=1.28 zeroed + got = _zernike_basis_numpy(xm, ym, lut, nterms, m_arr) + ref = centrosome.zernike.construct_zernike_polynomials(xm, ym, indexes) + numpy.testing.assert_array_equal(got, ref) + # the n=0,m=0 polynomial is the radial constant: kept where r^2<=1, 0 where >1 + assert got[0, 0] != 0 and got[1, 0] != 0 and got[2, 0] == 0 + + +def test_nterms_matches_index_structure(): + indexes = centrosome.zernike.get_zernike_indexes(10) + _, nterms, m_arr = zernike_coeffs(indexes) + n = indexes[:, 0] + numpy.testing.assert_array_equal(nterms, (n - m_arr) // 2 + 1) + + +@pytest.mark.parametrize("weighted", [False, True], ids=["shape", "intensity"]) +def test_zernike_moments_matches_basis_plus_segment_sum(weighted): + indexes = centrosome.zernike.get_zernike_indexes(10) + lut, nterms, m_arr = zernike_coeffs(indexes) + rng = numpy.random.default_rng(1 + weighted) + M, n = 600, 4 + xm = rng.uniform(-1.2, 1.2, size=M) + ym = rng.uniform(-1.2, 1.2, size=M) + seg0 = rng.integers(0, n, size=M).astype(numpy.int64) + weights = rng.random(M) if weighted else numpy.ones(M) + + z = _zernike_basis_numpy(xm, ym, lut, nterms, m_arr) # (M, K), bit-exact basis + K = lut.shape[0] + vr_ref = numpy.zeros((n, K)) + vi_ref = numpy.zeros((n, K)) + numpy.add.at(vr_ref, seg0, weights[:, None] * z.real) + numpy.add.at(vi_ref, seg0, weights[:, None] * z.imag) + + vr, vi = zernike_moments(weights, xm, ym, seg0, lut, nterms, m_arr, n) + numpy.testing.assert_allclose(vr, vr_ref, rtol=1e-9, atol=1e-12) + numpy.testing.assert_allclose(vi, vi_ref, rtol=1e-9, atol=1e-12) + + +def test_zernike_moments_skips_seg_negative_and_outside_disk(): + indexes = centrosome.zernike.get_zernike_indexes(4) + lut, nterms, m_arr = zernike_coeffs(indexes) + # one in-disk pixel (seg 0), one outside disk (skipped), one seg -1 (skipped) + xm = numpy.array([0.3, 0.9, 0.1]) + ym = numpy.array([0.3, 0.9, 0.1]) + seg0 = numpy.array([0, 0, -1], dtype=numpy.int64) + weights = numpy.ones(3) + vr, vi = zernike_moments(weights, xm, ym, seg0, lut, nterms, m_arr, 1) + # only the first pixel contributes (r^2=0.18<=1); compare to a manual single-pixel basis + z = _zernike_basis_numpy(xm[:1], ym[:1], lut, nterms, m_arr) + numpy.testing.assert_allclose(vr[0], z[0].real, rtol=1e-9, atol=1e-12) 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"