From 8ff1e52a69534ddffce98caa5bad7889c68cc84f Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:47:10 -0700 Subject: [PATCH 01/39] feat(viscy-utils): add the segmentation-aware and background losses Port the Spotlight-motivated losses from dynacell-models so they live in the shared package before any application consumes them: SegAuxDice (seg_aux.py) with the SAUNA-map and soft-clDice weight maps (seg_aux_maps.py), the segmenter-consistency loss (seg_consistency.py), the Track H background low-pass target (background_lowpass.py), and SpotlightLoss(return_components=True). Defaults keep SpotlightLoss's existing behavior; no package imports an application. Source on dynacell-models: 40de7939 (#498), 6c28a318 (#521), 0cbeda1b (#524), 050f553c (#530). Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_utils/losses/__init__.py | 5 +- .../viscy_utils/losses/background_lowpass.py | 258 ++++++++ .../src/viscy_utils/losses/seg_aux.py | 310 +++++++++ .../src/viscy_utils/losses/seg_aux_maps.py | 363 ++++++++++ .../src/viscy_utils/losses/seg_consistency.py | 230 +++++++ .../src/viscy_utils/losses/spotlight.py | 24 +- .../tests/test_background_lowpass.py | 254 +++++++ .../viscy-utils/tests/test_seg_aux_loss.py | 619 ++++++++++++++++++ .../tests/test_seg_consistency_loss.py | 171 +++++ .../viscy-utils/tests/test_spotlight_loss.py | 34 + 10 files changed, 2263 insertions(+), 5 deletions(-) create mode 100644 packages/viscy-utils/src/viscy_utils/losses/background_lowpass.py create mode 100644 packages/viscy-utils/src/viscy_utils/losses/seg_aux.py create mode 100644 packages/viscy-utils/src/viscy_utils/losses/seg_aux_maps.py create mode 100644 packages/viscy-utils/src/viscy_utils/losses/seg_consistency.py create mode 100644 packages/viscy-utils/tests/test_background_lowpass.py create mode 100644 packages/viscy-utils/tests/test_seg_aux_loss.py create mode 100644 packages/viscy-utils/tests/test_seg_consistency_loss.py diff --git a/packages/viscy-utils/src/viscy_utils/losses/__init__.py b/packages/viscy-utils/src/viscy_utils/losses/__init__.py index 7bf90b536..9b6b89c25 100644 --- a/packages/viscy-utils/src/viscy_utils/losses/__init__.py +++ b/packages/viscy-utils/src/viscy_utils/losses/__init__.py @@ -1,4 +1,7 @@ +from viscy_utils.losses.background_lowpass import BackgroundLowPass from viscy_utils.losses.mixed_loss import MixedLoss +from viscy_utils.losses.seg_aux import SegAuxDice +from viscy_utils.losses.seg_consistency import SegConsistencyLoss from viscy_utils.losses.spotlight import SpotlightLoss -__all__ = ["MixedLoss", "SpotlightLoss"] +__all__ = ["BackgroundLowPass", "MixedLoss", "SegAuxDice", "SegConsistencyLoss", "SpotlightLoss"] diff --git a/packages/viscy-utils/src/viscy_utils/losses/background_lowpass.py b/packages/viscy-utils/src/viscy_utils/losses/background_lowpass.py new file mode 100644 index 000000000..7dbd40ed4 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/losses/background_lowpass.py @@ -0,0 +1,258 @@ +"""Background low-pass of a training target, foreground interior kept unchanged. + +``x' = w * x + (1 - w) * B`` with ``m = dilate(fg_mask, r)``, +``w = clamp(G_f(m), 0, 1)`` and the background estimate +``B = G_lp(x * (1 - m)) / G_lp(1 - m)``: where the feather kernel lies inside +``m`` the target is unchanged, where it misses ``m`` the target is a Gaussian +low-pass of the background pixels alone, and in between the feather blends the +two without a seam. The blend reaches the feather radius inside ``m``, so a +feather radius above ``r`` also mixes a little ``B`` into the rim of the +foreground itself. + +``B`` is a normalized convolution: a plain ``G_lp(x)`` would bleed bright +foreground into the background as a halo, while ``B`` averages background +pixels only, so a foreground blob never reaches it. With ``sigma_lp=None`` (its +``sigma_lp -> inf`` limit) ``B`` is flat: the mean of each plane's background +pixels, which also removes autofluorescence and uneven illumination. + +It changes the target, never the loss: the background stays supervised toward a +smooth field. In flow matching, masking the background out of the loss would +leave the initial Gaussian noise there unremoved (the Spotlight v1 collapse). + +The Gaussians are normalized, edge-replicated (``scipy.ndimage`` ``mode="nearest"``, +``truncate=4.0``) and separable, applied as shifted multiply-adds in float32, so +no convolution backend (cuDNN TF32, autocast) can lower their precision. ``B`` is a +weighted mean of background pixels, so it keeps the local background level and +the op needs no change to the target's normalization. +""" + +import math + +import torch +import torch.nn.functional as F +from torch import Tensor, nn + +__all__ = ["BackgroundLowPass"] + +# At or below this fraction of the low-pass kernel's mass on background pixels the +# normalized convolution has no background in reach; B falls back to the plane's flat mean. +_MIN_BACKGROUND_WEIGHT = 1e-6 + + +def _gaussian_kernel(sigma: float, device: torch.device) -> Tensor: + """Return a normalized float32 1D Gaussian of radius ``int(4 * sigma + 0.5)``, as scipy does.""" + radius = int(4.0 * sigma + 0.5) + x = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32) + k = torch.exp(-0.5 * (x / sigma) ** 2) + return k / k.sum() + + +def _gaussian_blur(x: Tensor, sigmas: tuple[float, float, float]) -> Tensor: + """Separable Gaussian blur of ``(N, D, H, W)`` with edge-replicated borders. + + Parameters + ---------- + x : Tensor + Float32, shape ``(N, D, H, W)``. + sigmas : tuple of float + Standard deviation in voxels along ``(D, H, W)``; 0 leaves that axis alone. + + Returns + ------- + Tensor + Blurred tensor, shape of ``x``. + """ + for dim, sigma in zip((1, 2, 3), sigmas): + length = x.shape[dim] + # A size-1 axis replicates its one value, so the blur along it is the identity. + if sigma <= 0 or length == 1: + continue + k = _gaussian_kernel(sigma, x.device) + r = k.numel() // 2 + # F.pad lists the last dim first; replicate supports any pad width. + pad = [0] * 6 + pad[2 * (3 - dim)] = pad[2 * (3 - dim) + 1] = r + padded = F.pad(x.unsqueeze(1), pad, mode="replicate").squeeze(1) + out = k[0] * padded.narrow(dim, 0, length) + for i in range(1, k.numel()): + out = out + k[i] * padded.narrow(dim, i, length) + x = out + return x + + +class BackgroundLowPass(nn.Module): + """Replace a target's background by a low-pass estimate from background pixels. + + ``x' = w * x + (1 - w) * B``, ``m = dilate(fg_mask > 0.5, r)``, + ``w = clamp(G_f(m), 0, 1)``, ``B = G_lp(x * (1 - m)) / G_lp(1 - m)``. + ``w`` is exactly 1 where the feather kernel lies entirely inside ``m`` (the + dilated mask shrunk by its radius, ``int(4 * sigma_feather + 0.5)``), so + ``x' = x`` exactly there, and exactly 0 where the kernel misses ``m``, so + ``x' = B`` exactly there. + + ``sigma_lp=None`` makes ``B`` flat: per sample, channel and Z plane, the mean + of ``x`` over the plane's background pixels, the ``sigma_lp -> inf`` limit of + the normalized convolution. Where the normalized convolution has at most + ``1e-6`` of its kernel mass on background (a thin background ring narrower + than its support, say), ``B`` falls back to that flat mean. A plane with no + background pixel at all has no estimate: ``B = x`` there, and with the + per-plane feather ``w == 1``, so the plane is returned unchanged. + + By default every operation acts in XY, per Z plane: Z is anisotropic, and + 2D models have ``D = 1``. The ``*_z`` parameters extend each to Z. + + Computed in float32 regardless of autocast; returned in the target's dtype. + + Parameters + ---------- + sigma_lp : float or None + Gaussian sigma in XY, in voxels, of the background estimate (> 0); + ``None`` (or ``inf``) for a flat background. + sigma_feather : float + Gaussian sigma in XY, in voxels, that feathers the dilated mask into + ``w`` (>= 0; 0 keeps the hard dilated mask). + dilate_radius : int + Dilation radius in XY, in voxels (>= 0): a ``(2r + 1)``-wide square. + sigma_lp_z : float + Background-estimate sigma along Z, in voxels (>= 0; 0 = per plane). + Must be 0 with a flat background, which is per plane. + sigma_feather_z : float + Feather sigma along Z, in voxels (>= 0; 0 = per plane). + dilate_radius_z : int + Dilation radius along Z, in voxels (>= 0; 0 = per plane). + """ + + def __init__( + self, + sigma_lp: float | None, + sigma_feather: float, + dilate_radius: int, + sigma_lp_z: float = 0.0, + sigma_feather_z: float = 0.0, + dilate_radius_z: int = 0, + ) -> None: + super().__init__() + if sigma_lp is not None and math.isinf(sigma_lp): + sigma_lp = None + if sigma_lp is None: + if sigma_lp_z != 0: + raise ValueError(f"sigma_lp_z={sigma_lp_z} has no effect with a flat background (sigma_lp=None)") + elif sigma_lp <= 0: + raise ValueError(f"sigma_lp must be > 0 or None, got {sigma_lp}") + for name, value in ( + ("sigma_feather", sigma_feather), + ("sigma_lp_z", sigma_lp_z), + ("sigma_feather_z", sigma_feather_z), + ("dilate_radius", dilate_radius), + ("dilate_radius_z", dilate_radius_z), + ): + if value < 0: + raise ValueError(f"{name} must be >= 0, got {value}") + self.sigma_lp = None if sigma_lp is None else float(sigma_lp) + self.sigma_feather = float(sigma_feather) + self.dilate_radius = int(dilate_radius) + self.sigma_lp_z = float(sigma_lp_z) + self.sigma_feather_z = float(sigma_feather_z) + self.dilate_radius_z = int(dilate_radius_z) + + def dilated_mask(self, fg_mask: Tensor) -> Tensor: + """Return ``m = dilate(fg_mask > 0.5, r)`` as float32 in {0, 1}. + + Parameters + ---------- + fg_mask : Tensor + Foreground mask, shape ``(B, C, D, H, W)``; binarized at 0.5. + + Returns + ------- + Tensor + Float32, shape of ``fg_mask``. + """ + b, c = fg_mask.shape[:2] + rz, r = self.dilate_radius_z, self.dilate_radius + mask = (fg_mask > 0.5).float().reshape(b * c, 1, *fg_mask.shape[2:]) + if rz or r: + # max_pool pads with -inf, so the border never dilates the mask. + mask = F.max_pool3d(mask, (2 * rz + 1, 2 * r + 1, 2 * r + 1), stride=1, padding=(rz, r, r)) + return mask.reshape(fg_mask.shape) + + def blend_weight(self, dilated: Tensor) -> Tensor: + """Return ``w = clamp(G_f(m), 0, 1)`` in float32, exactly 1 under an all-``m`` kernel. + + Parameters + ---------- + dilated : Tensor + Dilated mask ``m`` from :meth:`dilated_mask`, shape ``(B, C, D, H, W)``. + + Returns + ------- + Tensor + Float32 weights in [0, 1], shape of ``dilated``. + """ + b, c = dilated.shape[:2] + spatial = dilated.shape[2:] + with torch.autocast(device_type=dilated.device.type, enabled=False): + sigmas = (self.sigma_feather_z, self.sigma_feather, self.sigma_feather) + blurred = _gaussian_blur(dilated.float().reshape(b * c, *spatial), sigmas) + # The normalized kernel sums to 1 only up to rounding (0.99999982 at sigma 0.5). + # Edge replication makes the blur of ones the same value at every voxel, computed + # by the same operations as any all-ones window of m: dividing by it gives exactly + # 1 there. Size-2 axes reproduce it (size 1 is skipped, like in the full tensor). + ones = torch.ones((1, *(min(n, 2) for n in spatial)), device=dilated.device) + unit = _gaussian_blur(ones, sigmas)[0, 0, 0, 0] + return (blurred / unit).clamp(0.0, 1.0).reshape(dilated.shape) + + def background(self, target: Tensor, dilated: Tensor) -> Tensor: + """Return the background estimate ``B`` in float32; ``target`` on planes without background. + + Parameters + ---------- + target : Tensor + Target, shape ``(B, C, D, H, W)``. + dilated : Tensor + Dilated mask ``m`` from :meth:`dilated_mask`, same shape. + + Returns + ------- + Tensor + Float32, shape of ``target``. + """ + b, c = target.shape[:2] + spatial = target.shape[2:] + with torch.autocast(device_type=target.device.type, enabled=False): + x = target.float() + bg = 1.0 - dilated.float() + count = bg.sum(dim=(-2, -1), keepdim=True) + plane_mean = (x * bg).sum(dim=(-2, -1), keepdim=True) / count.clamp(min=1.0) + flat = torch.where(count > 0, plane_mean, x) + if self.sigma_lp is None: + return flat + sigmas = (self.sigma_lp_z, self.sigma_lp, self.sigma_lp) + num = _gaussian_blur((x * bg).reshape(b * c, *spatial), sigmas).reshape(x.shape) + den = _gaussian_blur(bg.reshape(b * c, *spatial), sigmas).reshape(x.shape) + defined = den > _MIN_BACKGROUND_WEIGHT + return torch.where(defined, num / den.clamp(min=_MIN_BACKGROUND_WEIGHT), flat) + + def forward(self, target: Tensor, fg_mask: Tensor) -> Tensor: + """Return the target with its background replaced by ``B``. + + Parameters + ---------- + target : Tensor + Target, shape ``(B, C, D, H, W)``. + fg_mask : Tensor + Foreground mask, same shape; binarized at 0.5. + + Returns + ------- + Tensor + ``w * target + (1 - w) * B``, shape and dtype of ``target``. + """ + if target.ndim != 5: + raise ValueError(f"target must be (B, C, D, H, W), got shape {tuple(target.shape)}") + if fg_mask.shape != target.shape: + raise ValueError(f"fg_mask {tuple(fg_mask.shape)} must match target {tuple(target.shape)}") + dilated = self.dilated_mask(fg_mask) + w = self.blend_weight(dilated) + x = target.float() + return (w * x + (1.0 - w) * self.background(target, dilated)).to(target.dtype) diff --git a/packages/viscy-utils/src/viscy_utils/losses/seg_aux.py b/packages/viscy-utils/src/viscy_utils/losses/seg_aux.py new file mode 100644 index 000000000..350e66664 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/losses/seg_aux.py @@ -0,0 +1,310 @@ +"""Self-calibrating soft-Dice auxiliary loss for virtual staining. + +The prediction is soft-thresholded at a level derived per patch from the target +and the foreground mask it is scored against, so the loss needs no knowledge of +the normalization applied upstream (z-score, median/IQR, MinMax to [-1, 1]). + +For every (sample, channel) patch: + +- ``tau = quantile(target, 1 - mean(fg_mask))``: the level at which the target's + own foreground fraction equals the mask's. +- ``s = c * (median(target | fg) - median(target | bg))``: the knee width + relative to the patch's own foreground/background contrast. +- ``p = sigmoid((pred - tau) / s)``: bounded, with non-zero gradient everywhere. +- ``dice = 1 - 2 * sum(p * r) / (sum(p**2) + sum(r**2) + eps)``, where the + reference ``r`` is the binary mask (``label="mask"``) or the target through + the same sigmoid, ``sigmoid((target - tau) / s)`` (``label="target"``). + +``tau``, ``s`` and ``r`` are computed from the target without gradient. + +With ``label="mask"`` the loss is not zero at ``pred == target`` wherever the +mask is not a threshold of the target (0.07-0.25 on the iPSC baselines' +validation patches, 2026-09-24), so it also pulls the prediction toward the +mask. ``label="target"`` keeps the mask's role in setting ``tau``, ``s`` and +patch validity but is minimised exactly at ``pred == target``: it isolates a +segmentation-shaped penalty from that pull. + +Two optional, orthogonal extensions (both off by default, which leaves the loss +bit-identical to the plain soft Dice above): + +- ``weighting="sauna"`` weights every voxel's terms in both Dice sums by + ``|y~|``, the magnitude of SAUNA's combined boundary/thickness uncertainty map + of the patch's mask (:func:`~viscy_utils.losses.seg_aux_maps.sauna_weight_map`), + which down-weights the boundary of thick structures and keeps thin ones and + deep interiors/backgrounds at full weight. This is a SAUNA-map-weighted + binary soft Dice adapted from SAUNA's map, not SAUNA's loss: published SAUNA + (Oulu-IMEDS/SAUNA, ``SoftJaccardLoss``) regresses a tanh head toward the + signed ``y~`` with separate foreground (``relu(y~)``) and background + (``relu(-y~)``) soft-Jaccard terms. +- ``topology="cldice"`` replaces the per-patch loss by + ``(1 - alpha) * dice + alpha * (1 - clDice)`` with the soft-skeleton clDice. +""" + +import torch +from torch import Tensor, nn + +from viscy_utils.losses.seg_aux_maps import sauna_weight_map, soft_skeleton + +__all__ = ["SegAuxDice"] + + +def _quantile_sorted(sorted_rows: Tensor, q: Tensor) -> Tensor: + """Linearly interpolated per-row quantile of pre-sorted rows. + + Matches ``torch.quantile(..., interpolation="linear")`` but takes one + quantile level per row and has no input-size limit, which + ``torch.quantile`` imposes (2**24 elements) and large 3D patches exceed. + + Parameters + ---------- + sorted_rows : Tensor + Rows sorted ascending along the last dim, shape ``(R, N)``. + q : Tensor + Quantile level per row in ``[0, 1]``, shape ``(R,)``. + + Returns + ------- + Tensor + Quantile per row, shape ``(R,)``. + """ + n = sorted_rows.shape[-1] + pos = q.clamp(0.0, 1.0) * (n - 1) + lo = pos.floor().long() + hi = (lo + 1).clamp(max=n - 1) + frac = pos - lo.to(pos.dtype) + v_lo = sorted_rows.gather(-1, lo.unsqueeze(-1)).squeeze(-1) + v_hi = sorted_rows.gather(-1, hi.unsqueeze(-1)).squeeze(-1) + return v_lo + frac * (v_hi - v_lo) + + +def _masked_median(values: Tensor, keep: Tensor) -> Tensor: + """Linearly interpolated per-row median over the entries where ``keep`` is set. + + Parameters + ---------- + values : Tensor + Values, shape ``(R, N)``. + keep : Tensor + Boolean selection, shape ``(R, N)``. + + Returns + ------- + Tensor + Median per row, shape ``(R,)``; meaningless for rows with no kept entry. + """ + k = keep.sum(-1) + # Dropped entries sort to the end, so the first k sorted values are the kept ones. + sorted_rows = torch.where(keep, values, torch.full_like(values, float("inf"))).sort(dim=-1).values + pos = 0.5 * (k - 1).clamp(min=0).to(values.dtype) + lo = pos.floor().long() + hi = torch.minimum(lo + 1, (k - 1).clamp(min=0)) + frac = pos - lo.to(pos.dtype) + v_lo = sorted_rows.gather(-1, lo.unsqueeze(-1)).squeeze(-1) + v_hi = sorted_rows.gather(-1, hi.unsqueeze(-1)).squeeze(-1) + return v_lo + frac * (v_hi - v_lo) + + +class SegAuxDice(nn.Module): + """Squared-denominator soft Dice on a self-calibrated sigmoid of the prediction. + + Patches whose mask is empty or full, or whose target is not brighter inside + the mask than outside it (no contrast to set a knee width from), carry no + segmentation signal and are excluded from the mean. + + The knee width is set from the foreground/background contrast rather than + the patch IQR: on thin-structure patches (membrane) the IQR is ~0.65x the + contrast, which left 11-25% of foreground voxels with a vanishing sigmoid + gradient, against 0.5-7.5% with the contrast knee (measured on the iPSC + baselines' validation patches, 2026-09-24). If every patch is excluded the loss is a zero + that stays connected to ``pred``'s graph. + + Computed in float32 regardless of autocast. + + Parameters + ---------- + c : float + Knee width as a fraction of the patch's foreground/background contrast. + eps : float + Dice denominator stabilizer, and the floor on the knee width. + label : {"mask", "target"} + Dice reference: the binary foreground mask, or the target soft-thresholded + like the prediction (self-consistent; zero loss at ``pred == target``). + weighting : {"none", "sauna"} + ``"sauna"`` computes ``sum(w * p * r) / (sum(w * p**2) + sum(w * r**2))`` + with ``w = |y~|`` from the binarized mask, without gradient. + spacing : tuple of float or None + Physical voxel size per spatial dim, e.g. ``(z, y, x)`` in um; required + with ``weighting="sauna"`` (and only then), one entry per spatial dim. + topology : {"none", "cldice"} + ``"cldice"`` mixes in ``1 - clDice`` with weight ``cldice_alpha``. + cldice_alpha : float + Weight of ``1 - clDice`` in [0, 1]; the Dice term gets ``1 - cldice_alpha``. + cldice_iters : int + Soft-skeleton erosion iterations (>= 1). + """ + + def __init__( + self, + c: float = 0.1, + eps: float = 1e-6, + label: str = "mask", + weighting: str = "none", + spacing: tuple[float, ...] | None = None, + topology: str = "none", + cldice_alpha: float = 0.5, + cldice_iters: int = 10, + ) -> None: + super().__init__() + if c <= 0: + raise ValueError(f"c must be > 0, got {c}") + if eps <= 0: + raise ValueError(f"eps must be > 0, got {eps}") + if label not in ("mask", "target"): + raise ValueError(f"label must be 'mask' or 'target', got {label!r}") + if weighting not in ("none", "sauna"): + raise ValueError(f"weighting must be 'none' or 'sauna', got {weighting!r}") + if weighting == "sauna": + if spacing is None: + raise ValueError("weighting='sauna' needs spacing (physical voxel size per spatial dim)") + if len(spacing) == 0 or any(s <= 0 for s in spacing): + raise ValueError(f"spacing must be non-empty and positive, got {spacing}") + elif spacing is not None: + raise ValueError(f"spacing={spacing} has no effect without weighting='sauna'") + if topology not in ("none", "cldice"): + raise ValueError(f"topology must be 'none' or 'cldice', got {topology!r}") + if not 0.0 <= cldice_alpha <= 1.0: + raise ValueError(f"cldice_alpha must be in [0, 1], got {cldice_alpha}") + if cldice_iters < 1: + raise ValueError(f"cldice_iters must be >= 1, got {cldice_iters}") + self.c = c + self.eps = eps + self.label = label + self.weighting = weighting + self.spacing = None if spacing is None else tuple(float(s) for s in spacing) + self.topology = topology + self.cldice_alpha = cldice_alpha + self.cldice_iters = cldice_iters + + def per_channel(self, pred: Tensor, target: Tensor, fg_mask: Tensor) -> tuple[Tensor, Tensor]: + """Compute the Dice loss per (sample, channel) and which entries are valid. + + Parameters + ---------- + pred : Tensor + Prediction, shape ``(B, C, *spatial)``. + target : Tensor + Ground truth, same shape. + fg_mask : Tensor + Foreground mask, same shape; binarized at 0.5. + + Returns + ------- + tuple of (Tensor, Tensor) + Dice loss ``(B, C)`` in float32 (entries for invalid patches are + finite but meaningless) and the boolean validity mask ``(B, C)``. + """ + if pred.shape != target.shape or fg_mask.shape != target.shape: + raise ValueError( + f"pred, target and fg_mask must share a shape; got " + f"{tuple(pred.shape)}, {tuple(target.shape)}, {tuple(fg_mask.shape)}" + ) + b, c = target.shape[:2] + spatial = tuple(target.shape[2:]) + if self.spacing is not None and len(self.spacing) != len(spatial): + raise ValueError(f"spacing {self.spacing} has {len(self.spacing)} entries for {len(spatial)} spatial dims") + with torch.autocast(device_type=pred.device.type, enabled=False): + pred_f = pred.float().reshape(b * c, -1) + mask = (fg_mask.reshape(b * c, -1) > 0.5).float() + n = mask.shape[-1] + fg_count = mask.sum(-1) + with torch.no_grad(): + sorted_t = target.detach().float().reshape(b * c, -1).sort(dim=-1).values + q = 1.0 - fg_count / n + tau = _quantile_sorted(sorted_t, q) + flat_t = target.detach().float().reshape(b * c, -1) + fg = mask > 0.5 + contrast = _masked_median(flat_t, fg) - _masked_median(flat_t, ~fg) + valid = (fg_count > 0) & (fg_count < n) & (contrast > 0) + # An empty or full mask makes one median inf; substitute a finite + # width so invalid entries stay finite and the mask-multiply in + # forward() can zero them (inf * 0 would be NaN). + s = torch.where(valid, self.c * contrast, torch.ones_like(contrast)).clamp(min=self.eps) + if self.label == "target": + ref = torch.sigmoid((flat_t - tau.unsqueeze(-1)) / s.unsqueeze(-1)) + else: + ref = mask + p = torch.sigmoid((pred_f - tau.unsqueeze(-1)) / s.unsqueeze(-1)) + if self.weighting == "sauna": + with torch.no_grad(): + w = sauna_weight_map(fg.reshape(b * c, *spatial), self.spacing).reshape(b * c, -1) + inter = (w * p * ref).sum(-1) + denom = (w * p * p).sum(-1) + (w * ref * ref).sum(-1) + self.eps + else: + inter = (p * ref).sum(-1) + denom = (p * p).sum(-1) + (ref * ref).sum(-1) + self.eps + dice = 1.0 - 2.0 * inter / denom + if self.topology == "cldice": + dice = (1.0 - self.cldice_alpha) * dice + self.cldice_alpha * ( + 1.0 - self._cldice(p, ref, (b * c, 1, *spatial)) + ) + return dice.reshape(b, c), valid.reshape(b, c) + + def _cldice(self, p: Tensor, ref: Tensor, shape: tuple[int, ...]) -> Tensor: + """Soft clDice per row; gradient flows through ``p`` and its skeleton only. + + Parameters + ---------- + p : Tensor + Soft prediction, shape ``(R, N)``. + ref : Tensor + Reference, shape ``(R, N)``. + shape : tuple of int + ``(R, 1, *spatial)`` to restore the spatial layout. + + Returns + ------- + Tensor + clDice per row, shape ``(R,)``. + """ + skel_p = soft_skeleton(p.reshape(shape), self.cldice_iters).reshape(p.shape) + with torch.no_grad(): + skel_r = soft_skeleton(ref.reshape(shape), self.cldice_iters).reshape(ref.shape) + # eps on both sides (clDice's "smooth"): an empty skeleton reads as perfect, not 0/0. + t_prec = ((skel_p * ref).sum(-1) + self.eps) / (skel_p.sum(-1) + self.eps) + t_sens = ((skel_r * p).sum(-1) + self.eps) / (skel_r.sum(-1) + self.eps) + return 2.0 * t_prec * t_sens / (t_prec + t_sens + self.eps) + + def forward( + self, + pred: Tensor, + target: Tensor, + fg_mask: Tensor, + return_components: bool = False, + ) -> Tensor | tuple[Tensor, dict[str, Tensor]]: + """Compute the mean Dice loss over valid (sample, channel) patches. + + Parameters + ---------- + pred : Tensor + Prediction, shape ``(B, C, *spatial)``. + target : Tensor + Ground truth, same shape. + fg_mask : Tensor + Foreground mask, same shape; binarized at 0.5. + return_components : bool + When ``True``, also return ``{"dice": loss, "n_valid": count}``. + + Returns + ------- + Tensor or tuple of (Tensor, dict of str to Tensor) + Scalar float32 loss, optionally with its components. + """ + dice, valid = self.per_channel(pred, target, fg_mask) + n_valid = valid.sum() + # Invalid entries are finite, so multiplying by the mask zeroes them while + # keeping the graph: with no valid patch this is a connected 0 / 1, and no + # host sync is needed to branch on n_valid. + loss = (dice * valid).sum() / n_valid.clamp(min=1) + if return_components: + return loss, {"dice": loss, "n_valid": n_valid.float()} + return loss diff --git a/packages/viscy-utils/src/viscy_utils/losses/seg_aux_maps.py b/packages/viscy-utils/src/viscy_utils/losses/seg_aux_maps.py new file mode 100644 index 000000000..f2bebb88c --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/losses/seg_aux_maps.py @@ -0,0 +1,363 @@ +"""Mask-derived maps for :class:`~viscy_utils.losses.SegAuxDice`. + +- :func:`squared_edt`: exact, separable, anisotropic squared Euclidean distance + transform in torch (no cupy/cucim needed), on CPU or GPU. +- :func:`sauna_weight_map`: the SAUNA combined boundary/thickness uncertainty + map ("h" variant) of a binary mask, as a per-voxel Dice weight. +- :func:`soft_skeleton`: the clDice soft skeleton. + +SAUNA: Oulu-IMEDS/SAUNA, ``mlpipeline/utils/generate_uncertainty_masks.py`` +(``extract_boundary_uncertainty_map``, ``extract_thickness_uncertainty_map`` +with ``target_c_label="h"``, ``extract_combined_uncertainty_map``). +clDice: jocpae/clDice, ``soft_skeleton.py``. +""" + +import math +from collections.abc import Sequence + +import torch +import torch.nn.functional as F +from torch import Tensor +from torch.utils.checkpoint import checkpoint + +__all__ = ["box_max", "sauna_weight_map", "soft_skeleton", "squared_edt"] + +# Elements in one (lines, L, L) block of the brute-force EDT pass: 2**26 float32 = 256 MiB. +_EDT_BLOCK_ELEMENTS = 2**26 + + +def _nearest_seed_1d_squared(seeds: Tensor, spacing: float, dim: int) -> Tensor: + """Squared distance to the nearest seed along ``dim``, by two scans. + + Parameters + ---------- + seeds : Tensor + Boolean, any shape. + spacing : float + Physical size of one step along ``dim``. + dim : int + Axis to scan. + + Returns + ------- + Tensor + Float32 squared distance, shape of ``seeds``; ``inf`` on lines with no seed. + """ + shape = [1] * seeds.ndim + shape[dim] = seeds.shape[dim] + idx = torch.arange(seeds.shape[dim], device=seeds.device, dtype=torch.float32).reshape(shape) + left = torch.where(seeds, idx, float("-inf")).cummax(dim=dim).values + right = torch.where(seeds, idx, float("inf")).flip(dim).cummin(dim=dim).values.flip(dim) + dist = torch.minimum(idx - left, right - idx) * spacing + return dist * dist + + +def _lower_envelope_1d(f: Tensor, spacing: float, band: int | None) -> Tensor: + """``out[x] = min_y f[y] + (spacing * (x - y))**2`` along the last dim, brute force. + + Parameters + ---------- + f : Tensor + Float32, shape ``(M, L)``; may hold ``inf``. + spacing : float + Physical size of one step along the last dim. + band : int or None + Only consider ``|x - y| <= band``; ``None`` searches the whole line. + + Returns + ------- + Tensor + Shape ``(M, L)``. + """ + m, length = f.shape + out = torch.empty_like(f) + if band is None or 2 * band + 1 >= length: + pos = torch.arange(length, device=f.device, dtype=torch.float32) * spacing + offsets = (pos[:, None] - pos[None, :]) ** 2 # (x, y) + rows = max(1, _EDT_BLOCK_ELEMENTS // (length * length)) + for start in range(0, m, rows): + out[start : start + rows] = (f[start : start + rows, None, :] + offsets).amin(dim=-1) + return out + steps = torch.arange(-band, band + 1, device=f.device, dtype=torch.float32) * spacing + offsets = steps * steps # (2 * band + 1,) + rows = max(1, _EDT_BLOCK_ELEMENTS // (length * (2 * band + 1))) + for start in range(0, m, rows): + padded = F.pad(f[start : start + rows], (band, band), value=float("inf")) + out[start : start + rows] = (padded.unfold(-1, 2 * band + 1, 1) + offsets).amin(dim=-1) + return out + + +def _along_axis(x: Tensor, axis: int, fn) -> Tensor: + """Apply a ``(M, L) -> (M, L)`` function along ``axis`` of ``x``.""" + moved = x.movedim(axis, -1) + shape = moved.shape + return fn(moved.reshape(-1, shape[-1])).reshape(shape).movedim(-1, axis) + + +def squared_edt(seeds: Tensor, spacing: Sequence[float], max_distance: float | None = None) -> Tensor: + """Exact squared Euclidean distance from every voxel to the nearest seed. + + Separable (Saito-Toriwaki): an exact two-scan 1D pass along the largest + axis, then a brute-force lower-envelope pass along each other axis, blocked + to bound memory. Size-1 axes are skipped. Matches + ``scipy.ndimage.distance_transform_edt(~seeds, sampling=spacing) ** 2``. + + With ``max_distance`` the result is ``min(d**2, max_distance**2)``, still + exact: every value is capped at ``max_distance**2`` before each pass, so no + minimizer that matters lies further than ``max_distance`` along any line, + and each pass only searches that band. + + Parameters + ---------- + seeds : Tensor + Boolean, shape ``(N, *spatial)``; each of the ``N`` rows is independent. + spacing : Sequence[float] + Physical voxel size per spatial dim. + max_distance : float or None + Cap on the returned distance; ``None`` for none. + + Returns + ------- + Tensor + Float32 squared distances, shape of ``seeds``; 0 on seeds and, without + a cap, ``inf`` everywhere in a row without any seed. + """ + spatial = seeds.shape[1:] + if len(spacing) != len(spatial): + raise ValueError(f"spacing has {len(spacing)} entries for {len(spatial)} spatial dims") + cap2 = None if max_distance is None else float(max_distance) ** 2 + axes = [a for a in range(len(spatial)) if spatial[a] > 1] + first = max(axes, key=lambda a: spatial[a]) if axes else None + if first is None: + d2 = torch.where(seeds, 0.0, float("inf")).float() + else: + d2 = _nearest_seed_1d_squared(seeds, float(spacing[first]), first + 1) + if cap2 is not None: + d2 = d2.clamp(max=cap2) + for a in axes: + if a != first: + band = None if max_distance is None else math.ceil(max_distance / spacing[a]) + d2 = _along_axis(d2, a + 1, lambda f, a=a, band=band: _lower_envelope_1d(f, float(spacing[a]), band)) + # Passes keep values exact where the true distance is within the cap and >= the cap elsewhere. + return d2 if cap2 is None else d2.clamp(max=cap2) + + +def _axis_distance_bound(seeds: Tensor, spacing: Sequence[float]) -> Tensor: + """Per-voxel upper bound on the distance to the nearest seed. + + The smallest distance to a seed along any single axis: 1D scans only, cheap. + """ + bound2 = torch.full(seeds.shape, float("inf"), device=seeds.device) + for a in range(seeds.ndim - 1): + if seeds.shape[a + 1] > 1: + d2 = _nearest_seed_1d_squared(seeds, float(spacing[a]), a + 1) + bound2 = torch.minimum(bound2, d2) + return bound2.sqrt() + + +def _window_max(x: Tensor, k: int, dim: int) -> Tensor: + """Max over a centred odd window ``k`` along ``dim``, ``-inf`` beyond the edges. + + Doubling (sparse-table) maxima: ``log2(k)`` elementwise passes, no pooling + indices. Equal to ``max_pool1d(k, stride=1, padding=k // 2)`` along ``dim``. + """ + length = x.shape[dim] + pad = [0, 0] * (x.ndim - 1 - dim) + [k // 2, k // 2] + m = F.pad(x, pad, value=float("-inf")) # length + k - 1 along dim + span = 1 + while 2 * span <= k: + n = m.shape[dim] - span + m = torch.maximum(m.narrow(dim, 0, n), m.narrow(dim, span, n)) + span *= 2 + # m[j] = max over [j, j + span); combine two overlapping spans to cover k. + return torch.maximum(m.narrow(dim, 0, length), m.narrow(dim, k - span, length)) + + +def box_max(x: Tensor, windows: Sequence[int]) -> Tensor: + """Box max filter, stride 1, same size, as sequential 1D window maxima. + + Parameters + ---------- + x : Tensor + Float, shape ``(N, *spatial)``. + windows : Sequence[int] + Odd window length per spatial dim; 1 leaves that dim alone. + + Returns + ------- + Tensor + Filtered tensor, shape of ``x``. + """ + for a, k in enumerate(windows): + length = x.shape[a + 1] + # A window wider than 2L - 1 already covers the whole line from every voxel. + k = min(k, 2 * length - 1) + if k <= 1: + continue + x = _window_max(x, k, a + 1) + return x + + +def _odd_windows(radius: float, spacing: Sequence[float]) -> list[int]: + """SAUNA's ``do_max_pooling`` kernel per axis: ``ceil(radius)`` voxels, made odd.""" + windows = [] + for s in spacing: + k = math.ceil(radius / s) + windows.append(k + 1 if k % 2 == 0 else k) + return windows + + +def sauna_weight_map(mask: Tensor, spacing: Sequence[float]) -> Tensor: + """Per-voxel Dice weight ``|y~|`` from SAUNA's combined "h" uncertainty map. + + With ``fg_dist`` the distance of each voxel to the nearest background voxel + and ``bg_dist`` to the nearest foreground voxel (physical units), + ``fg_max = max(fg_dist)``: + + - boundary map ``gt_b = (fg_dist - min(bg_dist, fg_max)) / fg_max``; + - thickness map ``gt_t``: box max of ``fg_dist`` over a window of + ``fg_max`` per axis, over ``fg_max`` on foreground; box max of + ``min(bg_dist, fg_max)`` (window from that map's own maximum, as SAUNA + does) over ``fg_max``, clipped to [0, 1], on background; + - ``y~ = clip(gt_b + (1 - gt_t), -1, 1)`` on foreground and + ``clip(gt_b - (1 - gt_t), -1, 1)`` on background. + + Rows with no foreground or no background get weight 1. + + Using ``|y~|`` as a Dice weight is this package's adaptation of the map, + not part of SAUNA, which regresses a tanh head toward the signed ``y~`` + (see :func:`sauna_signed_map`). + + Parameters + ---------- + mask : Tensor + Boolean foreground, shape ``(N, *spatial)``. + spacing : Sequence[float] + Physical voxel size per spatial dim. + + Returns + ------- + Tensor + Float32 weights in [0, 1], shape of ``mask``. + """ + y = sauna_signed_map(mask, spacing) + ok = _has_fg_and_bg(mask) + return torch.where(_expand_rows(ok, mask), y.abs(), torch.ones_like(y)) + + +def _has_fg_and_bg(mask: Tensor) -> Tensor: + flat = mask.reshape(mask.shape[0], -1) + return flat.any(-1) & (~flat).any(-1) + + +def _expand_rows(row_values: Tensor, like: Tensor) -> Tensor: + return row_values.reshape(-1, *([1] * (like.ndim - 1))) + + +def sauna_signed_map(mask: Tensor, spacing: Sequence[float]) -> Tensor: + """SAUNA's signed combined map ``y~`` in [-1, 1] (see :func:`sauna_weight_map`). + + Parameters + ---------- + mask : Tensor + Boolean foreground, shape ``(N, *spatial)``. + spacing : Sequence[float] + Physical voxel size per spatial dim. + + Returns + ------- + Tensor + Float32, shape of ``mask``; 0 in rows with no foreground or no background. + """ + n = mask.shape[0] + ok_rows = _has_fg_and_bg(mask) + ok = _expand_rows(ok_rows, mask) + if not ok_rows.any(): + return torch.zeros(mask.shape, device=mask.device) + # Both EDTs only need to be exact up to the largest foreground distance: + # fg_dist never exceeds it and bg_dist is clipped to it. The fg cap is a + # cheap upper bound on fg_max, taken over rows that have background. + fg_bound = _axis_distance_bound(~mask, spacing).reshape(n, -1).amax(-1)[ok_rows].amax().item() + fg_cap = fg_bound if math.isfinite(fg_bound) else None + # Rows lacking fg or bg have inf / capped distances; zero them so everything stays finite. + fg_dist = torch.where(ok, squared_edt(~mask, spacing, max_distance=fg_cap).sqrt(), 0.0) + fg_max = fg_dist.reshape(n, -1).amax(-1) + fg_max = torch.where(fg_max > 0, fg_max, torch.ones_like(fg_max)) + bg_dist = torch.where(ok, squared_edt(mask, spacing, max_distance=fg_max.max().item()).sqrt(), 0.0) + bg_clip = torch.minimum(bg_dist, _expand_rows(fg_max, mask)) + bg_max = bg_clip.reshape(n, -1).amax(-1) + # Windows differ per row (each patch has its own fg_max): filter row by row. + fg_pool = torch.empty_like(fg_dist) + bg_pool = torch.empty_like(bg_clip) + for i, (fg_r, bg_r) in enumerate(zip(fg_max.tolist(), bg_max.tolist())): + fg_pool[i : i + 1] = box_max(fg_dist[i : i + 1], _odd_windows(fg_r, spacing)) + bg_pool[i : i + 1] = box_max(bg_clip[i : i + 1], _odd_windows(bg_r, spacing)) + fg_max_b = _expand_rows(fg_max, mask) + gt_b = (fg_dist - bg_clip) / fg_max_b + gt_t = torch.where(mask, fg_pool / fg_max_b, (bg_pool / fg_max_b).clamp(0.0, 1.0)) + y = torch.where(mask, gt_b + (1.0 - gt_t), gt_b - (1.0 - gt_t)).clamp(-1.0, 1.0) + return torch.where(ok, y, 0.0) + + +def _max_pool(x: Tensor, kernel: tuple[int, ...]) -> Tensor: + """Stride-1, same-size max-pool of ``(N, 1, *spatial)`` over 1-3 spatial dims.""" + pool = {1: F.max_pool1d, 2: F.max_pool2d, 3: F.max_pool3d}[len(kernel)] + return pool(x, kernel, stride=1, padding=tuple(k // 2 for k in kernel)) + + +def _soft_erode(x: Tensor) -> Tensor: + """ClDice ``soft_erode``: min of the 1D 3-voxel min-pools along each axis (the cross).""" + nd = x.ndim - 2 + out = None + for a in range(nd): + kernel = tuple(3 if i == a else 1 for i in range(nd)) + eroded = -_max_pool(-x, kernel) + out = eroded if out is None else torch.minimum(out, eroded) + return out + + +def _soft_dilate(x: Tensor) -> Tensor: + """ClDice ``soft_dilate``: 3^n box max-pool.""" + return _max_pool(x, (3,) * (x.ndim - 2)) + + +def _soft_open(x: Tensor) -> Tensor: + return _soft_dilate(_soft_erode(x)) + + +def _skeleton_step(img: Tensor, skel: Tensor) -> tuple[Tensor, Tensor]: + img = _soft_erode(img) + delta = F.relu(img - _soft_open(img)) + return img, skel + F.relu(delta - skel * delta) + + +def soft_skeleton(img: Tensor, iters: int) -> Tensor: + """ClDice soft skeleton (jocpae/clDice ``soft_skel``), in 1-3 spatial dims. + + When a gradient is needed every iteration is activation-checkpointed: each + one holds ~a dozen full-size intermediates (pool values and int64 indices), + which for a + ``(8, 1, 32, 384, 384)`` batch and 10 iterations exceeded a 48 GB GPU. + Checkpointing keeps two tensors per iteration and recomputes the rest in + backward; values and gradients are unchanged by it. + + Parameters + ---------- + img : Tensor + Soft mask in [0, 1], shape ``(N, 1, *spatial)``. + iters : int + Erosion iterations; should exceed the largest foreground radius in voxels. + + Returns + ------- + Tensor + Soft skeleton, shape of ``img``. + """ + checkpointed = img.requires_grad and torch.is_grad_enabled() + skel = F.relu(img - _soft_open(img)) + for _ in range(iters): + if checkpointed: + img, skel = checkpoint(_skeleton_step, img, skel, use_reentrant=False) + else: + img, skel = _skeleton_step(img, skel) + return skel diff --git a/packages/viscy-utils/src/viscy_utils/losses/seg_consistency.py b/packages/viscy-utils/src/viscy_utils/losses/seg_consistency.py new file mode 100644 index 000000000..58e658c10 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/losses/seg_consistency.py @@ -0,0 +1,230 @@ +"""Segmenter-consistency loss for virtual staining (ControlNet++ style). + +A frozen, differentiable segmenter ``S`` is run on the prediction and on the target, +and their dense, pre-threshold outputs are matched: + +``L = mean_px ( mean_c (flow_pred - flow_target)**2 + KL(q_target || q_pred) )`` + +- ``flow`` are the segmenter's two flow channels and ``q = sigmoid(cellprob_logit)``. +- The probability term is the binary KL, i.e. BCE-with-logits against the target's + soft probability minus that probability's entropy: the gradient is BCE's, and the + loss is exactly 0 at ``pred == target``. The loss equals the G4 gate's + ``D_total = D_flow + D_prob``. +- ``S(target)`` is computed under ``no_grad`` on the same augmented crop, every step; + ``S(pred)`` carries the gradient into the prediction. ``S`` itself gets none. + +The segmenter sees 2-D images. A crop ``(B, 1, Z, Y, X)`` is reduced to the +``2 * slab_halfwidth + 1``-plane max projection around its nuclear-area focus plane: +the plane of largest Otsu foreground fraction of the target crop, with the outer 15% +of planes excluded and the crop center as the fallback, as +``dynacell.evaluation.focus.nucleus_area_plane`` picks the plane the eval segments +(slab halfwidth 1 there). The same planes are projected from the prediction. One +segmenter pass per sample is the cheapest geometry the G4 bench measured, and it is +the geometry the eval scores. ``Z = 1`` crops (2-D models) give their single plane. +The projection is then center-cropped to ``crop_size``: 240 px, which Cellpose pads to +exactly one 256-px tile (a 256-px crop pads to 272 and costs 2 x 2 tiles). + +The segmenter is held outside the module tree, so its weights never enter a +``state_dict`` (checkpoints stay the backbone's, and ``ckpt_path`` loads of baseline +checkpoints stay strict) and the module's ``train()`` cannot switch it out of eval +mode. It follows the prediction's device on first use. It runs with autocast +disabled, in its own dtype, as stock inference does. +""" + +import torch +import torch.nn.functional as F +from torch import Tensor, nn + +__all__ = ["SegConsistencyLoss", "nucleus_area_planes"] + +_GUARD_FRAC = 0.15 +_MIN_FOREGROUND = 0.01 +_OTSU_BINS = 256 + + +def _otsu_thresholds(flat: Tensor) -> Tensor: + """Per-row Otsu threshold, as ``skimage.filters.threshold_otsu`` (256 bins over each row's range). + + Parameters + ---------- + flat : Tensor + Values, shape ``(R, N)``. A constant row gets a meaningless threshold. + + Returns + ------- + Tensor + Threshold per row, shape ``(R,)``: the center of the bin maximizing the + between-class variance. + """ + lo = flat.amin(1, keepdim=True) + width = ((flat.amax(1, keepdim=True) - lo) / _OTSU_BINS).clamp_min(torch.finfo(flat.dtype).tiny) + idx = ((flat - lo) / width).floor().long().clamp(0, _OTSU_BINS - 1) + # scatter_add_ rather than bincount: bincount sizes its output on the host (a sync). + hist = torch.zeros(flat.shape[0], _OTSU_BINS, device=flat.device, dtype=torch.long) + hist = hist.scatter_add_(1, idx, torch.ones((), device=flat.device, dtype=torch.long).expand_as(idx)).double() + centers = lo.double() + width.double() * (torch.arange(_OTSU_BINS, device=flat.device) + 0.5) + w1 = hist.cumsum(1) + w2 = hist.flip(1).cumsum(1).flip(1) + m1 = (hist * centers).cumsum(1) / w1 + m2 = (hist * centers).flip(1).cumsum(1).flip(1) / w2 + var = w1[:, :-1] * w2[:, 1:] * (m1[:, :-1] - m2[:, 1:]) ** 2 + return centers.gather(1, var.argmax(1, keepdim=True)).squeeze(1).to(flat.dtype) + + +@torch.no_grad() +def nucleus_area_planes(vol: Tensor) -> Tensor: + """Return each volume's plane of largest nuclear foreground area, without a host sync. + + Mirrors ``dynacell.evaluation.focus.nucleus_area_plane``: Otsu over the whole + volume, per-plane foreground fraction, argmax inside a band that drops the outer + 15% of planes; a constant volume, or one whose band peak is below 1% foreground, + falls back to the center plane. + + Parameters + ---------- + vol : Tensor + Volumes, shape ``(B, Z, Y, X)``. + + Returns + ------- + Tensor + Plane index per volume, shape ``(B,)``, int64. + """ + b, z = vol.shape[:2] + center = torch.full((b,), z // 2, device=vol.device, dtype=torch.long) + flat = vol.reshape(b, -1).float() + # A constant row has no threshold; its NaN/arbitrary one is discarded below. + constant = flat.amax(1) <= flat.amin(1) + thr = _otsu_thresholds(flat) + frac = (flat > thr.unsqueeze(1)).reshape(b, z, -1).float().mean(2) + lo = int(round(_GUARD_FRAC * z)) + hi = max(lo + 1, int(round((1.0 - _GUARD_FRAC) * z))) + band = frac[:, lo:hi] + peak, arg = band.max(1) + use_center = constant | (peak < _MIN_FOREGROUND) + return torch.where(use_center, center, lo + arg) + + +def _slab_mip(vol: Tensor, planes: Tensor, halfwidth: int) -> Tensor: + """Max-project ``(B, Z, Y, X)`` onto the planes ``[p - h, p + h]`` clipped to the stack. + + Clipping repeats an edge plane, which leaves the maximum unchanged, so this equals + ``dynacell.evaluation.focus.slab_mip``. Returns ``(B, Y, X)``. + """ + z = vol.shape[1] + offsets = torch.arange(-halfwidth, halfwidth + 1, device=vol.device) + idx = (planes.unsqueeze(1) + offsets).clamp(0, z - 1) + idx = idx[:, :, None, None].expand(-1, -1, *vol.shape[2:]) + return vol.gather(1, idx).amax(1) + + +def _center_crop(img: Tensor, size: int | None) -> Tensor: + """Center-crop ``(..., Y, X)`` to at most ``size`` per side (no-op when ``None``).""" + if size is None: + return img + y, x = img.shape[-2:] + oy, ox = max(0, (y - size) // 2), max(0, (x - size) // 2) + return img[..., oy : oy + min(y, size), ox : ox + min(x, size)] + + +def _binary_kl(p_logit: Tensor, q_logit: Tensor) -> Tensor: + """Per-element binary KL(q || p) for ``q = sigmoid(q_logit)``; 0 iff the logits match.""" + q = torch.sigmoid(q_logit) + bce = F.binary_cross_entropy_with_logits(p_logit, q, reduction="none") + ent = F.binary_cross_entropy_with_logits(q_logit, q, reduction="none") + return bce - ent + + +class SegConsistencyLoss(nn.Module): + """Match a frozen segmenter's flows and cell probability on prediction and target. + + Parameters + ---------- + segmenter : nn.Module + Frozen differentiable segmenter mapping ``(N, 1, Y, X)`` images in any + positive-affine units to ``{"flow": (N, 2, Y, X), "cellprob_logit": (N, 1, Y, X)}`` + (e.g. ``dynacell.cellpose_dense.CellposeDense``). Put in eval mode with + ``requires_grad=False`` here; never the evaluation segmenter. + slab_halfwidth : int + Half-width of the max projection around a 3-D crop's focus plane. + crop_size : int or None + Center-crop side of the projected image, ``None`` for the whole image. + """ + + def __init__( + self, + segmenter: nn.Module, + slab_halfwidth: int = 1, + crop_size: int | None = 240, + ) -> None: + super().__init__() + if slab_halfwidth < 0: + raise ValueError(f"slab_halfwidth must be >= 0, got {slab_halfwidth}") + if crop_size is not None and crop_size < 1: + raise ValueError(f"crop_size must be >= 1 or None, got {crop_size}") + segmenter.eval() + segmenter.requires_grad_(False) + # Bypass nn.Module.__setattr__ so the segmenter is not a registered submodule. + self.__dict__["segmenter"] = segmenter + self.slab_halfwidth = slab_halfwidth + self.crop_size = crop_size + + def project(self, pred: Tensor, target: Tensor) -> tuple[Tensor, Tensor]: + """Reduce ``pred`` and ``target`` to the 2-D images the segmenter scores. + + Parameters + ---------- + pred, target : Tensor + Shape ``(B, 1, Z, Y, X)``. + + Returns + ------- + tuple of (Tensor, Tensor) + Projected ``pred`` and ``target``, each ``(B, 1, Y', X')``. + """ + if pred.shape != target.shape: + raise ValueError(f"pred and target must share a shape; got {tuple(pred.shape)}, {tuple(target.shape)}") + if pred.ndim != 5 or pred.shape[1] != 1: + raise ValueError(f"expected (B, 1, Z, Y, X), got {tuple(pred.shape)}") + planes = nucleus_area_planes(target[:, 0].detach()) + pred = _slab_mip(pred[:, 0], planes, self.slab_halfwidth).unsqueeze(1) + target = _slab_mip(target[:, 0], planes, self.slab_halfwidth).unsqueeze(1) + return _center_crop(pred, self.crop_size), _center_crop(target, self.crop_size) + + def _segment(self, img: Tensor) -> dict[str, Tensor]: + seg = self.segmenter + if next(seg.parameters()).device != img.device: + seg.to(img.device) + with torch.autocast(device_type=img.device.type, enabled=False): + return seg(img.to(torch.promote_types(img.dtype, torch.float32))) + + def forward( + self, pred: Tensor, target: Tensor, return_components: bool = False + ) -> Tensor | tuple[Tensor, dict[str, Tensor]]: + """Compute the consistency loss. + + Parameters + ---------- + pred : Tensor + Prediction, ``(B, 1, Z, Y, X)``. + target : Tensor + Ground truth, same shape. + return_components : bool + Also return ``{"segcons", "segcons_flow", "segcons_prob"}``. + + Returns + ------- + Tensor or tuple of (Tensor, dict of str to Tensor) + Scalar float32 loss, optionally with its flow and probability terms. + """ + pred2d, target2d = self.project(pred, target.detach()) + with torch.no_grad(): + ref = self._segment(target2d) + out = self._segment(pred2d) + flow_sq = ((out["flow"] - ref["flow"]) ** 2).mean(1) + kl = _binary_kl(out["cellprob_logit"], ref["cellprob_logit"])[:, 0] + flow_term, prob_term = flow_sq.mean(), kl.mean() + loss = flow_term + prob_term + if return_components: + return loss, {"segcons": loss, "segcons_flow": flow_term, "segcons_prob": prob_term} + return loss diff --git a/packages/viscy-utils/src/viscy_utils/losses/spotlight.py b/packages/viscy-utils/src/viscy_utils/losses/spotlight.py index 1d74a52a3..c059255b1 100644 --- a/packages/viscy-utils/src/viscy_utils/losses/spotlight.py +++ b/packages/viscy-utils/src/viscy_utils/losses/spotlight.py @@ -158,7 +158,13 @@ def __init__( self.fg_threshold = fg_threshold self._warned_no_real_mask = False - def forward(self, pred: Tensor, target: Tensor, fg_mask: Tensor | None = None) -> Tensor: + def forward( + self, + pred: Tensor, + target: Tensor, + fg_mask: Tensor | None = None, + return_components: bool = False, + ) -> Tensor | tuple[Tensor, dict[str, Tensor]]: """Compute the Spotlight loss. Loss is computed per (batch, channel) pair and averaged. Channels @@ -176,11 +182,18 @@ def forward(self, pred: Tensor, target: Tensor, fg_mask: Tensor | None = None) - When provided, used directly instead of computing from target. When None (default), mask is computed at runtime using ``fg_threshold`` or Otsu thresholding. + return_components : bool + When ``True``, also return the two terms that make up the loss. + The terms are the ones actually summed here, so logging them is a + faithful record of the objective being optimized — re-deriving them + in the caller would silently fork from it. Returns ------- - Tensor - Scalar loss value. + Tensor or tuple of (Tensor, dict of str to Tensor) + Scalar loss value, or ``(loss, {"masked_mse": ..., "dice": ...})`` + when ``return_components`` is ``True``. The components are the + unweighted terms, before ``lambda_mse`` is applied. """ # Foreground mask: precomputed > fixed threshold > per-(sample, channel) Otsu if fg_mask is not None: @@ -222,4 +235,7 @@ def forward(self, pred: Tensor, target: Tensor, fg_mask: Tensor | None = None) - self._warned_no_real_mask = True dice = pred.new_tensor(0.0) - return self.lambda_mse * masked_mse + (1 - self.lambda_mse) * dice + total = self.lambda_mse * masked_mse + (1 - self.lambda_mse) * dice + if return_components: + return total, {"masked_mse": masked_mse, "dice": dice} + return total diff --git a/packages/viscy-utils/tests/test_background_lowpass.py b/packages/viscy-utils/tests/test_background_lowpass.py new file mode 100644 index 000000000..d5012ffe5 --- /dev/null +++ b/packages/viscy-utils/tests/test_background_lowpass.py @@ -0,0 +1,254 @@ +"""Unit tests for BackgroundLowPass, checked against scipy.ndimage references.""" + +import numpy as np +import pytest +import torch +from scipy import ndimage + +from viscy_utils.losses import BackgroundLowPass + + +def _square_batch(shape: tuple[int, ...], lo: int, hi: int, seed: int = 0) -> tuple[torch.Tensor, torch.Tensor]: + """Noisy target with a bright square; mask = the square ``[lo, hi)`` in YX of every plane.""" + g = torch.Generator().manual_seed(seed) + mask = torch.zeros(shape) + mask[..., lo:hi, lo:hi] = 1.0 + target = torch.randn(shape, generator=g) + 2.0 * mask + return target, mask + + +def _gaussian(a: np.ndarray, sigma_z: float, sigma: float) -> np.ndarray: + return ndimage.gaussian_filter(a, sigma=(0, 0, sigma_z, sigma, sigma), mode="nearest", truncate=4.0) + + +def _reference(op: BackgroundLowPass, target: torch.Tensor, mask: torch.Tensor) -> tuple[np.ndarray, np.ndarray]: + """``(w, x')`` from scipy: square max filter, Gaussians with ``mode="nearest"``, in float64.""" + x = target.double().numpy() + rz, r = op.dilate_radius_z, op.dilate_radius + dilated = ndimage.maximum_filter( + (mask.numpy() > 0.5).astype(np.float64), size=(1, 1, 2 * rz + 1, 2 * r + 1, 2 * r + 1), mode="constant" + ) + w = _gaussian(dilated, op.sigma_feather_z, op.sigma_feather).clip(0.0, 1.0) + flat = x.copy() # planes without background keep x + for i, j, d in np.ndindex(*x.shape[:3]): + if (dilated[i, j, d] == 0).any(): + flat[i, j, d] = x[i, j, d][dilated[i, j, d] == 0].mean() + if op.sigma_lp is None: + bg = flat + else: + num = _gaussian(x * (1.0 - dilated), op.sigma_lp_z, op.sigma_lp) + den = _gaussian(1.0 - dilated, op.sigma_lp_z, op.sigma_lp) + bg = np.where(den > 1e-6, num / np.maximum(den, 1e-6), flat) + return w, w * x + (1.0 - w) * bg + + +@pytest.mark.parametrize( + ("shape", "sigma_lp", "z_args"), + [ + ((2, 1, 1, 64, 64), 2.0, {}), + ((2, 2, 5, 48, 40), 2.0, {}), + ((1, 1, 6, 40, 40), 2.0, {"sigma_lp_z": 1.0, "sigma_feather_z": 0.5, "dilate_radius_z": 1}), + ((2, 1, 1, 64, 64), None, {}), + ((2, 2, 5, 48, 40), None, {"sigma_feather_z": 0.5, "dilate_radius_z": 1}), + ], + ids=["d1", "per_plane", "with_z", "flat_d1", "flat_with_z"], +) +def test_matches_scipy_reference(shape, sigma_lp, z_args): + """The blend weight and the output equal the scipy.ndimage pipeline, D=1 and D>1, smooth and flat.""" + op = BackgroundLowPass(sigma_lp=sigma_lp, sigma_feather=1.5, dilate_radius=3, **z_args) + target, mask = _square_batch(shape, 12, 28) + mask[:, :, : shape[2] // 2] = 0.0 # planes differ, so Z handling matters + w_ref, out_ref = _reference(op, target, mask) + np.testing.assert_allclose(op.blend_weight(op.dilated_mask(mask)).numpy(), w_ref, atol=1e-6) + np.testing.assert_allclose(op(target, mask).numpy(), out_ref, atol=1e-5) + + +def test_identity_where_w_is_one_and_background_where_w_is_zero(): + """Inside the dilated mask shrunk by the feather radius x' = x; beyond its reach x' = B.""" + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=4) + target, mask = _square_batch((2, 1, 1, 64, 64), 16, 48) + out = op(target, mask) + dilated = op.dilated_mask(mask) + # Dilated square [12, 52); the feather kernel's radius is int(4 * 2 + 0.5) = 8. + inner = (..., slice(20, 44), slice(20, 44)) + torch.testing.assert_close(out[inner], target[inner], rtol=0.0, atol=1e-6) + # Rows 0-3 are > 8 rows from the dilated square: w is exactly 0 there. + assert torch.equal(op.blend_weight(dilated)[..., :4, :], torch.zeros(2, 1, 1, 4, 64)) + assert torch.equal(out[..., :4, :], op.background(target, dilated)[..., :4, :]) + # ... and the background estimate is not the identity there. + assert (out[..., :4, :] - target[..., :4, :]).abs().mean() > 0.3 + + +def test_background_estimate_does_not_leak_the_foreground(): + """On a constant background B is that constant everywhere, even next to a bright blob a plain blur halos.""" + op = BackgroundLowPass(sigma_lp=4.0, sigma_feather=1.0, dilate_radius=2) + mask = torch.zeros((1, 1, 1, 64, 64)) + mask[..., 16:48, 16:48] = 1.0 + target = 0.7 + 10.0 * mask + dilated = op.dilated_mask(mask) + ring = (..., slice(50, 54), slice(16, 48)) # just outside the dilated square [14, 50) + assert torch.equal(dilated[ring], torch.zeros(1, 1, 1, 4, 32)) + plain = _gaussian(target.double().numpy(), 0.0, op.sigma_lp) + assert plain[ring].max() > 2.7 # the halo a plain G_lp(x) would teach + background = op.background(target, dilated) + torch.testing.assert_close(background, torch.full_like(target, 0.7), rtol=0.0, atol=1e-6) + out = op(target, mask) + torch.testing.assert_close(out[dilated == 0], torch.full_like(out[dilated == 0], 0.7), rtol=0.0, atol=1e-6) + + +def test_flat_background_is_the_masked_mean_per_plane(): + """sigma_lp=None: wherever w == 0, x' is one constant per (sample, channel, plane), its background mean.""" + op = BackgroundLowPass(sigma_lp=None, sigma_feather=2.0, dilate_radius=4) + target, mask = _square_batch((2, 2, 3, 64, 64), 16, 48) + mask[0, :, 1] = 0.0 # planes differ + target = target + torch.arange(3.0).reshape(3, 1, 1) + torch.linspace(-1.0, 1.0, 64) # per-plane offset + ramp + out = op(target, mask) + dilated = op.dilated_mask(mask) + zero_w = op.blend_weight(dilated) == 0 + for i, j, d in np.ndindex(2, 2, 3): + expected = target[i, j, d][dilated[i, j, d] == 0].double().mean().item() + values = out[i, j, d][zero_w[i, j, d]] + assert values.numel() > 300 + torch.testing.assert_close(values, torch.full_like(values, expected), rtol=0.0, atol=1e-5) + # An empty mask flattens every plane to its own mean. + flat = op(target, torch.zeros_like(target)) + torch.testing.assert_close(flat, target.mean(dim=(3, 4), keepdim=True).expand_as(target), rtol=0.0, atol=1e-5) + + +@pytest.mark.parametrize("sigma_lp", [None, 8.0], ids=["bgflat", "bglp"]) +def test_backlight_ramp_is_removed_by_flat_and_kept_by_smooth(sigma_lp): + """A linear backlight ramp: bgflat flattens it, bglp (even at a large sigma) keeps it.""" + op = BackgroundLowPass(sigma_lp=sigma_lp, sigma_feather=2.0, dilate_radius=4) + slope = 0.02 + mask = torch.zeros((1, 1, 1, 96, 96)) + mask[..., 40:56, 40:56] = 1.0 + target = slope * torch.arange(96.0) + 5.0 * mask + out = op(target, mask) + zero_w = op.blend_weight(op.dilated_mask(mask)) == 0 + values = out[zero_w] + if sigma_lp is None: + assert torch.unique(values).numel() == 1 + else: + cols = torch.arange(96.0).expand_as(target)[zero_w] + fitted = np.polyfit(cols.numpy(), values.numpy(), 1)[0] + assert 0.8 * slope < fitted < 1.2 * slope + + +def test_background_keeps_the_local_mean(): + """Background = constant + checkerboard: B removes the checkerboard and keeps the constant.""" + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=4) + yy, xx = torch.meshgrid(torch.arange(48), torch.arange(48), indexing="ij") + target = (3.0 + 0.5 * (-1.0) ** (yy + xx)).float().expand(1, 1, 1, 48, 48) + out = op(target, torch.zeros_like(target)) + # Away from the border, where edge replication breaks the checkerboard's alternation. + torch.testing.assert_close(out[..., 8:-8, 8:-8], torch.full((1, 1, 1, 32, 32), 3.0), rtol=0.0, atol=1e-4) + # A constant passes through unchanged whatever the mask. + const = torch.full((2, 1, 3, 32, 32), -0.7) + _, mask = _square_batch(const.shape, 8, 20) + torch.testing.assert_close(op(const, mask), const, rtol=0.0, atol=1e-6) + + +def test_empty_mask_blurs_everything(): + """An empty (or sub-threshold) mask makes B a plain blur of the whole target, and x' = B.""" + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=4) + target, _ = _square_batch((2, 1, 1, 32, 32), 0, 0) + blurred = _gaussian(target.double().numpy(), 0.0, op.sigma_lp) + for mask in (torch.zeros_like(target), torch.full_like(target, 0.4)): + np.testing.assert_allclose(op(target, mask).numpy(), blurred, atol=1e-5) + + +@pytest.mark.parametrize("sigma_feather", [0.5, 1.0, 2.0, 3.0]) +def test_all_foreground_plane_has_w_exactly_one_and_is_returned_unchanged(sigma_feather): + """Under an all-foreground plane w == 1 exactly (not 1 - rounding), so the plane comes back bit-identical.""" + target, mask = _square_batch((2, 1, 3, 32, 32), 8, 20) + mask[:, :, 1] = 1.0 + for sigma_lp in (2.0, None): + op = BackgroundLowPass(sigma_lp=sigma_lp, sigma_feather=sigma_feather, dilate_radius=4) + assert torch.equal(op.blend_weight(op.dilated_mask(mask))[:, :, 1], torch.ones(2, 1, 32, 32)) + out = op(target, mask) + assert torch.equal(out[:, :, 1], target[:, :, 1]) + assert not torch.equal(out[:, :, 0], target[:, :, 0]) + + +@pytest.mark.parametrize("sigma_lp", [2.0, None], ids=["smooth", "flat"]) +def test_patch_without_background_is_returned_unchanged(sigma_lp): + """Where the dilated mask covers the patch, B = x and w == 1: x' = x exactly; other patches are unaffected.""" + op = BackgroundLowPass(sigma_lp=sigma_lp, sigma_feather=2.0, dilate_radius=4) + target, mask = _square_batch((2, 1, 1, 32, 32), 8, 20) + mask[0] = 1.0 + mask[0, ..., 5:7, 9:11] = 0.0 # a hole the dilation fills + dilated = op.dilated_mask(mask) + assert torch.equal(dilated[0], torch.ones_like(dilated[0])) + assert torch.equal(op.blend_weight(dilated)[0], torch.ones_like(dilated[0])) + assert torch.equal(op.background(target, dilated)[0], target[0]) + out = op(target, mask) + assert torch.equal(out[0], target[0]) + assert torch.equal(out[1:], op(target[1:], mask[1:])) + + +def test_thin_background_ring_falls_back_to_the_plane_mean(): + """Pixels the normalized convolution cannot reach take the plane's flat mean; nothing is NaN or Inf.""" + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=1) + target, mask = _square_batch((1, 1, 1, 48, 48), 3, 45) # dilated: [2, 46), a 2-px background ring + dilated = op.dilated_mask(mask) + background = op.background(target, dilated) + den = _gaussian(1.0 - dilated.double().numpy(), 0.0, op.sigma_lp) + unreached = torch.from_numpy(den <= 1e-6) + assert unreached.sum() > 100 # the kernel's support (radius 8) misses the ring from the centre + ring_mean = target[dilated == 0].mean() + torch.testing.assert_close(background[unreached], torch.full_like(background[unreached], ring_mean.item())) + assert torch.isfinite(background).all() + assert torch.isfinite(op(target, mask)).all() + + +def test_planes_are_independent_by_default(): + """With every ``*_z`` at 0, each Z plane is the op applied to that plane alone.""" + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=1.0, dilate_radius=2) + target, mask = _square_batch((2, 1, 4, 32, 32), 8, 20) + mask[:, :, 1] = 0.0 # planes differ, so a leak across Z would show + out = op(target, mask) + for d in range(4): + plane = op(target[:, :, d : d + 1], mask[:, :, d : d + 1]) + torch.testing.assert_close(out[:, :, d : d + 1], plane, rtol=0.0, atol=1e-6) + + +def test_dtype_and_autocast(): + """Low-precision input comes back in its dtype, computed in float32; autocast changes nothing.""" + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=4) + target, mask = _square_batch((2, 1, 1, 32, 32), 8, 20) + bf16 = target.bfloat16() + out = op(bf16, mask) + assert out.dtype == torch.bfloat16 + assert torch.equal(out, op(bf16.float(), mask).bfloat16()) + with torch.autocast(device_type="cpu", dtype=torch.bfloat16): + autocast_out = op(target, mask) + assert autocast_out.dtype == torch.float32 + assert torch.equal(autocast_out, op(target, mask)) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA device") +@pytest.mark.parametrize("sigma_lp", [2.0, None], ids=["smooth", "flat"]) +def test_cuda_matches_cpu(sigma_lp): + """The GPU path agrees with the CPU path (no convolution backend, so no TF32).""" + op = BackgroundLowPass(sigma_lp=sigma_lp, sigma_feather=2.0, dilate_radius=4) + target, mask = _square_batch((2, 1, 1, 64, 64), 16, 48) + out = op(target.cuda(), mask.cuda()) + assert out.device.type == "cuda" + torch.testing.assert_close(out.cpu(), op(target, mask), rtol=0.0, atol=1e-5) + + +def test_rejects_bad_arguments_and_shapes(): + with pytest.raises(ValueError, match="sigma_lp must be > 0 or None"): + BackgroundLowPass(sigma_lp=0.0, sigma_feather=2.0, dilate_radius=4) + with pytest.raises(ValueError, match="no effect with a flat background"): + BackgroundLowPass(sigma_lp=None, sigma_feather=2.0, dilate_radius=4, sigma_lp_z=1.0) + assert BackgroundLowPass(sigma_lp=float("inf"), sigma_feather=2.0, dilate_radius=4).sigma_lp is None + with pytest.raises(ValueError, match="dilate_radius must be >= 0"): + BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=-1) + with pytest.raises(ValueError, match="sigma_feather_z must be >= 0"): + BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=4, sigma_feather_z=-1.0) + op = BackgroundLowPass(sigma_lp=2.0, sigma_feather=2.0, dilate_radius=4) + with pytest.raises(ValueError, match="must match target"): + op(torch.zeros(1, 1, 1, 8, 8), torch.zeros(1, 1, 1, 8, 4)) + with pytest.raises(ValueError, match=r"\(B, C, D, H, W\)"): + op(torch.zeros(1, 1, 8, 8), torch.zeros(1, 1, 8, 8)) diff --git a/packages/viscy-utils/tests/test_seg_aux_loss.py b/packages/viscy-utils/tests/test_seg_aux_loss.py new file mode 100644 index 000000000..fe986826f --- /dev/null +++ b/packages/viscy-utils/tests/test_seg_aux_loss.py @@ -0,0 +1,619 @@ +"""Unit tests for SegAuxDice.""" + +import numpy as np +import pytest +import torch +from scipy import ndimage + +from viscy_utils.losses import SegAuxDice +from viscy_utils.losses.seg_aux import _masked_median, _quantile_sorted +from viscy_utils.losses.seg_aux_maps import box_max, sauna_signed_map, sauna_weight_map, soft_skeleton, squared_edt + + +def _blob_batch(shape: tuple[int, ...], seed: int = 0) -> tuple[torch.Tensor, torch.Tensor]: + """Target = bright box on a noisy background; mask = the box.""" + g = torch.Generator().manual_seed(seed) + target = 0.1 * torch.randn(shape, generator=g) + mask = torch.zeros(shape) + mask[..., shape[-2] // 4 : shape[-2] // 2, shape[-1] // 4 : shape[-1] // 2] = 1.0 + target = target + 2.0 * mask + return target, mask + + +@pytest.mark.parametrize("shape", [(2, 1, 1, 32, 32), (2, 2, 4, 16, 16)], ids=["2d", "3d"]) +def test_perfect_prediction_beats_perturbed(shape): + target, mask = _blob_batch(shape) + loss_fn = SegAuxDice() + perfect = loss_fn(target.clone(), target, mask) + g = torch.Generator().manual_seed(1) + perturbed = loss_fn(target + 0.5 * torch.randn(shape, generator=g), target, mask) + assert perfect.ndim == 0 + assert perfect < perturbed + blurred_out = loss_fn(torch.zeros(shape), target, mask) + assert perfect < blurred_out + + +def test_gradient_nonzero_for_foreground_far_below_tau(): + """A foreground voxel predicted several knee widths below tau still gets a + gradient pushing it up; v1's clamped sigmoid gave exactly zero there.""" + target, mask = _blob_batch((1, 1, 1, 32, 32)) + flat = target.flatten() + tau = torch.quantile(flat, 1 - mask.mean()) + fg = mask.flatten() > 0.5 + s = 0.1 * (flat[fg].median() - flat[~fg].median()) + pred = target.clone() + fg_idx = (0, 0, 0, 10, 10) + assert mask[fg_idx] == 1 + pred[fg_idx] = tau - 8 * s + pred.requires_grad_(True) + SegAuxDice(c=0.1)(pred, target, mask).backward() + assert pred.grad[fg_idx] < 0 # increasing pred there lowers the loss + + +@pytest.mark.parametrize("a,b", [(3.0, -5.0), (0.01, 1.0), (1.0, 0.7)]) +def test_affine_invariance(a, b): + target, mask = _blob_batch((2, 1, 4, 16, 16)) + pred = target + 0.3 * torch.randn(target.shape, generator=torch.Generator().manual_seed(2)) + loss_fn = SegAuxDice(c=0.1) + ref = loss_fn(pred, target, mask) + moved = loss_fn(a * pred + b, a * target + b, mask) + torch.testing.assert_close(moved, ref, rtol=1e-4, atol=1e-5) + + +def test_masked_median_matches_torch_on_selection(): + g = torch.Generator().manual_seed(7) + values = torch.randn(4, 101, generator=g) + keep = torch.rand(4, 101, generator=g) > 0.6 + got = _masked_median(values, keep) + want = torch.stack([torch.quantile(v[k], 0.5) for v, k in zip(values, keep)]) + torch.testing.assert_close(got, want) + + +def test_mask_darker_than_background_is_excluded(): + target, mask = _blob_batch((1, 1, 1, 32, 32)) + loss, comps = SegAuxDice()(target.clone(), -target, mask, return_components=True) + assert comps["n_valid"] == 0 + + +def test_empty_and_full_masks_are_excluded(): + target, mask = _blob_batch((3, 1, 1, 32, 32)) + mask[1] = 0.0 + mask[2] = 1.0 + pred = target + 0.3 * torch.randn(target.shape, generator=torch.Generator().manual_seed(3)) + loss_fn = SegAuxDice() + loss, comps = loss_fn(pred, target, mask, return_components=True) + only_valid = loss_fn(pred[:1], target[:1], mask[:1]) + assert comps["n_valid"] == 1 + torch.testing.assert_close(loss, only_valid) + torch.testing.assert_close(comps["dice"], loss) + + +def test_all_invalid_is_zero_with_graph(): + target = torch.randn(2, 1, 1, 16, 16) + pred = torch.randn(2, 1, 1, 16, 16, requires_grad=True) + loss, comps = SegAuxDice()(pred, target, torch.zeros_like(target), return_components=True) + assert loss.item() == 0.0 + assert comps["n_valid"] == 0 + assert loss.requires_grad + loss.backward() + assert torch.equal(pred.grad, torch.zeros_like(pred)) + + +def test_constant_target_is_excluded(): + target = torch.ones(1, 1, 1, 16, 16) + mask = torch.zeros_like(target) + mask[..., :8, :] = 1.0 + loss, comps = SegAuxDice()(torch.randn_like(target), target, mask, return_components=True) + assert comps["n_valid"] == 0 + assert loss.item() == 0.0 + + +def test_fractional_mask_is_binarized(): + target, mask = _blob_batch((1, 1, 1, 32, 32)) + pred = target + 0.2 + loss_fn = SegAuxDice() + torch.testing.assert_close(loss_fn(pred, target, mask * 0.8), loss_fn(pred, target, mask)) + + +def test_quantile_matches_torch_quantile(): + rows = torch.randn(4, 1001) + q = torch.tensor([0.0, 0.25, 0.731, 1.0]) + got = _quantile_sorted(rows.sort(-1).values, q) + expected = torch.stack([torch.quantile(rows[i], q[i]) for i in range(4)]) + torch.testing.assert_close(got, expected) + + +def test_quantile_beyond_torch_quantile_size_limit(): + """torch.quantile raises above 2**24 elements; the sort-based path does not.""" + n = 2**24 + 16 + row = torch.arange(n, dtype=torch.float32).unsqueeze(0) + got = _quantile_sorted(row, torch.tensor([0.5])) + torch.testing.assert_close(got, torch.tensor([(n - 1) / 2])) + + +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +def test_autocast_computes_in_float32(dtype): + target, mask = _blob_batch((2, 1, 4, 16, 16)) + pred = (target + 0.3).requires_grad_(True) + ref = SegAuxDice()(pred, target, mask) + with torch.autocast(device_type="cpu", dtype=dtype): + low = SegAuxDice()(pred.to(dtype), target.to(dtype), mask.to(dtype)) + assert low.dtype == torch.float32 + assert torch.isfinite(low) + torch.testing.assert_close(low, ref, rtol=0.05, atol=0.02) + low.backward() + assert torch.isfinite(pred.grad).all() + + +def test_shape_mismatch_raises(): + with pytest.raises(ValueError, match="share a shape"): + SegAuxDice()(torch.zeros(1, 1, 4, 4), torch.zeros(1, 1, 4, 4), torch.zeros(1, 1, 4, 5)) + + +def _mask_disagrees_with_target(shape: tuple[int, ...] = (2, 1, 1, 32, 32)) -> tuple[torch.Tensor, torch.Tensor]: + """Box target plus a bright blob outside the mask: the mask is not a threshold of the target.""" + target, mask = _blob_batch(shape) + target[..., -6:, -6:] += 2.0 + return target, mask + + +def test_target_label_is_minimised_at_pred_equals_target(): + """label='target' is ~0 with zero gradient at pred == target, where label='mask' has a floor.""" + target, mask = _mask_disagrees_with_target() + floor = SegAuxDice(label="mask")(target.clone(), target, mask) + pred = target.clone().requires_grad_(True) + loss = SegAuxDice(label="target")(pred, target, mask) + loss.backward() + assert floor > 0.05 + assert loss < 1e-4 + assert pred.grad.abs().max() < 1e-4 + g = torch.Generator().manual_seed(1) + perturbed = target + 0.5 * torch.randn(target.shape, generator=g) + assert SegAuxDice(label="target")(perturbed, target, mask) > 10 * loss + + +def test_target_label_keeps_the_mask_validity_rule(): + """The mask still decides which patches count: an empty mask is excluded under either label.""" + target, mask = _blob_batch((2, 1, 1, 16, 16)) + mask[0] = 0.0 + for label in ("mask", "target"): + _, valid = SegAuxDice(label=label).per_channel(target, target, mask) + assert valid.tolist() == [[False], [True]] + + +def test_unknown_label_raises(): + with pytest.raises(ValueError, match="label"): + SegAuxDice(label="soft") + + +# ---- Defaults are bit-identical to the pre-extension implementation ---- + +# per_channel(...).dice on _reference_inputs(), computed before weighting/topology existed. +_PRE_EXTENSION_DICE = { + "mask": ["0x1.71c02p-3", "0x1.73a7cp-3", "0x1.37d6bp-3", "0x1.66fff8p-3", "0x1.530968p-3", "0x1.0p+0"], + "target": ["0x1.9c865p-4", "0x1.b3d3ap-4", "0x1.57231p-4", "0x1.a811fp-4", "0x1.80088p-4", "0x1.740c6p-5"], +} + + +def _reference_inputs() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + g = torch.Generator().manual_seed(123) + shape = (3, 2, 3, 12, 12) + target = torch.randn(shape, generator=g) + mask = (target + 0.3 * torch.randn(shape, generator=g) > 0.5).float() + mask[2, 1] = 0.0 + pred = target + 0.4 * torch.randn(shape, generator=g) + return pred, target, mask + + +@pytest.mark.parametrize("label", ["mask", "target"]) +def test_defaults_are_bit_identical_to_pre_extension(label): + pred, target, mask = _reference_inputs() + loss_fn = SegAuxDice(label=label, weighting="none", topology="none") + dice, valid = loss_fn.per_channel(pred, target, mask) + assert dice.dtype == torch.float32 + assert dice.flatten().tolist() == [float.fromhex(h) for h in _PRE_EXTENSION_DICE[label]] + assert valid.flatten().tolist() == [True] * 5 + [False] + + +# ---- Exact torch EDT ---- + + +def _random_mask(shape: tuple[int, ...], fraction: float, seed: int) -> np.ndarray: + rng = np.random.default_rng(seed) + return rng.random(shape) < fraction + + +@pytest.mark.parametrize( + "shape,spacing", + [ + ((40, 37), (1.0, 1.0)), + ((40, 37), (0.3, 1.7)), + ((9, 21, 18), (1.0, 1.0, 1.0)), + ((9, 21, 18), (0.29, 0.108, 0.108)), + ((1, 30, 25), (0.29, 0.108, 0.108)), + ], + ids=["2d-iso", "2d-aniso", "3d-iso", "3d-aniso", "3d-z1"], +) +@pytest.mark.parametrize("fraction", [0.02, 0.5]) +def test_squared_edt_matches_scipy(shape, spacing, fraction): + seeds = np.stack([_random_mask(shape, fraction, seed) for seed in range(3)]) + seeds[:, (0,) * len(shape)] = True # at least one seed per row + got = squared_edt(torch.from_numpy(seeds), spacing).sqrt().numpy() + want = np.stack([ndimage.distance_transform_edt(~s, sampling=spacing) for s in seeds]) + np.testing.assert_allclose(got, want, rtol=1e-4, atol=1e-6) + + +@pytest.mark.parametrize("max_distance", [0.5, 2.0, 7.5]) +def test_squared_edt_capped_is_exact_below_the_cap(max_distance): + shape, spacing = (9, 41, 38), (0.29, 0.108, 0.108) + seeds = np.stack([_random_mask(shape, 0.01, seed) for seed in range(2)]) + seeds[:, 0, 0, 0] = True + got = squared_edt(torch.from_numpy(seeds), spacing, max_distance=max_distance).sqrt().numpy() + want = np.stack([ndimage.distance_transform_edt(~s, sampling=spacing) for s in seeds]) + np.testing.assert_allclose(got, np.minimum(want, max_distance), rtol=1e-4, atol=1e-6) + + +@pytest.mark.parametrize("shape", [(2, 1, 5, 20, 20), (2, 1, 1, 20, 20), (2, 1, 24, 20)], ids=["3d", "3d-z1", "2d"]) +def test_soft_skeleton_matches_official_values_and_gradients(shape): + """Max-pool morphology (F.max_pool{1,2,3}d) + checkpointing reproduce jocpae/clDice soft_skel.""" + img = torch.rand(shape, generator=torch.Generator().manual_seed(8)) + weights = torch.rand(img.shape, generator=torch.Generator().manual_seed(9)) + a = img.clone().requires_grad_(True) + (soft_skeleton(a, 4) * weights).sum().backward() + b = img.clone().requires_grad_(True) + official = _official_soft_skel(b, 4) + (official * weights).sum().backward() + with torch.no_grad(): + torch.testing.assert_close(soft_skeleton(img, 4), official) + torch.testing.assert_close(a.grad, b.grad) + + +def _official_soft_skel(img: torch.Tensor, iters: int) -> torch.Tensor: + """jocpae/clDice soft_skeleton.py, verbatim for 4D and 5D input.""" + F = torch.nn.functional + + def soft_erode(img): + if len(img.shape) == 4: + p1 = -F.max_pool2d(-img, (3, 1), (1, 1), (1, 0)) + p2 = -F.max_pool2d(-img, (1, 3), (1, 1), (0, 1)) + return torch.min(p1, p2) + p1 = -F.max_pool3d(-img, (3, 1, 1), (1, 1, 1), (1, 0, 0)) + p2 = -F.max_pool3d(-img, (1, 3, 1), (1, 1, 1), (0, 1, 0)) + p3 = -F.max_pool3d(-img, (1, 1, 3), (1, 1, 1), (0, 0, 1)) + return torch.min(torch.min(p1, p2), p3) + + def soft_dilate(img): + if len(img.shape) == 4: + return F.max_pool2d(img, (3, 3), (1, 1), (1, 1)) + return F.max_pool3d(img, (3, 3, 3), (1, 1, 1), (1, 1, 1)) + + def soft_open(img): + return soft_dilate(soft_erode(img)) + + img1 = soft_open(img) + skel = F.relu(img - img1) + for _ in range(iters): + img = soft_erode(img) + img1 = soft_open(img) + delta = F.relu(img - img1) + skel = skel + F.relu(delta - skel * delta) + return skel + + +@pytest.mark.parametrize("windows", [(1, 3, 5), (3, 1, 7), (5, 9, 31), (9, 3, 1)]) +def test_box_max_matches_scipy_maximum_filter(windows): + x = torch.rand(2, 4, 13, 11, generator=torch.Generator().manual_seed(3)) + want = np.stack([ndimage.maximum_filter(r, size=windows, mode="constant", cval=-np.inf) for r in x.numpy()]) + np.testing.assert_array_equal(box_max(x, windows).numpy(), want) + + +def test_squared_edt_without_seeds_is_inf(): + d2 = squared_edt(torch.zeros(2, 4, 5, dtype=torch.bool), (1.0, 1.0)) + assert torch.isinf(d2).all() + + +# ---- SAUNA map ---- + + +def _sauna_reference(gt: np.ndarray) -> np.ndarray: + """SAUNA's combined "h" map, line by line from Oulu-IMEDS/SAUNA + ``generate_uncertainty_masks.py``, with scipy's exact EDT in place of + ``cv2.distanceTransform(..., DIST_L2, DIST_MASK_5)``. ``gt`` is uint8 0/255.""" + + def compute_distance_transform(image): + return ndimage.distance_transform_edt(image).astype(np.float32) + + def do_max_pooling(img_dist): + kernel_size = int(np.ceil(img_dist.max())) + kernel_size = kernel_size + 1 if kernel_size % 2 == 0 else kernel_size + maxpool = torch.nn.MaxPool2d(kernel_size=kernel_size, stride=1, padding=kernel_size // 2) + return maxpool(torch.tensor(img_dist).unsqueeze(0).unsqueeze(0))[0, 0].numpy() + + # extract_boundary_uncertainty_map + img_fg_dist = compute_distance_transform(gt) + fg_max = img_fg_dist.max() + img_bg_dist = -compute_distance_transform(255 - gt) + img_bg_dist[img_bg_dist <= -fg_max] = -fg_max + gt_b = (img_fg_dist + img_bg_dist) / (fg_max + 1e-6) + # extract_thickness_uncertainty_map, target_c_label="h" + img_dist = compute_distance_transform(gt) + thickness_max = img_dist.max() + img_thick_pos = (gt > 0) * do_max_pooling(img_dist) / (thickness_max + 1e-6) + img_bg_dist = compute_distance_transform(255 - gt) + img_bg_dist[img_bg_dist >= fg_max] = fg_max + img_thick_neg = (gt <= 0) * do_max_pooling(img_bg_dist) / (thickness_max + 1e-6) + img_thick_neg = np.clip(img_thick_neg, a_min=0.0, a_max=1.0) + gt_t = np.where(gt > 0, img_thick_pos, img_thick_neg) + # extract_combined_uncertainty_map, target_c_label="h" + gt_c = gt_b.copy() + gt_t_abs = np.abs(gt_t) + fg = (gt_t_abs > 0) & (gt_b > 0) + gt_c[fg] = gt_b[fg] + (1.0 - gt_t[fg]) + bg = (gt_t_abs > 0) & (gt_b < 0) + gt_c[bg] = gt_b[bg] - (1.0 - gt_t[bg]) + return np.clip(gt_c, a_min=-1.0, a_max=1.0) + + +def _disk(canvas: np.ndarray, cy: int, cx: int, r: float) -> None: + yy, xx = np.ogrid[: canvas.shape[0], : canvas.shape[1]] + canvas[(yy - cy) ** 2 + (xx - cx) ** 2 <= r * r] = True + + +def _synthetic_shapes() -> tuple[np.ndarray, dict[str, tuple[int, int]]]: + """Fat disk, 2-px line, two disks across a 2-px gap and a 2-px slit; probe voxels.""" + m = np.zeros((320, 320), dtype=bool) + _disk(m, 50, 50, 20) # rows/cols 30..70 + m[120:122, 20:230] = True # 2-px-wide line + _disk(m, 200, 100, 20) # cols 80..120 + _disk(m, 200, 143, 20) # cols 123..163: gap = cols 121, 122 + m[250:291, 20:100] = True # slit = cols 100, 101 between two 41-px-tall slabs + m[250:291, 102:180] = True + probes = { + "fat_disk_boundary_fg": (50, 70), + "fat_disk_boundary_bg": (50, 71), + "disk_center": (50, 50), + "thin_line": (120, 120), + "gap_center": (200, 121), + "slit_center": (270, 100), + "deep_background": (310, 310), + } + return m, probes + + +def _blob_masks(n: int, size: int, seed: int) -> np.ndarray: + rng = np.random.default_rng(seed) + masks = np.zeros((n, size, size), dtype=bool) + for m in masks: + for _ in range(4): + _disk(m, *rng.integers(0, size, 2), rng.uniform(2, 9)) + row = rng.integers(0, size - 1) + m[row : row + 1, :] = True # 1-px line + return masks + + +def test_sauna_map_matches_sauna_reference(): + synthetic, _ = _synthetic_shapes() + masks = [synthetic, *_blob_masks(4, 64, seed=0)] + for m in masks: + want = _sauna_reference(m.astype(np.uint8) * 255) + got = sauna_signed_map(torch.from_numpy(m)[None], (1.0, 1.0))[0].numpy() + np.testing.assert_allclose(got, want, rtol=1e-4, atol=1e-4) + + +def test_sauna_map_on_synthetic_shapes(capsys): + m, probes = _synthetic_shapes() + y = sauna_signed_map(torch.from_numpy(m)[None], (1.0, 1.0))[0] + values = {name: y[idx].item() for name, idx in probes.items()} + with capsys.disabled(): + print("\nSAUNA y~ on synthetic shapes:", {k: round(v, 4) for k, v in values.items()}) + assert values["disk_center"] == pytest.approx(1.0, abs=1e-4) + assert values["deep_background"] == -1.0 + assert values["thin_line"] > 0.9 + assert values["fat_disk_boundary_fg"] > 0 > values["fat_disk_boundary_bg"] + assert values["gap_center"] < 0 and values["slit_center"] < 0 + # Thick-structure boundaries are the uncertain, down-weighted region: the + # ceil(fg_max)-voxel window reaches ~fg_max / 2 inward, so |y~| ~ 0.5 there. + assert 0.35 < abs(values["fat_disk_boundary_fg"]) < 0.65 + assert 0.35 < abs(values["fat_disk_boundary_bg"]) < 0.65 + assert values["gap_center"] < -0.75 and values["slit_center"] < -0.75 + + +def test_sauna_map_z1_equals_2d_and_degenerate_rows_weigh_one(): + masks = torch.from_numpy(_blob_masks(2, 48, seed=1)) + flat = sauna_signed_map(masks, (1.0, 1.0)) + z1 = sauna_signed_map(masks[:, None], (0.29, 1.0, 1.0))[:, 0] + torch.testing.assert_close(z1, flat) + degenerate = torch.stack([torch.zeros(8, 8, dtype=torch.bool), torch.ones(8, 8, dtype=torch.bool)]) + torch.testing.assert_close(sauna_weight_map(degenerate, (1.0, 1.0)), torch.ones(2, 8, 8)) + + +def test_sauna_weighting_changes_dice_and_stays_finite(): + target, mask = _blob_batch((3, 1, 4, 32, 32)) + mask[1] = 0.0 # invalid patch: weights 1, entry finite and excluded + pred = (target + 0.5 * torch.randn(target.shape, generator=torch.Generator().manual_seed(4))).requires_grad_(True) + plain = SegAuxDice() + sauna = SegAuxDice(weighting="sauna", spacing=(0.29, 0.108, 0.108)) + d_plain, v_plain = plain.per_channel(pred, target, mask) + d_sauna, v_sauna = sauna.per_channel(pred, target, mask) + assert torch.equal(v_plain, v_sauna) + assert torch.isfinite(d_sauna).all() + assert not torch.allclose(d_plain[v_plain], d_sauna[v_sauna]) + loss, comps = sauna(pred, target, mask, return_components=True) + assert comps["n_valid"] == 2 + loss.backward() + assert torch.isfinite(pred.grad).all() + + +# A 1x7 row with a 3-voxel object, spacing 1. By hand: fg_dist = [0,0,1,2,1,0,0], +# bg_dist = [2,1,0,0,0,1,2], fg_max = 2, so gt_b = (fg_dist - bg_dist) / 2. Both +# max-pool windows are ceil(2) -> 3 voxels, which put fg_pool = 2 on every fg voxel +# and bg_pool = 2 on every bg voxel, so gt_t = 1 everywhere and y~ = gt_b. +_ROW_MASK = [0, 0, 1, 1, 1, 0, 0] +_ROW_SIGNED = [-1.0, -0.5, 0.5, 1.0, 0.5, -0.5, -1.0] + + +def test_sauna_weight_map_is_abs_of_hand_computed_map(): + mask = torch.tensor(_ROW_MASK, dtype=torch.bool).reshape(1, 1, 7) + w = sauna_weight_map(mask, (1.0, 1.0))[0, 0] + torch.testing.assert_close(w, torch.tensor([abs(v) for v in _ROW_SIGNED])) + # Background next to the object is uncertain (y~ = -0.5) but still weighted. + assert w[1].item() == pytest.approx(0.5) + + +def test_sauna_weighted_dice_matches_hand_computation(): + target = np.array([0.0, 0.2, 2.0, 3.0, 1.8, 0.4, 0.1]) + pred = np.array([0.5, 1.2, 1.1, 2.5, 0.8, 0.9, -0.3]) + mask = np.array(_ROW_MASK, dtype=float) + w = np.abs(np.array(_ROW_SIGNED)) + c, eps = 0.1, 1e-6 + # tau: target quantile at 1 - 3/7 -> sorted position 3.43, 0.4 + 0.43 * (1.8 - 0.4) = 1.0. + tau = np.quantile(target, 1.0 - mask.mean()) + # s: c * (median fg 2.0 - median bg 0.15) = 0.185. + s = c * (np.median(target[mask > 0]) - np.median(target[mask == 0])) + p = 1.0 / (1.0 + np.exp(-(pred - tau) / s)) + want = 1.0 - 2.0 * (w * p * mask).sum() / ((w * p * p).sum() + (w * mask * mask).sum() + eps) + + def as_input(a: np.ndarray) -> torch.Tensor: + return torch.tensor(a, dtype=torch.float32).reshape(1, 1, 1, 7) + + loss_fn = SegAuxDice(c=c, eps=eps, weighting="sauna", spacing=(1.0, 1.0)) + dice, valid = loss_fn.per_channel(as_input(pred), as_input(target), as_input(mask)) + assert valid.item() + assert dice.item() == pytest.approx(want, rel=1e-5) + + +# ---- clDice ---- + + +def _line_mask(shape: tuple[int, ...] = (1, 1, 1, 48, 48)) -> torch.Tensor: + mask = torch.zeros(shape) + mask[..., 23:25, 4:44] = 1.0 # 2-px-wide line + return mask + + +def _crisp(mask: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Target = mask + noise; a prediction that is a high-contrast copy of a mask.""" + noise = 0.05 * torch.randn(mask.shape, generator=torch.Generator().manual_seed(5)) + return mask + noise, 3.0 * mask - 1.0 + + +def _skeleton_precision_sensitivity(loss_fn: SegAuxDice, pred, target, mask) -> tuple[float, float]: + """Tprec/Tsens on the loss's own soft prediction, via the module's soft_skeleton.""" + flat_t = target.flatten() + fg = mask.flatten() > 0.5 + tau = torch.quantile(flat_t, 1 - mask.mean()) + s = loss_fn.c * (flat_t[fg].median() - flat_t[~fg].median()) + p = torch.sigmoid((pred - tau) / s)[:, 0] + r = mask[:, 0] + sp = soft_skeleton(p, loss_fn.cldice_iters) + sr = soft_skeleton(r, loss_fn.cldice_iters) + return ((sp * r).sum() / sp.sum()).item(), ((sr * p).sum() / sr.sum()).item() + + +def _line_and_disk_mask() -> torch.Tensor: + mask = _line_mask() + _disk(mask[0, 0, 0].numpy(), 10, 10, 6) # writes through the numpy view + return mask + + +def test_cldice_near_one_for_crisp_copy_of_mask(): + mask = _line_and_disk_mask() + target, pred = _crisp(mask) + pure = SegAuxDice(label="mask", topology="cldice", cldice_alpha=1.0) + one_minus_cl, valid = pure.per_channel(pred, target, mask) + assert valid.all() + assert one_minus_cl.item() < 0.02 + + +def test_cldice_target_label_prefers_pred_equal_target(): + """With label='target' the reference is soft, so clDice < 1 even at pred == target, + but a broken prediction still scores worse.""" + mask = _line_and_disk_mask() + target, _ = _crisp(mask) + pure = SegAuxDice(label="target", topology="cldice", cldice_alpha=1.0) + broken = target.clone() + broken[..., 18:30] = target.min() + assert pure(broken, target, mask) > pure(target.clone(), target, mask) + + +def test_cldice_bridge_lowers_precision_and_break_lowers_sensitivity(): + loss_fn = SegAuxDice(topology="cldice", cldice_alpha=1.0) + two = torch.zeros(1, 1, 1, 48, 64) + _disk(two[0, 0, 0].numpy(), 24, 18, 8) + _disk(two[0, 0, 0].numpy(), 24, 45, 8) # disjoint, gap at cols 27..36 + target, perfect = _crisp(two) + bridged = perfect.clone() + bridged[..., 22:27, 18:46] = 2.0 + prec_ok, sens_ok = _skeleton_precision_sensitivity(loss_fn, perfect, target, two) + prec_br, sens_br = _skeleton_precision_sensitivity(loss_fn, bridged, target, two) + assert prec_br < prec_ok - 0.1 + assert loss_fn(bridged, target, two) > loss_fn(perfect, target, two) + + line = _line_mask() + target, perfect = _crisp(line) + broken = perfect.clone() + broken[..., 18:30] = -1.0 # 12-px break + prec_ok, sens_ok = _skeleton_precision_sensitivity(loss_fn, perfect, target, line) + prec_bk, sens_bk = _skeleton_precision_sensitivity(loss_fn, broken, target, line) + assert sens_bk < sens_ok - 0.1 + assert prec_bk == pytest.approx(prec_ok, abs=0.05) + assert loss_fn(broken, target, line) > loss_fn(perfect, target, line) + + +@pytest.mark.parametrize("weighting", ["none", "sauna"]) +def test_cldice_gradients_finite_and_invalid_excluded(weighting): + target, mask = _blob_batch((3, 1, 4, 32, 32)) + mask[1] = 0.0 + mask[2] = 1.0 + pred = (target + 0.3 * torch.randn(target.shape, generator=torch.Generator().manual_seed(6))).requires_grad_(True) + spacing = (0.29, 0.108, 0.108) if weighting == "sauna" else None + loss_fn = SegAuxDice(topology="cldice", cldice_alpha=0.5, weighting=weighting, spacing=spacing) + loss, comps = loss_fn(pred, target, mask, return_components=True) + assert comps["n_valid"] == 1 + torch.testing.assert_close(loss, loss_fn(pred[:1], target[:1], mask[:1])) + loss.backward() + assert torch.isfinite(pred.grad).all() + assert pred.grad.abs().sum() > 0 + + +def test_cldice_alpha_zero_equals_dice(): + pred, target, mask = _reference_inputs() + plain, _ = SegAuxDice().per_channel(pred, target, mask) + mixed, _ = SegAuxDice(topology="cldice", cldice_alpha=0.0).per_channel(pred, target, mask) + torch.testing.assert_close(mixed, plain) + + +@pytest.mark.parametrize("label", ["mask", "target"]) +def test_extensions_run_under_bf16_autocast(label): + target, mask = _blob_batch((2, 1, 4, 16, 16)) + pred = (target + 0.3).requires_grad_(True) + loss_fn = SegAuxDice(label=label, weighting="sauna", spacing=(0.29, 0.108, 0.108), topology="cldice") + with torch.autocast(device_type="cpu", dtype=torch.bfloat16): + loss = loss_fn(pred.to(torch.bfloat16), target.to(torch.bfloat16), mask.to(torch.bfloat16)) + assert loss.dtype == torch.float32 + assert torch.isfinite(loss) + loss.backward() + assert torch.isfinite(pred.grad).all() + + +@pytest.mark.parametrize( + "kwargs,match", + [ + ({"weighting": "boundary"}, "weighting"), + ({"weighting": "sauna"}, "needs spacing"), + ({"weighting": "sauna", "spacing": (1.0, -1.0)}, "positive"), + ({"spacing": (1.0, 1.0, 1.0)}, "no effect"), + ({"topology": "skeleton"}, "topology"), + ({"topology": "cldice", "cldice_alpha": 1.5}, "cldice_alpha"), + ({"topology": "cldice", "cldice_iters": 0}, "cldice_iters"), + ], +) +def test_invalid_extension_args_raise(kwargs, match): + with pytest.raises(ValueError, match=match): + SegAuxDice(**kwargs) + + +def test_spacing_length_must_match_spatial_dims(): + target, mask = _blob_batch((1, 1, 4, 16, 16)) + with pytest.raises(ValueError, match="spatial dims"): + SegAuxDice(weighting="sauna", spacing=(1.0, 1.0)).per_channel(target, target, mask) diff --git a/packages/viscy-utils/tests/test_seg_consistency_loss.py b/packages/viscy-utils/tests/test_seg_consistency_loss.py new file mode 100644 index 000000000..6217d02b1 --- /dev/null +++ b/packages/viscy-utils/tests/test_seg_consistency_loss.py @@ -0,0 +1,171 @@ +"""Tests for SegConsistencyLoss on a tiny stand-in segmenter (the Cellpose-SAM one is in dynacell).""" + +import pytest +import torch +from torch import Tensor, nn + +from viscy_utils.losses import SegConsistencyLoss +from viscy_utils.losses.seg_consistency import _OTSU_BINS, _binary_kl, _otsu_thresholds, nucleus_area_planes + + +class TinySegmenter(nn.Module): + """Two conv layers to flow (2 ch) + cell-probability logit (1 ch), per-image min-max normalized.""" + + def __init__(self, seed: int = 0) -> None: + super().__init__() + torch.manual_seed(seed) + self.net = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.Tanh(), nn.Conv2d(8, 3, 3, padding=1)) + + def forward(self, x: Tensor) -> dict[str, Tensor]: + with torch.no_grad(): + lo = x.amin((1, 2, 3), keepdim=True) + hi = x.amax((1, 2, 3), keepdim=True) + y = self.net((x - lo) / (hi - lo + 1e-6)) + return {"flow": y[:, :2], "cellprob_logit": y[:, 2:3]} + + +def _nuclei(shape: tuple[int, ...], seed: int = 0) -> Tensor: + """Bright blobs on noise, peaked in z around a random plane per sample.""" + g = torch.Generator().manual_seed(seed) + b, z, y, x = shape[0], shape[2], shape[3], shape[4] + yy, xx = torch.meshgrid(torch.arange(y), torch.arange(x), indexing="ij") + out = 0.05 * torch.randn(shape, generator=g) + for i in range(b): + zc = float(torch.randint(z // 4, max(z // 4 + 1, 3 * z // 4), (1,), generator=g)) + for _ in range(4): + cy, cx = (torch.rand(2, generator=g) * torch.tensor([y, x])).tolist() + disk = ((yy - cy) ** 2 + (xx - cx) ** 2 < 36).float() + prof = torch.exp(-(((torch.arange(z) - zc) / 2.0) ** 2)) + out[i, 0] += prof[:, None, None] * disk + return out + + +@pytest.mark.parametrize("shape", [(2, 1, 8, 32, 32), (2, 1, 1, 32, 32)], ids=["3d", "z1"]) +def test_zero_at_pred_equals_target_and_positive_otherwise(shape): + target = _nuclei(shape) + loss_fn = SegConsistencyLoss(TinySegmenter()) + assert float(loss_fn(target.clone(), target)) == pytest.approx(0.0, abs=1e-6) + g = torch.Generator().manual_seed(1) + assert float(loss_fn(target + 0.3 * torch.randn(shape, generator=g), target)) > 1e-4 + + +def test_invariant_to_positive_affine_units_of_both_inputs(): + """The segmenter normalizes per image, so a z-scored pred vs a raw target loses nothing.""" + target = _nuclei((2, 1, 6, 32, 32)) + loss_fn = SegConsistencyLoss(TinySegmenter()) + assert float(loss_fn(3.0 * target + 7.0, target)) == pytest.approx(0.0, abs=1e-5) + + +def test_components_match_their_definition(): + target = _nuclei((2, 1, 6, 32, 32)) + pred = target + 0.2 * torch.randn_like(target) + seg = TinySegmenter() + loss_fn = SegConsistencyLoss(seg, crop_size=None) + loss, comps = loss_fn(pred, target, return_components=True) + pred2d, target2d = loss_fn.project(pred, target) + out, ref = seg(pred2d), seg(target2d) + d_flow = ((out["flow"] - ref["flow"]) ** 2).mean() + d_prob = _binary_kl(out["cellprob_logit"], ref["cellprob_logit"]).mean() + torch.testing.assert_close(comps["segcons_flow"], d_flow) + torch.testing.assert_close(comps["segcons_prob"], d_prob) + torch.testing.assert_close(loss, d_flow + d_prob) + assert comps["segcons"] is loss + + +def test_gradient_reaches_pred_only_and_matches_finite_difference(): + target = _nuclei((1, 1, 6, 16, 16)).double() + seg = TinySegmenter().double() + loss_fn = SegConsistencyLoss(seg) + pred = (target + 0.2 * torch.randn_like(target)).requires_grad_(True) + loss_fn(pred, target).backward() + assert torch.isfinite(pred.grad).all() and pred.grad.abs().sum() > 0 + assert all(p.grad is None for p in seg.parameters()) + # Only the 3 focus-slab planes feed the segmenter. + plane = int(nucleus_area_planes(target[:, 0])[0]) + touched = (pred.grad[0, 0].abs().sum((1, 2)) > 0).nonzero().flatten().tolist() + assert set(touched) <= {plane - 1, plane, plane + 1} + # Directional central difference. The segmenter's min/max statistics are stop-gradient, + # so perturb away from the projected image's extreme pixels, which leaves them fixed. + v = torch.zeros_like(pred) + v[0, 0, plane] = torch.randn(16, 16, dtype=torch.float64) + pred2d, _ = loss_fn.project(pred.detach(), target) + for flat_idx in (pred2d.argmax(), pred2d.argmin()): + y, x = divmod(int(flat_idx), 16) + v[0, 0, :, y, x] = 0.0 + eps = 1e-6 + with torch.no_grad(): + num = (loss_fn(pred + eps * v, target) - loss_fn(pred - eps * v, target)) / (2 * eps) + ana = (pred.grad * v).sum() + assert float(num) == pytest.approx(float(ana), rel=1e-3) + + +def test_segmenter_is_frozen_unregistered_and_stays_in_eval(): + seg = TinySegmenter() + loss_fn = SegConsistencyLoss(seg) + parent = nn.Sequential(nn.Linear(2, 2)) + parent.loss = loss_fn + assert all(not k.startswith("loss") for k in parent.state_dict()) + assert list(loss_fn.parameters()) == [] + parent.train() + assert not seg.training + assert not any(p.requires_grad for p in seg.parameters()) + + +def test_crop_size_shapes_what_the_segmenter_sees(): + target = _nuclei((4, 1, 6, 40, 40)) + loss_fn = SegConsistencyLoss(TinySegmenter(), crop_size=24) + pred2d, target2d = loss_fn.project(target, target) + assert pred2d.shape == target2d.shape == (4, 1, 24, 24) + + +def test_rejects_bad_arguments_and_shapes(): + with pytest.raises(ValueError, match="slab_halfwidth"): + SegConsistencyLoss(TinySegmenter(), slab_halfwidth=-1) + with pytest.raises(ValueError, match="crop_size"): + SegConsistencyLoss(TinySegmenter(), crop_size=0) + loss_fn = SegConsistencyLoss(TinySegmenter()) + with pytest.raises(ValueError, match="share a shape"): + loss_fn(torch.zeros(1, 1, 4, 8, 8), torch.zeros(1, 1, 4, 8, 9)) + with pytest.raises(ValueError, match="expected"): + loss_fn(torch.zeros(1, 2, 4, 8, 8), torch.zeros(1, 2, 4, 8, 8)) + with pytest.raises(ValueError, match="expected"): + loss_fn(torch.zeros(1, 1, 8, 8), torch.zeros(1, 1, 8, 8)) + + +def test_nucleus_area_planes_fallbacks(): + assert nucleus_area_planes(torch.zeros(2, 7, 8, 8)).tolist() == [3, 3] + assert nucleus_area_planes(torch.zeros(1, 1, 8, 8)).tolist() == [0] + + +def _otsu_thresholds_bincount(flat: Tensor) -> Tensor: + """The first (bincount-histogram) implementation of ``_otsu_thresholds``, kept as the reference.""" + r = flat.shape[0] + lo = flat.amin(1, keepdim=True) + width = ((flat.amax(1, keepdim=True) - lo) / _OTSU_BINS).clamp_min(torch.finfo(flat.dtype).tiny) + idx = ((flat - lo) / width).floor().long().clamp(0, _OTSU_BINS - 1) + idx += torch.arange(r, device=flat.device).unsqueeze(1) * _OTSU_BINS + hist = torch.bincount(idx.reshape(-1), minlength=r * _OTSU_BINS).reshape(r, _OTSU_BINS).double() + centers = lo.double() + width.double() * (torch.arange(_OTSU_BINS, device=flat.device) + 0.5) + w1 = hist.cumsum(1) + w2 = hist.flip(1).cumsum(1).flip(1) + m1 = (hist * centers).cumsum(1) / w1 + m2 = (hist * centers).flip(1).cumsum(1).flip(1) / w2 + var = w1[:, :-1] * w2[:, 1:] * (m1[:, :-1] - m2[:, 1:]) ** 2 + return centers.gather(1, var.argmax(1, keepdim=True)).squeeze(1).to(flat.dtype) + + +def test_otsu_thresholds_equal_the_bincount_implementation(): + vols = [_nuclei((3, 1, z, 24, 24), seed=z).reshape(3, -1) for z in (1, 5, 16)] + vols.append(torch.rand(2, 1000) ** 3 * 50 - 7) + for flat in vols: + assert torch.equal(_otsu_thresholds(flat), _otsu_thresholds_bincount(flat)) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="host-sync detection needs CUDA") +def test_focus_planes_make_no_host_sync(): + vol = _nuclei((2, 1, 8, 32, 32))[:, 0].cuda() + torch.cuda.set_sync_debug_mode("error") + try: + nucleus_area_planes(vol) + finally: + torch.cuda.set_sync_debug_mode("default") diff --git a/packages/viscy-utils/tests/test_spotlight_loss.py b/packages/viscy-utils/tests/test_spotlight_loss.py index 5055ac79f..24214da4c 100644 --- a/packages/viscy-utils/tests/test_spotlight_loss.py +++ b/packages/viscy-utils/tests/test_spotlight_loss.py @@ -251,6 +251,40 @@ def test_partial_mask_ignores_placeholder_channel_in_dice(): assert loss.item() > 0 +def test_return_components_matches_default_with_precomputed_mask(): + """return_components=True: total is bit-identical to the default call, + and the components recombine to the total under lambda_mse.""" + torch.manual_seed(0) + pred = torch.randn(2, 1, 4, 8, 8, requires_grad=True) + target = torch.randn(2, 1, 4, 8, 8).abs() + fg_mask = (target > target.median()).float() + lambda_mse = 0.5 + loss_fn = SpotlightLoss(lambda_mse=lambda_mse, sigmoid_k=-0.95) + + total_default = loss_fn(pred, target, fg_mask=fg_mask) + total, components = loss_fn(pred, target, fg_mask=fg_mask, return_components=True) + + assert torch.equal(total, total_default) + recombined = lambda_mse * components["masked_mse"] + (1 - lambda_mse) * components["dice"] + assert torch.equal(recombined, total) + + +def test_return_components_matches_default_with_otsu_mask(): + """Same guarantee on the runtime-Otsu branch (no fg_mask passed).""" + torch.manual_seed(1) + pred = torch.randn(2, 1, 4, 8, 8, requires_grad=True) + target = torch.randn(2, 1, 4, 8, 8).abs() + lambda_mse = 0.3 + loss_fn = SpotlightLoss(lambda_mse=lambda_mse, sigmoid_k=-0.95, fg_threshold=None) + + total_default = loss_fn(pred, target) + total, components = loss_fn(pred, target, return_components=True) + + assert torch.equal(total, total_default) + recombined = lambda_mse * components["masked_mse"] + (1 - lambda_mse) * components["dice"] + assert torch.equal(recombined, total) + + def test_otsu_per_channel(): """Otsu computes per-(sample, channel) thresholds.""" from viscy_utils.losses.spotlight import _otsu_threshold_batch From 3a09658410a2e122b348ab89991ca4039f54ed44 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:47:34 -0700 Subject: [PATCH 02/39] feat(viscy-utils): build foreground masks with CLAHE, median and Otsu Port the Spotlight fg-mask recipe from dynacell-models. generate_fg_masks now binarizes a CLAHE-equalized, 5^3-median-filtered volume at an Otsu threshold picked on that volume after a sigma=5 Gaussian; compute_otsu uses the same full-resolution smoothing, so the otsu_grid_spacing parameter is gone from generate_normalization_metadata and VisCyTrainer.preprocess. The median and Gaussian run through cubic on the GPU when cubic and a device are present, else SciPy; cubic stays an optional import. torch is imported lazily so that light callers of write_meta_field do not pay for it. Behavior change: Spotlight masks and Otsu thresholds written by `viscy preprocess` differ from main's. Source on dynacell-models: 40de7939 (#498), d087d962 (#510). Co-Authored-By: Claude Opus 5.5 --- .../viscy-utils/src/viscy_utils/meta_utils.py | 315 ++++++++++++++++-- .../viscy-utils/src/viscy_utils/trainer.py | 4 - packages/viscy-utils/tests/test_meta_utils.py | 71 ++-- .../viscy-utils/tests/test_smooth_gaussian.py | 78 +++++ .../viscy-utils/tests/test_smooth_median.py | 80 +++++ 5 files changed, 495 insertions(+), 53 deletions(-) create mode 100644 packages/viscy-utils/tests/test_smooth_gaussian.py create mode 100644 packages/viscy-utils/tests/test_smooth_median.py diff --git a/packages/viscy-utils/src/viscy_utils/meta_utils.py b/packages/viscy-utils/src/viscy_utils/meta_utils.py index 2dbe70e92..ce949bcae 100644 --- a/packages/viscy-utils/src/viscy_utils/meta_utils.py +++ b/packages/viscy-utils/src/viscy_utils/meta_utils.py @@ -1,14 +1,33 @@ """Normalization metadata generation for OME-Zarr datasets.""" +import logging + import iohub.ngff as ngff import numpy as np from iohub.core.config import TensorStoreConfig from scipy.ndimage import median_filter -from skimage.filters import threshold_otsu +from skimage.exposure import equalize_adapthist +from skimage.filters import gaussian, threshold_otsu from tqdm import tqdm from viscy_utils.mp_utils import get_val_stats +try: + # cubic is how the rest of the codebase reaches the GPU (see + # dynacell.evaluation.metrics): its proxies dispatch on the INPUT ARRAY's + # device, so the same call runs cupyx on a cupy array and scipy on a numpy + # one. Optional here because viscy-utils does not depend on cubic; it ships + # in dynacell's `eval` and `preprocess` extras. + from cubic.cuda import ascupy, asnumpy + from cubic.scipy import ndimage as _cubic_ndimage + from cubic.skimage import exposure as _cubic_exposure + from cubic.skimage import filters as _cubic_filters +except ImportError: # pragma: no cover - exercised by the CPU-only environments + ascupy = None + +_logger = logging.getLogger(__name__) +_BACKEND: dict[str, str] = {} + def write_meta_field(position, metadata, field_name, subfield_name): """Write metadata to position's .zattrs. @@ -58,9 +77,257 @@ def _grid_sample(position, grid_spacing, channel_index): return position["0"].native[:, channel_index, :, ::grid_spacing, ::grid_spacing].read().result() -def generate_normalization_metadata( - zarr_dir, num_workers=4, channel_ids=-1, grid_spacing=32, compute_otsu=False, otsu_grid_spacing=8 -): +def _select_backend(op: str) -> str: + """Pick the array backend for ``op``, logging the choice once per operation.""" + # torch is imported lazily: it costs seconds, and dynacell.evaluation.focus + # imports this module only for write_meta_field. + import torch + + if ascupy is None: + chosen = "scipy" + # Gate the upload on an actual device, as metrics.py does: cubic imports + # cleanly without cupy/cucim and ascupy would raise "GPU requested but not + # available". Falling through to numpy keeps cubic's own CPU path. + elif not torch.cuda.is_available(): + chosen = "cubic-cpu" + else: + chosen = "cubic-gpu" + if _BACKEND.get(op) != chosen: + _BACKEND[op] = chosen + _logger.info("%s: using the %s backend", op, chosen) + return chosen + + +def smooth_median(array, size): + """Median-filter ``array``, on the GPU when cubic and a device are available. + + The mask pass in :func:`generate_fg_masks` spends essentially all of its + time here. Measured on a (44, 624, 924) float32 volume with the (1, 3, 3) + footprint cell.zarr actually uses, on an A40: + + scipy.ndimage.median_filter 5.44 s + cubic.scipy.ndimage.median_filter via ascupy 0.03 s + + Routing through ``cubic.scipy.ndimage`` rather than calling cupyx directly + is the codebase convention (``dynacell.evaluation.metrics`` does the same): + the proxy dispatches on the input array's device, so uploading with + ``ascupy`` selects the GPU implementation and passing plain numpy selects + SciPy, with one call site either way. + + A median filter selects an existing element rather than computing a new + one, so there is no floating-point reassociation and the backends agree + exactly -- pinned by a test on both footprints used here. + + Parameters + ---------- + array : numpy.ndarray + Input array. + size : tuple of int + Filter footprint, one entry per axis of ``array``. + + Returns + ------- + numpy.ndarray + Filtered array, on the host, with the input's dtype and shape. + """ + backend = _select_backend("median filter") + if backend == "scipy": + return median_filter(array, size=size) + if backend == "cubic-cpu": + return asnumpy(_cubic_ndimage.median_filter(array, size=size)) + return asnumpy(_cubic_ndimage.median_filter(ascupy(array), size=size)) + + +def smooth_gaussian(array, sigma): + """Gaussian-blur ``array``, on the GPU when cubic and a device are available. + + Used only to pick the Otsu threshold, never to build the mask: blurring + this hard makes the intensity histogram separable but smears object + boundaries, so the threshold it yields is applied to the median-filtered + image instead. Measured against binarized cpdino segmentations on the 100 + ``dataset_v4/test_cropped`` positions (nucleus, mean over positions): + + threshold from median 5^3 alone, applied to median 5^3 IoU 0.7292 + threshold from median 5^3 + this blur, applied to median IoU 0.7647 + same threshold, applied to the blurred image instead IoU 0.7288 + + Unlike :func:`smooth_median`, a Gaussian computes new values by weighted + summation, so GPU and CPU results are close but not bit-identical; the + tests assert a tolerance rather than equality. + + Parameters + ---------- + array : numpy.ndarray + Input array. + sigma : float or tuple of float + Standard deviation, scalar for all axes or one entry per axis. + + Returns + ------- + numpy.ndarray + Blurred array, on the host, with the input's shape. + """ + backend = _select_backend("gaussian filter") + if backend == "scipy": + return gaussian(array, sigma=sigma, preserve_range=True) + if backend == "cubic-cpu": + return asnumpy(_cubic_filters.gaussian(array, sigma=sigma, preserve_range=True)) + return asnumpy(_cubic_filters.gaussian(ascupy(array), sigma=sigma, preserve_range=True)) + + +_CLAHE_TILE = (50, 120, 120) +_CLAHE_CLIP_LIMIT = 0.015 +_CLAHE_PERCENTILE = 0.1 + + +def equalize_clahe(array, tile_shape=_CLAHE_TILE, clip_limit=_CLAHE_CLIP_LIMIT, nbins=256): + """Contrast-limited adaptive histogram equalization, on the GPU when available. + + ``equalize_adapthist`` takes a tile SIZE, but the number of tiles is what has + to adapt: the established config computes ``shape // (50, 120, 120)`` so tiles + stay roughly constant in pixels across differently-shaped stacks. That floors + to zero in Z for a 44-plane stack, so it is clamped to at least one tile. + + The input is percentile-clipped to ``[0, 1]`` first because + ``equalize_adapthist`` rejects float input outside ``[-1, 1]``. Clipping at + ``_CLAHE_PERCENTILE`` rather than min-max keeps one bright speck from + compressing the rest of the volume toward zero. + + Parameters + ---------- + array : numpy.ndarray + Input ``(Z, Y, X)`` volume. + tile_shape : tuple of int + Target tile size in voxels; the tile COUNT is derived from it. + clip_limit : float + Contrast-limiting threshold. + nbins : int + Histogram bins. + + Returns + ------- + numpy.ndarray + Equalized volume in ``[0, 1]`` -- a different intensity space from the + input, so a threshold taken here is not comparable to a raw one. + """ + lo, hi = np.percentile(array, (_CLAHE_PERCENTILE, 100 - _CLAHE_PERCENTILE)) + scaled = np.clip((array - lo) / (hi - lo), 0.0, 1.0).astype(np.float32) + n_tiles = tuple(max(int(dim // tile), 1) for dim, tile in zip(array.shape, tile_shape)) + kernel = tuple(int(dim // n) for dim, n in zip(array.shape, n_tiles)) + backend = _select_backend("clahe") + if backend == "scipy": + return equalize_adapthist(scaled, kernel_size=kernel, clip_limit=clip_limit, nbins=nbins) + device = scaled if backend == "cubic-cpu" else ascupy(scaled) + return asnumpy(_cubic_exposure.equalize_adapthist(device, kernel_size=kernel, clip_limit=clip_limit, nbins=nbins)) + + +def foreground_mask_from_volume(volume_zyx): + """Binary foreground mask for one ``(Z, Y, X)`` volume. + + The binarization path of the original Spotlight runs + (``scripts/blastoid/preprocess.py::read_preprocess`` in the fnet_astr_vpa + repo): CLAHE, then ``otsu_binarize(filter_size=5)`` -- a 5x5x5 median cube and + ``threshold_otsu``, with no Gaussian, since CLAHE already separates the + histogram. Foreground extraction is a preprocessing choice and not part of + the Spotlight loss, so this is free to differ from :func:`otsu_threshold_from_volume`, + which still supplies the raw-space ``otsu_threshold`` that ``NormalizeSampled`` + subtracts. + + Measured against binarized cpdino segmentations, nucleus channel, on each + sample's own ``nucleus_area`` focus plane (mean IoU / recall): + + iPSC A549 + median 5^3 + gaussian, raw space 0.7637 / 0.7851 0.7713 / 0.7916 + this recipe 0.8262 / 0.8539 0.8898 / 0.9654 + + A Gaussian on top of CLAHE was measured and rejected: it helps iPSC (0.8644) + but hurts A549 (0.8807), while no-blur improves both. The repo's cleanup step + (``min_obj_size=100``, ``max_hole_size=25``) was measured as a no-op here + (+0.0003 and +0.0000) and is omitted. + + Parameters + ---------- + volume_zyx : numpy.ndarray + Full-resolution single-channel volume. + + Returns + ------- + numpy.ndarray + Boolean mask of the input's shape. + """ + # A constant volume (e.g. a blank timepoint) has no structure to segment, and + # its percentile range is zero, which CLAHE's rescale would divide by. + if volume_zyx.min() == volume_zyx.max(): + return np.zeros(volume_zyx.shape, dtype=bool) + equalized = smooth_median(equalize_clahe(volume_zyx), size=_OTSU_MEDIAN_SIZE) + flat = equalized.ravel() + if flat.min() == flat.max(): + return np.zeros(volume_zyx.shape, dtype=bool) + return equalized >= float(threshold_otsu(flat, nbins=256)) + + +_OTSU_MEDIAN_SIZE = 5 +_OTSU_BLUR_SIGMA = 5.0 + + +def otsu_threshold_from_volume(volume_tzyx): + """Compute one FOV's Otsu threshold from a full-resolution ``(T, Z, Y, X)`` volume. + + This is the recipe of the original Spotlight implementation + (``norm_threshold`` in the fnet_astr_vpa notebooks, which resolves to + ``cubic``'s ``downscale_and_filter(downscale_factor=1, filter_size=5)`` then + ``get_threshold_otsu``): a 5x5x5 median cube at full resolution, a sigma=5 + Gaussian, then Otsu. Both filters span Z. On the strongly anisotropic iPSC + stacks that matters a lot -- restricting them to the plane scores worse + than the decimated recipe they replace -- so the footprints are kept + isotropic in voxels rather than matched to physical spacing. + + Measured against binarized cpdino segmentations, nucleus channel, scored on + each sample's own ``nucleus_area`` focus plane (mean IoU / recall), on two + datasets with very different voxel geometry -- iPSC ``test_cropped`` (100 + positions, z=0.290 / xy=0.108) and A549 ``dual_nucl_memb_mock`` (36 + position-timepoint samples, z=0.174 / xy=0.1494): + + iPSC A549 + decimate ::8 + median (1, 3, 3), previous 0.7086 / 0.7309 0.7406 / 0.7558 + this recipe, applied to the median volume 0.7647 / 0.7862 0.7713 / 0.7916 + both footprints restricted to XY 0.6967 / 0.7135 -- + + Otsu erodes on both: precision stays at 0.94-0.97 while recall sits near + 0.76, so the masks are shrunken versions of the truth rather than + mislocated ones, and this recipe buys back recall at near-flat precision. + + ``_OTSU_BLUR_SIGMA`` is the published value, deliberately NOT tuned. A + larger blur scores substantially better on both datasets (iPSC 0.8713 at + sigma=30 and still climbing, A549 0.8649 peaking at sigma=20), but the + optimum is dataset-specific and does not follow from voxel spacing -- a + spacing-derived sigma was measured and refuted. Tuning it would also mean + fitting a training-time parameter on the split the models are scored on, + which biases the Spotlight arms specifically, since the baselines never + read ``fg_mask``. Tune it on a held-out non-eval split or not at all. + + The threshold is pooled over every timepoint and Z plane, so a position + gets one scalar per channel. + + Parameters + ---------- + volume_tzyx : numpy.ndarray + Full-resolution ``(T, Z, Y, X)`` volume for a single channel. + + Returns + ------- + float + The Otsu threshold, or the constant value itself for a constant input, + for which Otsu is undefined. + """ + median = smooth_median(volume_tzyx, size=(1,) + (_OTSU_MEDIAN_SIZE,) * 3) + flat = smooth_gaussian(median, sigma=(0,) + (_OTSU_BLUR_SIGMA,) * 3).ravel() + if flat.min() == flat.max(): + return float(flat.min()) + return float(threshold_otsu(flat)) + + +def generate_normalization_metadata(zarr_dir, num_workers=4, channel_ids=-1, grid_spacing=32, compute_otsu=False): """Generate pixel intensity metadata for normalization. Normalization values are recorded in the image-level metadata in the @@ -79,10 +346,6 @@ def generate_normalization_metadata( compute_otsu : bool, optional Whether to compute Otsu thresholds for foreground estimation, by default False. Required for Spotlight loss. - otsu_grid_spacing : int, optional - Grid spacing for Otsu sampling, by default 8. Denser than the - default ``grid_spacing=32`` to capture inter-cell gaps. A median - filter is applied before thresholding to smooth noise. """ with ngff.open_ome_zarr( zarr_dir, @@ -113,16 +376,10 @@ def generate_normalization_metadata( dataset_sample_values.append(samples) fov_stats = get_val_stats(samples) if compute_otsu: - otsu_samples = _grid_sample(pos, otsu_grid_spacing, channel_index) - smoothed = median_filter(otsu_samples, size=(1, 1, 3, 3)) - flat = smoothed.ravel() - # Otsu's method is undefined for constant-valued inputs. - # Use the constant value itself so generate_fg_masks marks - # nothing as foreground (no meaningful structure to supervise). - if flat.min() == flat.max(): - fov_stats["otsu_threshold"] = float(flat.min()) - else: - fov_stats["otsu_threshold"] = float(threshold_otsu(flat)) + # Full resolution: the sigma=5 blur below is defined in source + # pixels, so thresholding a decimated grid would scale it by the + # stride and erase the effect. + fov_stats["otsu_threshold"] = otsu_threshold_from_volume(_grid_sample(pos, 1, channel_index)) fov_statistics = {"fov_statistics": fov_stats} fov_timepoint_statistics = {} for t in range(num_timepoints): @@ -164,12 +421,16 @@ def generate_fg_masks( """Precompute binary foreground masks from Otsu thresholds. For each FOV and specified channel, loads the full-resolution image one - timepoint at a time, smooths with the same median filter used for Otsu - thresholding, and writes the binary mask as a zarr array alongside the - image data. + timepoint at a time and binarizes it with + :func:`foreground_mask_from_volume`, writing the result as a zarr array + alongside the image data. - Requires ``generate_normalization_metadata`` with ``compute_otsu=True`` - to have been run first (Otsu thresholds must be stored in zattrs). + Does NOT read ``otsu_threshold``. That value is computed in raw intensity + units for ``NormalizeSampled`` to subtract, whereas this pass thresholds in + CLAHE's equalized ``[0, 1]`` space, so the two are not interchangeable and + the mask is deliberately not derivable from the stored scalar. Running + ``generate_normalization_metadata`` with ``compute_otsu=True`` is still + required for Spotlight, but for normalization rather than for masking. Parameters ---------- @@ -217,11 +478,7 @@ def generate_fg_masks( mask_arr[:, c] = 1 # Compute and write target channel masks per timepoint - for ch_name, ch_idx in zip(channel_names, channel_indices): - norm = pos.zattrs["normalization"][ch_name]["fov_statistics"] - otsu_threshold = norm["otsu_threshold"] - + for ch_idx in channel_indices: for t in range(t_total): data = img_arr[t, ch_idx].astype(np.float32) - smoothed = median_filter(data, size=(1, 3, 3)) - mask_arr[t, ch_idx] = (smoothed >= otsu_threshold).astype(np.uint8) + mask_arr[t, ch_idx] = foreground_mask_from_volume(data).astype(np.uint8) diff --git a/packages/viscy-utils/src/viscy_utils/trainer.py b/packages/viscy-utils/src/viscy_utils/trainer.py index be5bcb653..dfa2dbe8e 100644 --- a/packages/viscy-utils/src/viscy_utils/trainer.py +++ b/packages/viscy-utils/src/viscy_utils/trainer.py @@ -26,7 +26,6 @@ def preprocess( num_workers: int = 1, block_size: int = 32, compute_otsu: bool = False, - otsu_grid_spacing: int = 8, compute_fg_masks: bool = False, fg_mask_channels: list[str] | None = None, fg_mask_key: str = "fg_mask", @@ -47,8 +46,6 @@ def preprocess( compute_otsu : bool, optional Whether to compute Otsu thresholds for Spotlight loss, by default False. - otsu_grid_spacing : int, optional - Grid spacing for Otsu sampling (denser than default), by default 8. compute_fg_masks : bool, optional Whether to precompute binary foreground masks from Otsu thresholds, by default False. Requires ``compute_otsu=True``. @@ -75,7 +72,6 @@ def preprocess( channel_ids=channel_indices, grid_spacing=block_size, compute_otsu=compute_otsu, - otsu_grid_spacing=otsu_grid_spacing, ) if compute_fg_masks: if not compute_otsu: diff --git a/packages/viscy-utils/tests/test_meta_utils.py b/packages/viscy-utils/tests/test_meta_utils.py index 08ed7e5b2..fb8646052 100644 --- a/packages/viscy-utils/tests/test_meta_utils.py +++ b/packages/viscy-utils/tests/test_meta_utils.py @@ -1,8 +1,10 @@ +import warnings + import numpy as np import pytest from iohub import open_ome_zarr -from viscy_utils.meta_utils import generate_fg_masks, generate_normalization_metadata +from viscy_utils.meta_utils import foreground_mask_from_volume, generate_fg_masks, generate_normalization_metadata GRID_SPACING = 8 @@ -103,9 +105,7 @@ def test_normalization_metadata_keys(small_hcs_dataset): def test_compute_otsu_stores_threshold(bimodal_hcs_dataset): """compute_otsu=True stores otsu_threshold in fov_statistics.""" - generate_normalization_metadata( - bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True, otsu_grid_spacing=4 - ) + generate_normalization_metadata(bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True) with open_ome_zarr(bimodal_hcs_dataset, mode="r") as plate: for _, fov in plate.positions(): @@ -117,9 +117,7 @@ def test_compute_otsu_stores_threshold(bimodal_hcs_dataset): def test_compute_otsu_threshold_separates_bimodal(bimodal_hcs_dataset): """Otsu threshold on bimodal fluorescence falls between the two modes.""" - generate_normalization_metadata( - bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True, otsu_grid_spacing=4 - ) + generate_normalization_metadata(bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True) with open_ome_zarr(bimodal_hcs_dataset, mode="r") as plate: for _, fov in plate.positions(): @@ -141,9 +139,7 @@ def test_compute_otsu_false_omits_threshold(small_hcs_dataset): def test_generate_fg_masks_stores_mask(bimodal_hcs_dataset): """generate_fg_masks stores a uint8 binary mask array per position.""" - generate_normalization_metadata( - bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True, otsu_grid_spacing=4 - ) + generate_normalization_metadata(bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True) generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) with open_ome_zarr(bimodal_hcs_dataset, mode="r") as plate: @@ -158,9 +154,7 @@ def test_generate_fg_masks_stores_mask(bimodal_hcs_dataset): def test_generate_fg_masks_separates_bimodal(bimodal_hcs_dataset): """Fluorescence mask marks the bright half as foreground.""" - generate_normalization_metadata( - bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True, otsu_grid_spacing=4 - ) + generate_normalization_metadata(bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True) generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) with open_ome_zarr(bimodal_hcs_dataset, mode="r") as plate: @@ -173,18 +167,55 @@ def test_generate_fg_masks_separates_bimodal(bimodal_hcs_dataset): assert (fov["fg_mask"][:, phase_idx] == 1).all() -def test_generate_fg_masks_requires_otsu(bimodal_hcs_dataset): - """generate_fg_masks raises KeyError without prior Otsu computation.""" - with pytest.raises(KeyError): - generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) +def test_generate_fg_masks_does_not_need_otsu(bimodal_hcs_dataset): + """Masking no longer reads otsu_threshold, so it runs without one. + + The mask is thresholded in CLAHE's equalized [0, 1] space while + otsu_threshold is in raw intensity units for NormalizeSampled to subtract. + Deriving the mask from that scalar would be a unit error, so the dependency + is gone -- and this pins it gone, because the previous contract (raise + KeyError) was the opposite assertion. + """ + generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) + + with open_ome_zarr(bimodal_hcs_dataset, mode="r") as plate: + ch_idx = plate.channel_names.index("Fluorescence") + for _, fov in plate.positions(): + # The stats pass never ran, so there is no normalization block at + # all -- masking cannot have consulted a threshold that is absent. + assert "normalization" not in fov.zattrs + assert fov["fg_mask"][:, ch_idx].sum() > 0 + + +def test_fg_mask_separates_the_bimodal_channel(bimodal_hcs_dataset): + """The mask must track the bright half, not merely be non-empty. + + The fixture's Fluorescence channel is bright on y >= 32 only, so a mask that + covers everything (or the wrong half) still passes a `sum() > 0` check. + """ + generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) + + with open_ome_zarr(bimodal_hcs_dataset, mode="r") as plate: + ch_idx = plate.channel_names.index("Fluorescence") + for _, fov in plate.positions(): + mask = np.asarray(fov["fg_mask"][0, ch_idx]) + bright, dark = mask[:, 32:, :].mean(), mask[:, :32, :].mean() + assert bright > 0.9, f"bright half under-covered: {bright}" + assert dark < 0.1, f"dark half over-covered: {dark}" def test_generate_fg_masks_no_overwrite(bimodal_hcs_dataset): """generate_fg_masks raises FileExistsError on re-run.""" - generate_normalization_metadata( - bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True, otsu_grid_spacing=4 - ) + generate_normalization_metadata(bimodal_hcs_dataset, num_workers=1, grid_spacing=GRID_SPACING, compute_otsu=True) generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) with pytest.raises(FileExistsError): generate_fg_masks(bimodal_hcs_dataset, channel_names=["Fluorescence"]) + + +def test_constant_volume_gives_an_empty_mask_without_warnings(): + """A blank timepoint is all background, reached by the explicit guard rather than a NaN.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + mask = foreground_mask_from_volume(np.full((4, 64, 64), 3.0, dtype=np.float32)) + assert mask.dtype == bool and mask.shape == (4, 64, 64) and not mask.any() diff --git a/packages/viscy-utils/tests/test_smooth_gaussian.py b/packages/viscy-utils/tests/test_smooth_gaussian.py new file mode 100644 index 000000000..22b6b5f94 --- /dev/null +++ b/packages/viscy-utils/tests/test_smooth_gaussian.py @@ -0,0 +1,78 @@ +"""Tests for the GPU-dispatching Gaussian blur behind the Otsu threshold. + +Unlike the median, a Gaussian computes new values by weighted summation, so +the backends reassociate floating-point additions differently and agree only +to a tolerance -- these tests assert closeness, not equality. The blur only +ever picks a threshold, so a last-bit difference cannot change a mask except +for a voxel sitting exactly on the threshold. + +Dispatch goes through ``cubic.skimage.filters``, whose proxies select the +backend from the input array's device. The GPU tests are skipped, not failed, +when cubic or a device is absent: viscy-utils does not depend on cubic, which +ships in dynacell's eval/preprocess extras. +""" + +import numpy as np +import pytest +from skimage.filters import gaussian + +from viscy_utils.meta_utils import _OTSU_BLUR_SIGMA, otsu_threshold_from_volume, smooth_gaussian + +# The footprint otsu_threshold_from_volume uses: no blur across T, sigma in ZYX. +_SIGMA = (0,) + (_OTSU_BLUR_SIGMA,) * 3 +_SHAPE = (2, 8, 16, 24) + + +def _volume(seed: int) -> np.ndarray: + """Bimodal float32 array, so Otsu has two populations to separate.""" + rng = np.random.default_rng(seed) + arr = rng.normal(400.0, 20.0, size=_SHAPE).astype(np.float32) + arr[..., 4:12, 6:18] += 300.0 + return arr + + +def test_matches_skimage_on_the_cpu_path() -> None: + """Whichever backend runs, the result matches skimage's to float32 tolerance.""" + arr = _volume(seed=0) + expected = gaussian(arr, sigma=_SIGMA, preserve_range=True) + assert np.allclose(smooth_gaussian(arr, sigma=_SIGMA), expected, rtol=1e-5, atol=1e-4) + + +def test_preserve_range_is_honoured() -> None: + """Without preserve_range skimage rescales via img_as_float, moving the threshold. + + The blurred values must stay in the input's intensity units, because the + threshold Otsu picks from them is written to zattrs and later compared + against raw voxel values. + """ + arr = _volume(seed=1) + out = smooth_gaussian(arr, sigma=_SIGMA) + assert out.min() > 300.0 + assert out.max() < 800.0 + + +def test_cubic_gpu_and_cpu_agree() -> None: + """Pin cubic's GPU blur against skimage, the assumption the speedup rests on.""" + pytest.importorskip("cubic") + torch = pytest.importorskip("torch") + from cubic.cuda import ascupy, asnumpy + from cubic.skimage import filters as cubic_filters + + if not torch.cuda.is_available(): + pytest.skip("no CUDA device available") + arr = _volume(seed=2) + on_gpu = asnumpy(cubic_filters.gaussian(ascupy(arr), sigma=_SIGMA, preserve_range=True)) + assert np.allclose(on_gpu, gaussian(arr, sigma=_SIGMA, preserve_range=True), rtol=1e-4, atol=1e-3) + + +def test_threshold_separates_a_bimodal_volume() -> None: + """The whole recipe, end to end: the threshold must land between the two modes.""" + arr = _volume(seed=3) + threshold = otsu_threshold_from_volume(arr) + assert 400.0 < threshold < 700.0 + + +def test_constant_volume_returns_its_constant() -> None: + """Otsu is undefined for a constant input; the recipe returns the value itself.""" + arr = np.full(_SHAPE, 42.0, dtype=np.float32) + assert otsu_threshold_from_volume(arr) == pytest.approx(42.0) diff --git a/packages/viscy-utils/tests/test_smooth_median.py b/packages/viscy-utils/tests/test_smooth_median.py new file mode 100644 index 000000000..1cd5dba97 --- /dev/null +++ b/packages/viscy-utils/tests/test_smooth_median.py @@ -0,0 +1,80 @@ +"""Tests for the GPU-dispatching median filter behind the fg_mask pass. + +The filter is the whole cost of both Otsu passes -- 42.8 s of the 43.9 s a +(44, 624, 924) float32 volume takes on the CPU with the 5x5x5 cube, against +~0.1 s on an A40 including both transfers. That only makes it a safe swap if +the two backends agree exactly, which they should: a median filter SELECTS an +existing element rather than computing a new one, so there is no +floating-point reassociation for the backends to disagree about. + +Dispatch goes through ``cubic.scipy.ndimage``, whose proxies select the +backend from the input array's device -- the same route +``dynacell.evaluation.metrics`` takes. The GPU test is skipped, not failed, +when cubic or a device is absent: viscy-utils does not depend on cubic, which +ships in dynacell's eval/preprocess extras. +""" + +import numpy as np +import pytest +from scipy.ndimage import median_filter + +from viscy_utils.meta_utils import smooth_median + +# The two footprints the callers actually use: the 4-D (1, 5, 5, 5) of +# otsu_threshold_from_volume over a (T, Z, Y, X) volume, and the bare 5 cube +# generate_fg_masks applies per timepoint to (Z, Y, X). Both span Z, which is +# where the accuracy comes from -- restricting them to the plane scores worse +# than the decimated (1, 3, 3) recipe they replaced. +_SIZES = [(1, 5, 5, 5), 5] +_SHAPES = {(1, 5, 5, 5): (2, 8, 16, 24), 5: (8, 16, 24)} + + +def _volume(size, seed: int) -> np.ndarray: + """Bimodal float32 array of matching rank, so the filter has structure to act on.""" + rng = np.random.default_rng(seed) + arr = rng.normal(400.0, 20.0, size=_SHAPES[size]).astype(np.float32) + arr[..., 4:12, 6:18] += 300.0 + return arr + + +@pytest.mark.parametrize("size", _SIZES) +def test_matches_scipy_on_the_cpu_path(size) -> None: + """Whichever backend runs, the result equals SciPy's exactly.""" + arr = _volume(size, seed=0) + assert np.array_equal(smooth_median(arr, size=size), median_filter(arr, size=size)) + + +@pytest.mark.parametrize("size", _SIZES) +def test_cubic_gpu_and_cpu_agree_bitwise(size) -> None: + """Pin cubic's GPU path == SciPy, the assumption the speedup rests on.""" + pytest.importorskip("cubic") + torch = pytest.importorskip("torch") + from cubic.cuda import ascupy, asnumpy + from cubic.scipy import ndimage as cubic_ndimage + + if not torch.cuda.is_available(): + pytest.skip("no CUDA device available") + arr = _volume(size, seed=1) + on_gpu = asnumpy(cubic_ndimage.median_filter(ascupy(arr), size=size)) + assert np.array_equal(on_gpu, median_filter(arr, size=size)) + + +@pytest.mark.parametrize("size", _SIZES) +def test_cubic_cpu_path_agrees_bitwise(size) -> None: + """A numpy input must route to cubic's CPU path and still match SciPy.""" + pytest.importorskip("cubic") + from cubic.cuda import asnumpy + from cubic.scipy import ndimage as cubic_ndimage + + arr = _volume(size, seed=3) + on_cpu = asnumpy(cubic_ndimage.median_filter(arr, size=size)) + assert np.array_equal(on_cpu, median_filter(arr, size=size)) + + +def test_preserves_dtype_and_shape() -> None: + """The mask pass casts the result to uint8, so dtype must not drift to float64.""" + arr = _volume(5, seed=2) + out = smooth_median(arr, size=5) + assert out.dtype == arr.dtype + assert out.shape == arr.shape + assert isinstance(out, np.ndarray) From e461edc63eff863975b8ac86a10079d5628cc2a9 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:47:45 -0700 Subject: [PATCH 03/39] feat(viscy-utils): make prediction writes resumable, identified and write-once Port the HCSPredictionWriter and prediction_metadata work from dynacell-models: - z_reduction="center" and z_planes (slab-only predicts write only the planes they predicted, and record them); - per-FOV completion markers and the checkpoint/settings identity that predict resume and the eval caches key on (prediction_metadata.py); - refuse writes into an existing store when a FOV outside the run has unmarked prediction channels, or when the array would grow; - assemble each volume in memory and write it once on a background thread (bytes unchanged); - raise NotImplementedError when trainer.world_size > 1, instead of letting ranks race on one store. Source on dynacell-models: 2b848a65, 1be44667, 74fa18a4 (#497), 8e414988 (#511), 996adb47 (#526), 6c83fff4 (#531), 8e74a7eb (#532). Co-Authored-By: Claude Opus 5.5 --- .../callbacks/prediction_writer.py | 491 ++++++++- .../src/viscy_utils/prediction_metadata.py | 483 +++++++++ .../tests/test_prediction_metadata.py | 202 ++++ .../tests/test_prediction_writer.py | 958 +++++++++++++++++- 4 files changed, 2116 insertions(+), 18 deletions(-) create mode 100644 packages/viscy-utils/src/viscy_utils/prediction_metadata.py create mode 100644 packages/viscy-utils/tests/test_prediction_metadata.py diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/prediction_writer.py b/packages/viscy-utils/src/viscy_utils/callbacks/prediction_writer.py index f7f778b91..6716851b5 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/prediction_writer.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/prediction_writer.py @@ -6,8 +6,10 @@ from __future__ import annotations +import functools import logging import os +from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING, Literal, Optional, Sequence import numpy as np @@ -17,6 +19,21 @@ from lightning.pytorch.callbacks import BasePredictionWriter from numpy.typing import DTypeLike, NDArray +from viscy_utils.prediction_metadata import ( + PREDICTION_COMPLETE_KEY, + canonical_z_planes, + checkpoint_signature, + completion_marker, + mark_complete, + mark_started, + outruns, + predicted_z_planes, + prediction_run, + record_z_planes, + same_run, + tzyx_shape, + z_planes_sha256_12, +) from viscy_utils.tensor_utils import to_numpy if TYPE_CHECKING: @@ -78,8 +95,8 @@ def _blend_in( ) -> torch.Tensor | NDArray: """Blend a new stack into an old stack over a Z range with linear feathering. - Supports both torch tensors (5D: B,C,Z,Y,X) for in-memory sliding window - prediction and numpy arrays (4D: C,Z,Y,X) for out-of-core HCSPredictionWriter. + Supports torch tensors (B,C,Z,Y,X or C,Z,Y,X) for in-memory prediction and + numpy arrays (C,Z,Y,X) for HCSPredictionWriter's per-window writes. Parameters ---------- @@ -104,16 +121,60 @@ def _blend_in( factors.append(min(i + 1, samples)) _logger.debug(f"Blending with factors {factors}.") if isinstance(old_stack, torch.Tensor): - factors = torch.tensor(factors, dtype=old_stack.dtype, device=old_stack.device) - factors = factors.view(1, 1, -1, 1, 1) + factors = _torch_factors(tuple(factors), old_stack.dtype, old_stack.device) else: factors = np.array(factors)[np.newaxis, :, np.newaxis, np.newaxis] return old_stack * (factors - 1) / factors + new_stack / factors +@functools.lru_cache(maxsize=64) +def _torch_factors(factors: tuple[int, ...], dtype: torch.dtype, device: torch.device) -> torch.Tensor: + """Blend factors as a ``(Z, 1, 1)`` tensor, cached: a per-window host-to-device copy would sync every window.""" + # A normal tensor even when first built under predict's inference mode, so a later + # grad-enabled caller can still save it for backward. + with torch.inference_mode(False): + return torch.tensor(factors, dtype=dtype, device=device).view(-1, 1, 1) + + +def _store_into(volume: torch.Tensor, z_slice: slice, planes: torch.Tensor) -> None: + """Write ``planes`` into ``volume[:, z_slice]``, rounding to the volume dtype as numpy does. + + Torch rounds to float32 and float64 exactly like numpy. It converts float64 to + float16 through float32, though, which can round twice, so other dtypes are cast + by numpy on the host. + + Parameters + ---------- + volume : torch.Tensor + ``(C, Z, Y, X)`` volume buffer. + z_slice : slice + Planes of ``volume`` to write. + planes : torch.Tensor + ``(C, Z, Y, X)`` planes, on the volume's device. + """ + if volume.dtype not in (torch.float32, torch.float64) and planes.dtype != volume.dtype: + cast = planes.cpu().numpy().astype(torch.empty(0, dtype=volume.dtype).numpy().dtype) + planes = torch.from_numpy(cast).to(volume.device) + volume[:, z_slice] = planes + + class HCSPredictionWriter(BasePredictionWriter): """Callback to store virtual staining predictions as HCS OME-Zarr. + Single-process only: completion is tracked per process and channels are + appended to the store without cross-rank coordination, so a multi-device + predict is refused before the store is opened. + + Each (position, timepoint) volume is assembled in memory from its depth + windows and written once, when its last window arrives (or at predict end), + instead of a zarr read-modify-write per window; the stored bytes are the + same. The write runs on a background thread while the next volume is + predicted, one at a time and in order; a FOV's completion marker is written + on that thread after its last volume, and a write error is raised at the + next batch or at predict end. ``write_input`` and plane-restricted predicts (``predict_z_planes``) keep + the per-window writes: the first stores slices beside each window, and the + second writes single planes, which a volume flush would fill in between. + Parameters ---------- output_store : str @@ -126,6 +187,52 @@ class HCSPredictionWriter(BasePredictionWriter): (must be writing to a new store), by default False. write_interval : {'batch', 'epoch', 'batch_and_epoch'}, optional When to write, by default "batch". + z_reduction : {'blend', 'center'}, optional + How to combine the depth windows that cover one output plane, by + default "blend". + + ``'blend'`` feathers each window in with :func:`_blend_in`. With the + usual stride-1 depth windows that recurrence is the incremental mean, + so every plane ends up the *unweighted mean of ``z_window_size`` + forward passes*. That is a real sharpness cost for a generative model: + on an iPSC-trained pix2pix3d membrane prediction the mean over 8 + windows halves the mid-band power relative to a single pass + (0.045 -> 0.021 of ground truth), and because all 8 share one XY grid + it cancels none of the decoder's XY lattice. + + ``'center'`` writes each window from its center plane onward, so a + later window overwrites everything but that center plane and the + volume is assembled one plane per forward pass with no averaging. + The leading ``z_window_size // 2`` planes come from the first window + and the trailing ``z_window_size - 1 - z_window_size // 2`` from the + last, since no window is centered on them. For odd ``z_window_size`` + that trailing count equals ``z_window_size // 2`` -- at ``w=5`` it is + 2, not 1. Every dynacell leaf that sets ``z_window_size`` uses an odd + value. + + Both modes assume windows arrive in increasing ``z`` order within a + ``(position, timepoint)`` -- ``'blend'`` because its factors are + derived from ``z_slice.start``, ``'center'`` because it relies on + last-write-wins. The predict dataloader is sequential and iterates + ``z`` innermost, which is what makes that hold. + checkpoint_path : str or None, optional + Checkpoint the model predicts with. Its path and content hash are + recorded in every FOV's completion marker, so a resume can tell + predictions made with different weights apart and refuses to mix + them in one store. The file's size and mtime are taken when the + writer is constructed -- the same instantiation pass that loads the + model's weights -- and ``on_predict_start`` refuses a file changed + since, so the recorded hash names the bytes the model loaded rather + than whatever sits at the path later. Default None records no + checkpoint. + settings_sha256_12 : str or None, optional + Hash of the other settings that shape the predicted voxels (model + inference arguments, input normalization, precision), recorded in the + marker alongside the checkpoint so a resume with the same weights but + other settings is refused as well. The submitter computes it from the + resolved config (``submit_benchmark_job.prediction_settings_sha256_12``); + the writer cannot derive it from live objects. Default None records + none. """ def __init__( @@ -134,12 +241,24 @@ def __init__( overwrite: bool = False, write_input: bool = False, write_interval: Literal["batch", "epoch", "batch_and_epoch"] = "batch", + z_reduction: Literal["blend", "center"] = "blend", + checkpoint_path: str | None = None, + settings_sha256_12: str | None = None, ) -> None: super().__init__(write_interval) + if z_reduction not in ("blend", "center"): + raise ValueError(f"z_reduction must be 'blend' or 'center', got {z_reduction!r}") self.output_store = output_store self.overwrite = overwrite self.write_input = write_input + self.z_reduction = z_reduction + self.checkpoint_path = checkpoint_path + self._checkpoint_signature = None if checkpoint_path is None else checkpoint_signature(checkpoint_path) + self.settings_sha256_12 = settings_sha256_12 self._dataset_scale = None + # Set by on_predict_start; None until then, so a failure during setup closes nothing. + self.plate: Plate | None = None + self._flush_pool: ThreadPoolExecutor | None = None def _get_scale_metadata(self, metadata_store: os.PathLike | None) -> None: """Read scale metadata from an existing zarr store. @@ -169,6 +288,11 @@ def on_predict_start(self, trainer: Trainer, pl_module: LightningModule) -> None pl_module : LightningModule The Lightning module being used for prediction. """ + if trainer.world_size > 1: + raise NotImplementedError( + f"HCSPredictionWriter tracks completion per process and appends channels without " + f"cross-rank coordination; run predict on a single device (got world_size={trainer.world_size})." + ) dm: HCSDataModule = trainer.datamodule self._get_scale_metadata(dm.data_path) self.z_padding = dm.z_window_size // 2 if dm.target_2d else 0 @@ -176,16 +300,68 @@ def on_predict_start(self, trainer: Trainer, pl_module: LightningModule) -> None source_channel = dm.source_channel target_channel = dm.target_channel prediction_channel = [ch + "_prediction" for ch in target_channel] - if os.path.exists(self.output_store): + self._prediction_channels = prediction_channel + if ( + self.checkpoint_path is not None + and checkpoint_signature(self.checkpoint_path) != self._checkpoint_signature + ): + raise RuntimeError( + f"Checkpoint '{self.checkpoint_path}' changed since this writer was constructed alongside the " + "model, so its hash would not name the weights actually loaded. Restart the predict." + ) + # Plane-restricted predict (HCSDataModule.predict_z_planes): only the listed + # planes are predicted, the rest keep the fill value, and the planes are part + # of the run identity so a resume never mixes them with another selection. + self._z_planes = dm.predict_dataset.z_planes + # write_input stores source/target slices beside each window, and a plane selection + # writes single planes that a [first, last] flush would fill in between: both write + # per window. + self._write_once = not self.write_input and self._z_planes is None + self._volumes: dict[tuple[str, int], tuple[torch.Tensor, int, int]] = {} + # Volume writes (and the markers after them) run here, in submission order. + self._flush_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="prediction-flush") + self._pending: Future | None = None + # Hashes the checkpoint once; every FOV's marker records this identity. + self._run = prediction_run( + array_key=dm.array_key, + z_window_size=dm.z_window_size, + z_reduction=self.z_reduction, + checkpoint_path=self.checkpoint_path, + settings_sha256_12=self.settings_sha256_12, + z_planes_sha256_12=None if self._z_planes is None else z_planes_sha256_12(self._z_planes), + ) + window_arrays = dm.predict_dataset.window_arrays + self._source_shapes = {f"/{array.path}": tzyx_shape(array) for array in window_arrays} + # Array dimensions grow before writes, so only a successful write of every + # distinct (T, Z-window) establishes completion, including overlapping windows. + # A plane-restricted run marks the windows it skips as written up front. + self._written_windows: dict[str, NDArray[np.bool_]] = {} + for array in window_arrays: + written = np.zeros((array.frames, array.slices - dm.z_window_size + 1), dtype=bool) + if self._z_planes is not None: + written[:] = True + for t, planes in enumerate(self._z_planes[array.path.rsplit("/", 1)[0]]): + written[t, list(planes)] = False + self._written_windows[f"/{array.path}"] = written + run_positions = {array.path.rsplit("/", 1)[0] for array in window_arrays} + existed = os.path.exists(self.output_store) + if existed: if self.write_input: raise FileExistsError("Cannot write input to an existing store. Aborting.") else: self.plate = open_ome_zarr(self.output_store, mode="r+") # Validate all positions before mutating any. - needs_append: list[tuple[Position, list[str]]] = [] - for _, pos in self.plate.positions(): + positions = dict(self.plate.positions()) + needs_append: list[tuple[str, list[str]]] = [] + overwritten: list[str] = [] + mixed: list[str] = [] + oversized: list[str] = [] + foreign: list[str] = [] + channel_orders: set[tuple[str, ...]] = set() + for name, pos in positions.items(): existing = set(pos.channel_names) missing = [ch for ch in prediction_channel if ch not in existing] + channel_orders.add((*pos.channel_names, *missing)) for ch in prediction_channel: if ch in existing and not self.overwrite: self.plate.close() @@ -201,10 +377,67 @@ def on_predict_start(self, trainer: Trainer, pl_module: LightningModule) -> None self.output_store, ) if missing: - needs_append.append((pos, missing)) - for pos, channels in needs_append: + needs_append.append((name, missing)) + if name in run_positions: + overwritten.append(name) + if self._outruns_source(pos, name, dm.array_key): + oversized.append(name) + if self._holds_other_planes(pos, name, prediction_channel): + foreign.append(name) + elif self._cannot_share(pos, prediction_channel): + mixed.append(name) + if oversized: + self.plate.close() + raise ValueError( + f"{len(oversized)} FOVs in '{self.output_store}' hold more timepoints or depth " + f"slices than their source (e.g. {oversized[:3]}); arrays only grow, so the stale " + "planes would survive every rewrite yet the FOVs would be marked complete. " + "Predict into a new output store." + ) + if foreign: + self.plate.close() + raise ValueError( + f"{len(foreign)} FOVs in '{self.output_store}' hold {prediction_channel} that another run " + f"predicted on other planes (e.g. {foreign[:3]}); a plane-restricted predict rewrites only " + "its own planes, so remove that store (or predict into a new one) first." + ) + if mixed: + self.plate.close() + raise ValueError( + f"{len(mixed)} FOVs outside this run already hold {prediction_channel} " + f"in '{self.output_store}' that were predicted before completion markers " + f"existed or with a different checkpoint or settings (e.g. {mixed[:3]}); " + "predict into a new output store instead of mixing them." + ) + if len(channel_orders) > 1: + self.plate.close() + raise ValueError( + f"Positions of '{self.output_store}' would disagree on channel order after " + f"appending {prediction_channel} ({sorted(channel_orders)}); the writer addresses " + "channels by one plate-wide index. Predict into a new output store." + ) + # A started marker names this run as the writer of a channel's voxels. + # Run positions get one for every prediction channel: this run + # replaces their voxels, so an interrupted run must never reuse an + # old completion, and the next resume sees unfinished FOVs, not + # legacy ones. FOVs outside the run (e.g. excluded on resume) keep + # their markers, except for the channels appended below: those hold + # zeros this run allocated, and without a marker a later resume + # could not tell them from data predicted before markers existed. + started = {name: prediction_channel for name in overwritten} + for name, channels in needs_append: + started.setdefault(name, channels) for ch in channels: - pos.append_channel(ch, resize_arrays=True) + positions[name].append_channel(ch, resize_arrays=True) + if needs_append: + # Plate.channel_names is cached from the first position when the + # store is opened; reopen so channel indices and positions created + # later in this run see the appended channel. + self.plate.close() + self.plate = open_ome_zarr(self.output_store, mode="r+") + positions = {name: self.plate[name] for name in started} + for name, channels in started.items(): + mark_started(positions[name], channels, self._run) else: channel_names = prediction_channel if self.write_input: @@ -220,6 +453,15 @@ def on_predict_start(self, trainer: Trainer, pl_module: LightningModule) -> None self.source_index = self._get_channel_indices(source_channel) self.target_index = self._get_channel_indices(target_channel) self.prediction_index = self._get_channel_indices(prediction_channel) + # Read now: once volume writes run on the flush thread, this thread leaves the store alone. + self._store_dtypes: dict[str, torch.dtype] = {} + if existed: + for img_name in self._source_shapes: + try: + array = self.plate[img_name] + except KeyError: + continue # created by this run, in the prediction's dtype + self._store_dtypes[img_name] = torch.from_numpy(np.empty(0, dtype=array.dtype)).dtype def _get_channel_indices(self, channel_names: list[str]) -> list[int]: """Get channel indices from the plate for given channel names. @@ -266,6 +508,8 @@ def write_on_batch_end( Index of the current dataloader. """ _logger.debug(f"Writing batch {batch_idx}.") + if self._pending is not None and self._pending.done(): + self._pending.result() # raise a failed volume write here, not volumes later for sample_index, _ in enumerate(batch["index"][0]): self.write_sample(batch, prediction[sample_index], sample_index) @@ -279,7 +523,35 @@ def on_predict_end(self, trainer: Trainer, pl_module: LightningModule) -> None: pl_module : LightningModule The Lightning module. """ - self.plate.close() + try: + for img_name, t_index in list(self._volumes): + self._flush_volume(img_name, t_index, complete=False) + finally: + # Even when a write failed. + self._close() + if self._pending is not None: + self._pending.result() + + def on_exception(self, trainer: Trainer, pl_module: LightningModule, exception: BaseException) -> None: + """Close the store when predict fails, e.g. on a volume write that failed mid-run. + + Parameters + ---------- + trainer : Trainer + The Lightning trainer instance. + pl_module : LightningModule + The Lightning module. + exception : BaseException + The exception that stopped predict. + """ + self._close() + + def _close(self) -> None: + """Let the last volume write finish, then close the store; safe after a partial setup or a second call.""" + if self._flush_pool is not None: + self._flush_pool.shutdown(wait=True) + if self.plate is not None: + self.plate.close() def write_sample( self, @@ -299,15 +571,29 @@ def write_sample( Index of this sample within the batch. """ _logger.debug(f"Writing sample {sample_index}.") - sample_prediction = to_numpy(sample_prediction) img_name, t_index, z_index = [batch["index"][i][sample_index] for i in range(3)] t_index = int(t_index) z_index = int(z_index) + window_z_index = z_index # account for lost slices in 2.5D z_index += self.z_padding z_slice = slice(z_index, z_index + sample_prediction.shape[-3]) - image = self._create_image(img_name, sample_prediction.shape, sample_prediction.dtype) - _resize_image(image, t_index, z_slice) + written = self._written_windows[img_name] + if written[t_index, window_z_index]: + # A repeat would blend into a volume already flushed (a zero buffer) or blend twice. + raise RuntimeError(f"Window z={window_z_index} of {img_name} t={t_index} arrived twice.") + # Blend reads and the write go to the in-memory volume, or to the store per window. + if self._write_once: + # The volume stays on the prediction's device and reaches the host once, at its flush. + sample_prediction = sample_prediction.detach() + if sample_prediction.dtype == torch.bfloat16: + sample_prediction = sample_prediction.float() # to_numpy's cast + dest, lead = self._buffer_volume(img_name, t_index, sample_prediction, z_slice), (slice(None),) + else: + sample_prediction = to_numpy(sample_prediction) + image = self._create_image(img_name, sample_prediction.shape, sample_prediction.dtype) + _resize_image(image, t_index, z_slice) + dest, lead = image.oindex, (t_index, self.prediction_index) if self.write_input: source_stack = batch["source"][sample_index].cpu() center_slice_index = source_stack.shape[-3] // 2 @@ -316,9 +602,172 @@ def write_sample( image[t_index, self.target_index, z_index] = batch["target"][sample_index][:, center_slice_index].cpu() # write CZYX if self.z_padding == 0 and sample_prediction.shape[-3] > 1: - old_stack = image.oindex[t_index, self.prediction_index, z_slice] - sample_prediction = _blend_in(old_stack, sample_prediction, z_slice) - image.oindex[t_index, self.prediction_index, z_slice] = sample_prediction + if self.z_reduction == "blend": + old_stack = dest[(*lead, z_slice)] + if self._write_once and z_slice.start != 0: + # The numpy blend promotes to float64 (integer factors); so does this one. + # The first window is stored as predicted, so it needs no float64 copies. + old_stack, sample_prediction = old_stack.double(), sample_prediction.double() + sample_prediction = _blend_in(old_stack, sample_prediction, z_slice) + else: + # Drop the planes before this window's center: the window centered on + # each of them writes it later and wins. The first window keeps all of + # them because nothing is centered on the leading planes. + keep = 0 if z_index == 0 else sample_prediction.shape[-3] // 2 + sample_prediction = sample_prediction[..., keep:, :, :] + z_slice = slice(z_slice.start + keep, z_slice.stop) + # The buffer has the store's dtype, so this casts like the zarr write and blending + # reads back what the store would hold. + if self._write_once: + _store_into(dest, z_slice, sample_prediction) + else: + dest[(*lead, z_slice)] = sample_prediction + written[t_index, window_z_index] = True + if self._write_once: + if written[t_index].all(): + self._flush_volume(img_name, t_index, complete=written.all()) + elif written.all(): + self._mark_complete(img_name) + + def _mark_complete(self, img_name: str) -> None: + """Write the completion marker of the FOV holding ``img_name``; every window is in the store.""" + name = img_name.strip("/").rsplit("/", 1)[0] + position = self.plate[name] + planes = None if self._z_planes is None else self._z_planes[name] + record_z_planes(position, self._prediction_channels, planes) + marker = completion_marker(self._source_shapes[img_name], self._run) + mark_complete(position, self._prediction_channels, marker) + + def _buffer_volume( + self, img_name: str, t_index: int, sample_prediction: torch.Tensor, z_slice: slice + ) -> torch.Tensor: + """Return the in-memory CZYX volume of ``(img_name, t_index)``, allocating it on first use. + + Parameters + ---------- + img_name : str + Hierarchical path name of the source image. + t_index : int + Timepoint of the volume. + sample_prediction : torch.Tensor + First window of the volume; sets channel count, YX shape, device, and the dtype + unless the store already holds this array (then its dtype, as the per-window + writes would cast to it). + z_slice : slice + Planes of the incoming window. The flush writes from the first plane any + window reached to the last, so planes no window predicts (``target_2d`` + padding, unselected planes) keep what the store holds, as with per-window + writes. + + Returns + ------- + torch.Tensor + Zero-filled ``(C, Z, Y, X)`` buffer spanning the source depth, like a new zarr array. + """ + key = (img_name, t_index) + if key not in self._volumes: + depth = self._source_shapes[img_name][1] + shape = (sample_prediction.shape[0], depth, *sample_prediction.shape[-2:]) + dtype = self._store_dtypes.get(img_name, sample_prediction.dtype) + volume = torch.zeros(shape, dtype=dtype, device=sample_prediction.device) + self._volumes[key] = (volume, z_slice.start, z_slice.stop) + volume, start, stop = self._volumes[key] + if z_slice.stop > volume.shape[1]: + # A prediction deeper than its window: grow like the per-window resize does. + extra = volume.new_zeros((volume.shape[0], z_slice.stop - volume.shape[1], *volume.shape[2:])) + volume = torch.cat([volume, extra], dim=1) + self._volumes[key] = (volume, min(start, z_slice.start), max(stop, z_slice.stop)) + return volume + + def _flush_volume(self, img_name: str, t_index: int, complete: bool) -> None: + """Copy one buffered volume to the host and queue its write, over the planes its windows reached. + + Parameters + ---------- + img_name : str + Hierarchical path name of the source image. + t_index : int + Timepoint of the volume. + complete : bool + The volume is the FOV's last: mark the FOV complete once it is written. + """ + volume, start, stop = self._volumes.pop((img_name, t_index)) + planes = volume[:, start:stop].cpu().numpy() + if self._pending is not None: + # One write in flight bounds the host copies held to two volumes. + self._pending.result() + self._pending = self._flush_pool.submit(self._write_volume, img_name, t_index, planes, start, complete) + + def _write_volume(self, img_name: str, t_index: int, planes: NDArray, start: int, complete: bool) -> None: + """Write planes ``start:`` of one volume to the store, then the FOV's marker if ``complete`` (flush thread).""" + stop = start + planes.shape[1] + image = self._create_image(img_name, (planes.shape[0], stop, *planes.shape[2:]), planes.dtype) + _resize_image(image, t_index, slice(start, stop)) + image.oindex[t_index, self.prediction_index, slice(start, stop)] = planes + if complete: + self._mark_complete(img_name) + + def _outruns_source(self, position: Position, name: str, array_key: str) -> bool: + """Return whether the existing output array outruns this run's source in T or Z. + + Parameters + ---------- + position : Position + Existing output position that this run rewrites. + name : str + Plate-relative position name. + array_key : str + Array level this run writes. + + Returns + ------- + bool + True when the array exists and :func:`outruns` the source. + """ + try: + output = position[array_key] + except KeyError: + return False + return outruns(output, self._source_shapes[f"/{name}/{array_key}"]) + + def _cannot_share(self, position: Position, channels: list[str]) -> bool: + """Return whether ``position`` holds any of ``channels`` that this run cannot vouch for. + + Parameters + ---------- + position : Position + Existing output position outside this run. + channels : list of str + This run's prediction channels. + + Returns + ------- + bool + True when a channel present in the position has no marker of its + own (written before markers existed, so unverifiable even if other + channels are marked), or a marker written with other weights or + settings, or in a layout this version cannot read. + """ + completed = position.zattrs.get(PREDICTION_COMPLETE_KEY, {}) + return any( + channel in position.channel_names and not same_run(completed.get(channel), self._run) + for channel in channels + ) + + def _holds_other_planes(self, position: Position, name: str, channels: list[str]) -> bool: + """Return whether a plane-restricted run would leave another run's planes in ``position``. + + Overwrite rewrites in place and this run writes only its own planes, so a + channel another run wrote survives off them unless that run predicted exactly + these planes (e.g. the same slab with a new checkpoint). A full-volume run + rewrites every plane and never holds any. + """ + if self._z_planes is None or not self._cannot_share(position, channels): + return False + planes = canonical_z_planes(self._z_planes[name]) + return any( + predicted_z_planes(position, channel) != planes for channel in channels if channel in position.channel_names + ) def _create_image(self, img_name: str, shape: tuple[int, ...], dtype: DTypeLike): """Create or retrieve an image in the zarr store. @@ -351,7 +800,15 @@ def _create_image(self, img_name: str, shape: tuple[int, ...], dtype: DTypeLike) _logger.debug(f"Creating image '{img_name}'") _, row_name, col_name, pos_name, arr_name = img_name.split("/") position = self.plate.create_position(row_name, col_name, pos_name) + # A started marker from the first write on distinguishes an interrupted FOV + # from one written before completion markers existed. + mark_started(position, self._prediction_channels, self._run) shape = [1] + list(shape) + if self._z_planes is not None: + # Allocate the full source extent: the planes this run skips must still + # exist (at the fill value) so the store aligns plane by plane with its + # source, instead of ending at the last predicted plane. + shape[0], shape[2] = self._source_shapes[img_name][:2] shape[1] = len(position.channel_names) return position.create_zeros( arr_name, diff --git a/packages/viscy-utils/src/viscy_utils/prediction_metadata.py b/packages/viscy-utils/src/viscy_utils/prediction_metadata.py new file mode 100644 index 000000000..f274a17a6 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/prediction_metadata.py @@ -0,0 +1,483 @@ +"""Prediction completion metadata shared by writers and submission tools.""" + +import hashlib +import json +import os +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path +from typing import Any + +from iohub.ngff import ImageArray, Position + +__all__ = [ + "PREDICTED_Z_PLANES_KEY", + "PREDICTION_COMPLETE_KEY", + "checkpoint_sha256_12", + "canonical_z_planes", + "checkpoint_signature", + "completion_marker", + "mark_complete", + "mark_started", + "marker_identity", + "outruns", + "plane_predict_unfinished", + "predicted_z_planes", + "prediction_complete", + "prediction_run", + "record_z_planes", + "same_marker", + "same_run", + "started_marker", + "tzyx_shape", + "z_planes_sha256_12", +] + +# Position attribute mapping each prediction channel to its marker: the run +# identity from ``prediction_run`` while a run is writing the channel +# (``started_marker``), and that identity plus the source TZYX once every +# window was written (``completion_marker``). A channel with no entry of its +# own was never recorded by any run -- written before markers existed -- so +# nothing can vouch for it, even when other channels of the FOV are marked. +PREDICTION_COMPLETE_KEY = "viscy_prediction_complete" + +# Position attribute mapping each prediction channel to the Z planes a plane-restricted +# run (``HCSDataModule.predict_z_planes``) predicted, one list per timepoint. Every other +# plane holds the array's fill value, so a reader must score only these. Absent for a +# channel predicted on every plane; a full run clears a stale entry. +PREDICTED_Z_PLANES_KEY = "viscy_predicted_z_planes" + +# Marker fields kept for provenance only; two markers that differ solely in +# these still describe the same prediction (a checkpoint may be moved). +_PROVENANCE_KEYS = frozenset({"checkpoint_path"}) + + +def tzyx_shape(array: ImageArray) -> list[int]: + """Return the TZYX extent of a 5D OME-Zarr array. + + Parameters + ---------- + array : ImageArray + Array whose ``(T, C, Z, Y, X)`` shape to project. + + Returns + ------- + list of int + ``[T, Z, Y, X]``, the source identity a completion marker records. + """ + return [array.frames, array.slices, array.height, array.width] + + +def outruns(array: ImageArray, source_shape: list[int]) -> bool: + """Return whether an output array holds more frames or depth slices than its source. + + Arrays only grow and no run writes beyond its source's extent, so the + excess would keep stale voxels under a fresh completion marker. + + Parameters + ---------- + array : ImageArray + Existing output array. + source_shape : list of int + TZYX shape of the source, from :func:`tzyx_shape`. + + Returns + ------- + bool + True when ``array`` exceeds the source in T or Z. + """ + return array.frames > source_shape[0] or array.slices > source_shape[1] + + +_SIGNATURE_FIELDS = ("size", "mtime_ns", "ctime_ns", "ino") + + +def checkpoint_signature(path: str | os.PathLike) -> tuple[int, int, int, int]: + """Return the size, ``st_mtime_ns``, ``st_ctime_ns`` and inode of the file at *path*. + + Overwriting a file or replacing it by rename changes at least one of these: + a copy can preserve size and mtime, but the kernel sets ctime itself and a + rename swaps the inode. Equal signatures therefore mean the same bytes for + the hash memo in :func:`checkpoint_sha256_12` and for the writer's check + that a checkpoint did not change between loading its weights and hashing + it. The one blind spot is a same-size, mtime-preserving overwrite within + the same clock tick as the previous change; identity-critical callers hash + the bytes instead (:func:`prediction_run`). + + Parameters + ---------- + path : str or PathLike + Checkpoint file to describe. + + Returns + ------- + tuple of int + ``(size, st_mtime_ns, st_ctime_ns, st_ino)``. + """ + stat = Path(path).stat() + return stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_ino + + +def _sha256_hex(path: Path) -> str: + """Return the full sha256 hex digest of the file at *path*, read in 1 MiB chunks.""" + hasher = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def checkpoint_sha256_12(path: str | os.PathLike, *, memoize: bool = True) -> str: + """Return the first 12 hex chars of the sha256 of the file at *path*. + + On repeated calls for the same checkpoint, reads the digest from a + ``.sha256`` sidecar file, avoiding a multi-GB re-read. The sidecar + records the :func:`checkpoint_signature` of the file it hashed and is only + trusted when every field still matches exactly: a checkpoint overwritten + or replaced changes at least one of them even when it was copied with a + preserved or older mtime, so the digest never certifies stale weights (a + sidecar that is merely newer than the checkpoint proves nothing). Writes + the sidecar after a fresh hash; silently tolerates read-only parent + directories and NFS flakes by falling back to recompute. + + Parameters + ---------- + path : str or PathLike + Checkpoint file to hash. + memoize : bool, optional + Consult and maintain the sidecar (default). Pass False to hash the + bytes as they are now and leave the checkpoint's directory untouched: + file metadata cannot prove byte equality, so a digest that certifies + which weights a run loaded must come from the bytes themselves. + + Returns + ------- + str + First 12 hex characters of the file's sha256 digest. + """ + ckpt = Path(path) + if not memoize: + return _sha256_hex(ckpt)[:12] + sidecar = ckpt.with_suffix(ckpt.suffix + ".sha256") + signature = dict(zip(_SIGNATURE_FIELDS, checkpoint_signature(ckpt), strict=True)) + try: + recorded = json.loads(sidecar.read_text()) + if ( + isinstance(recorded, dict) + and all(recorded.get(field) == value for field, value in signature.items()) + and isinstance(recorded.get("sha256"), str) + and len(recorded["sha256"]) == 64 + ): + return recorded["sha256"][:12] + except (OSError, ValueError): + pass + digest = _sha256_hex(ckpt) + try: + tmp = sidecar.with_suffix(sidecar.suffix + ".tmp") + tmp.write_text(json.dumps({"sha256": digest, **signature}) + "\n") + tmp.replace(sidecar) + except OSError: + pass + return digest[:12] + + +def canonical_z_planes(per_t: Sequence[Sequence[int]]) -> list[list[int]]: + """Sorted, de-duplicated ``int`` planes of each timepoint, as :func:`record_z_planes` stores them.""" + return [sorted({int(z) for z in planes}) for planes in per_t] + + +def z_planes_sha256_12(z_planes: Mapping[str, Sequence[Sequence[int]]]) -> str: + """Content hash of a plane selection, independent of key order and plane order. + + Parameters + ---------- + z_planes : Mapping[str, Sequence[Sequence[int]]] + Position name to one plane list per timepoint. + + Returns + ------- + str + First 12 hex digits of the SHA-256 of the canonical JSON. + """ + canonical = {name: canonical_z_planes(per_t) for name, per_t in z_planes.items()} + return hashlib.sha256(json.dumps(canonical, sort_keys=True).encode()).hexdigest()[:12] + + +def prediction_run( + *, + array_key: str, + z_window_size: int, + z_reduction: str, + checkpoint_path: str | os.PathLike | None, + settings_sha256_12: str | None = None, + z_planes_sha256_12: str | None = None, +) -> dict[str, Any]: + """Describe what determines a run's voxels: the weights, the depth handling and the other settings. + + Call once per run; the checkpoint's bytes are hashed here, not per FOV, + and never taken from a ``.sha256`` sidecar: the marker certifies + which weights produced the voxels, and a memo keyed on file metadata + cannot prove that a same-size copy did not replace them. + + Parameters + ---------- + array_key : str + Name of the source (and output) array level that is predicted. + z_window_size : int + Depth of the model's input window. + z_reduction : str + How overlapping depth windows are combined by the writer. + checkpoint_path : str or PathLike or None + Checkpoint the model predicts with; ``None`` when the run has none. + settings_sha256_12 : str or None, optional + Hash of the remaining settings that shape the predicted voxels (model + inference arguments, input normalization, precision), computed by the + submitter from the resolved config; ``None`` when the run records none. + z_planes_sha256_12 : str or None, optional + :func:`z_planes_sha256_12` of the planes a plane-restricted run predicts. + ``None`` (every plane) leaves the key out, so a full run's identity is + the one recorded before plane selection existed. + + Returns + ------- + dict + JSON-serializable identity, including ``checkpoint_sha256_12`` of the + file's content and its path for provenance. + """ + run = { + "array_key": array_key, + "z_window_size": int(z_window_size), + "z_reduction": z_reduction, + "checkpoint_path": None if checkpoint_path is None else str(checkpoint_path), + "checkpoint_sha256_12": None + if checkpoint_path is None + else checkpoint_sha256_12(checkpoint_path, memoize=False), + "settings_sha256_12": settings_sha256_12, + } + if z_planes_sha256_12 is not None: + run["z_planes_sha256_12"] = z_planes_sha256_12 + return run + + +def started_marker(run: dict[str, Any]) -> dict[str, Any]: + """Return the marker a run records while it is writing a channel: its identity alone. + + Parameters + ---------- + run : dict + Run identity from :func:`prediction_run`. + + Returns + ------- + dict + ``dict(run)``; :func:`completion_marker` adds ``source_shape`` once the + channel is fully written. + """ + return dict(run) + + +def completion_marker(source_shape: list[int], run: dict[str, Any]) -> dict[str, Any]: + """Return the marker a run records for a channel predicted from ``source_shape``. + + Parameters + ---------- + source_shape : list of int + TZYX shape of the source array, from :func:`tzyx_shape`. + run : dict + Run identity from :func:`prediction_run`. + + Returns + ------- + dict + ``{"source_shape": [T, Z, Y, X], **run}``. + """ + return {"source_shape": list(source_shape), **run} + + +def marker_identity(marker: Any) -> Any: + """Strip provenance-only fields so markers compare on what was predicted. + + Parameters + ---------- + marker : Any + Recorded marker value, or None when the channel has no entry. + + Returns + ------- + Any + ``marker`` without its provenance-only keys when it is a dict, else + ``marker`` unchanged. + """ + if isinstance(marker, dict): + return {key: value for key, value in marker.items() if key not in _PROVENANCE_KEYS} + return marker + + +def same_marker(recorded: Any, marker: Any) -> bool: + """Return whether a recorded marker equals ``marker`` up to provenance fields. + + Parameters + ---------- + recorded : Any + Recorded marker value, or None when the channel has no entry. + marker : Any + Marker from :func:`started_marker` or :func:`completion_marker`. + + Returns + ------- + bool + True when both describe the same prediction state. + """ + return marker_identity(recorded) == marker_identity(marker) + + +def same_run(marker: Any, run: dict[str, Any]) -> bool: + """Return whether a recorded marker came from ``run``, started or complete, whatever its source shape. + + Parameters + ---------- + marker : Any + Recorded marker value; markers from other layouts never match. + run : dict + Run identity from :func:`prediction_run`. + + Returns + ------- + bool + True when the marker's run fields equal ``run`` up to provenance. + """ + if not isinstance(marker, dict): + return False + recorded = {key: value for key, value in marker.items() if key != "source_shape"} + return marker_identity(recorded) == marker_identity(run) + + +def mark_complete(position: Position, channels: Iterable[str], marker: dict[str, Any]) -> None: + """Record ``marker`` as the completion of ``channels``, keeping other channels' markers. + + Parameters + ---------- + position : Position + Output position whose completion attribute to update. + channels : iterable of str + Prediction channels that were fully written. + marker : dict + Marker from :func:`completion_marker`. + """ + completed = dict(position.zattrs.get(PREDICTION_COMPLETE_KEY, {})) + for channel in channels: + completed[channel] = marker + position.zattrs[PREDICTION_COMPLETE_KEY] = completed + + +def mark_started(position: Position, channels: Iterable[str], run: dict[str, Any]) -> None: + """Record that ``run`` is writing ``channels``, keeping other channels' markers. + + Replaces any completion of ``channels``, whose voxels are about to change, + so an interrupted run never leaves an old completion behind; unlike a + channel with no entry, a started channel is known to be this run's + unfinished work rather than an unverifiable legacy prediction. + + Parameters + ---------- + position : Position + Output position whose completion attribute to update. + channels : iterable of str + Prediction channels about to be (re)written. + run : dict + Run identity from :func:`prediction_run`. + """ + completed = dict(position.zattrs.get(PREDICTION_COMPLETE_KEY, {})) + for channel in channels: + completed[channel] = started_marker(run) + position.zattrs[PREDICTION_COMPLETE_KEY] = completed + + +def prediction_complete(position: Position, channels: Iterable[str], marker: dict[str, Any]) -> bool: + """Return whether every channel of ``position`` was completed with ``marker``. + + Parameters + ---------- + position : Position + Output position to inspect. + channels : iterable of str + Prediction channels that must all be complete. + marker : dict + Marker the current run would record; see :func:`completion_marker`. + + Returns + ------- + bool + True only when each channel's recorded marker equals ``marker`` up to + provenance fields. + """ + completed = position.zattrs.get(PREDICTION_COMPLETE_KEY, {}) + return all(same_marker(completed.get(channel), marker) for channel in channels) + + +def record_z_planes(position: Position, channels: Iterable[str], planes: Sequence[Sequence[int]] | None) -> None: + """Record the planes ``channels`` were predicted on, or clear them for a full prediction. + + Parameters + ---------- + position : Position + Output position whose attribute to update. + channels : iterable of str + Prediction channels that were just fully written. + planes : Sequence[Sequence[int]] or None + One plane list per timepoint, or ``None`` when every plane was predicted. + """ + recorded = dict(position.zattrs.get(PREDICTED_Z_PLANES_KEY, {})) + for channel in channels: + if planes is None: + recorded.pop(channel, None) + else: + recorded[channel] = canonical_z_planes(planes) + if recorded: + position.zattrs[PREDICTED_Z_PLANES_KEY] = recorded + elif PREDICTED_Z_PLANES_KEY in position.zattrs: + del position.zattrs[PREDICTED_Z_PLANES_KEY] + + +def predicted_z_planes(position: Position, channel: str) -> list[list[int]] | None: + """Planes ``channel`` was predicted on (one list per timepoint), or ``None`` for every plane. + + Parameters + ---------- + position : Position + Prediction position to inspect. + channel : str + Prediction channel name. + + Returns + ------- + list[list[int]] or None + The recorded per-timepoint planes, or ``None`` when the channel holds a + prediction on every plane. + """ + return position.zattrs.get(PREDICTED_Z_PLANES_KEY, {}).get(channel) + + +def plane_predict_unfinished(position: Position, channel: str) -> bool: + """Return whether ``channel`` holds a plane-restricted predict that has not finished. + + Such a run replaces the channel's marker with its started marker (which names the + plane selection, ``z_planes_sha256_12``) before its first write, and + :func:`completion_marker` adds ``source_shape`` only once every plane is written. + Until then the planes may be unwritten or another run's, while + :func:`predicted_z_planes` returns ``None`` (a fresh store) or the previous run's + planes (an interrupted re-predict of the same slab), so neither says "unfinished". + + Parameters + ---------- + position : Position + Prediction position to inspect. + channel : str + Prediction channel name. + + Returns + ------- + bool + True when the channel's marker names a plane selection but no ``source_shape``. + """ + marker = position.zattrs.get(PREDICTION_COMPLETE_KEY, {}).get(channel) + return isinstance(marker, dict) and "z_planes_sha256_12" in marker and "source_shape" not in marker diff --git a/packages/viscy-utils/tests/test_prediction_metadata.py b/packages/viscy-utils/tests/test_prediction_metadata.py new file mode 100644 index 000000000..6046c4ff1 --- /dev/null +++ b/packages/viscy-utils/tests/test_prediction_metadata.py @@ -0,0 +1,202 @@ +"""Tests for the checkpoint content hash shared by prediction writers and caches.""" + +import hashlib +import json +import os +import shutil +import time +from pathlib import Path + +import pytest + +from viscy_utils import prediction_metadata +from viscy_utils.prediction_metadata import checkpoint_sha256_12, prediction_run + + +def test_checkpoint_sha256_12(tmp_path: Path) -> None: + """Returns the first 12 hex chars of sha256; differs for different content.""" + file_a = tmp_path / "a.ckpt" + file_b = tmp_path / "b.ckpt" + file_a.write_bytes(b"model-weights-a") + file_b.write_bytes(b"model-weights-b") + + h_a = checkpoint_sha256_12(file_a) + h_b = checkpoint_sha256_12(file_b) + assert len(h_a) == 12 + assert len(h_b) == 12 + assert h_a != h_b + assert checkpoint_sha256_12(file_a) == h_a # deterministic + + +def test_checkpoint_sha256_12_writes_and_reuses_sidecar(tmp_path: Path, monkeypatch) -> None: + """First call writes ``.sha256`` with the file's size and mtime; second call skips the hash.""" + ckpt = tmp_path / "last.ckpt" + ckpt.write_bytes(b"weights") + h1 = checkpoint_sha256_12(ckpt) + sidecar = tmp_path / "last.ckpt.sha256" + recorded = json.loads(sidecar.read_text()) + assert recorded["sha256"][:12] == h1 + assert len(recorded["sha256"]) == 64 + stat = ckpt.stat() + assert recorded["size"] == stat.st_size + assert recorded["mtime_ns"] == stat.st_mtime_ns + assert recorded["ctime_ns"] == stat.st_ctime_ns + assert recorded["ino"] == stat.st_ino + + calls = {"n": 0} + real_sha256 = hashlib.sha256 + + def tracking_sha256(*args, **kwargs): + calls["n"] += 1 + return real_sha256(*args, **kwargs) + + monkeypatch.setattr(prediction_metadata.hashlib, "sha256", tracking_sha256) + h2 = checkpoint_sha256_12(ckpt) + assert h2 == h1 + assert calls["n"] == 0 + + +def test_checkpoint_sha256_12_recomputes_when_sidecar_older(tmp_path: Path) -> None: + """Newer ckpt mtime invalidates the sidecar and forces a recompute.""" + ckpt = tmp_path / "last.ckpt" + ckpt.write_bytes(b"weights-v1") + h1 = checkpoint_sha256_12(ckpt) + + ckpt.write_bytes(b"weights-v2") + sidecar = tmp_path / "last.ckpt.sha256" + old = sidecar.stat().st_mtime + os.utime(ckpt, (old + 10, old + 10)) + + h2 = checkpoint_sha256_12(ckpt) + assert h2 != h1 + assert json.loads(sidecar.read_text())["sha256"][:12] == h2 + + +def test_checkpoint_sha256_12_recomputes_after_replacement_with_older_mtime(tmp_path: Path) -> None: + """A same-size checkpoint copied in with a preserved, older mtime must not reuse the old digest. + + Only exact size and ``st_mtime_ns`` equality qualifies the sidecar; being + newer than the checkpoint is not evidence the content is unchanged. + """ + ckpt = tmp_path / "last.ckpt" + ckpt.write_bytes(b"weights-v1") + h1 = checkpoint_sha256_12(ckpt) + sidecar = tmp_path / "last.ckpt.sha256" + + ckpt.write_bytes(b"weights-v2") # same size as v1 + older = sidecar.stat().st_mtime - 3600 + os.utime(ckpt, (older, older)) + + h2 = checkpoint_sha256_12(ckpt) + assert h2 == hashlib.sha256(b"weights-v2").hexdigest()[:12] + assert h2 != h1 + + +def test_checkpoint_sha256_12_recomputes_after_replacement_preserving_size_and_mtime(tmp_path: Path) -> None: + """A same-size copy that carries the old mtime along still gets a new ctime, so the sidecar is dropped.""" + ckpt = tmp_path / "last.ckpt" + ckpt.write_bytes(b"weights-v1") + h1 = checkpoint_sha256_12(ckpt) + before = ckpt.stat() + replacement = tmp_path / "replacement.ckpt" + replacement.write_bytes(b"weights-v2") + os.utime(replacement, ns=(before.st_atime_ns, before.st_mtime_ns)) + time.sleep(0.05) # ctime advances per kernel tick, not per write + + shutil.copy2(replacement, ckpt) + + assert ckpt.stat().st_mtime_ns == before.st_mtime_ns + assert ckpt.stat().st_size == before.st_size + h2 = checkpoint_sha256_12(ckpt) + assert h2 == hashlib.sha256(b"weights-v2").hexdigest()[:12] + assert h2 != h1 + + +@pytest.mark.parametrize("content", ["not-a-hex-digest\n", "a" * 64 + "\n", '{"sha256": "abc"}\n']) +def test_checkpoint_sha256_12_ignores_unusable_sidecars(tmp_path: Path, content: str) -> None: + """Corrupt, legacy plain-hex, and incomplete sidecars are treated as missing and recomputed.""" + ckpt = tmp_path / "last.ckpt" + ckpt.write_bytes(b"weights") + sidecar = tmp_path / "last.ckpt.sha256" + sidecar.write_text(content) + + h = checkpoint_sha256_12(ckpt) + assert h == hashlib.sha256(b"weights").hexdigest()[:12] + assert json.loads(sidecar.read_text())["sha256"][:12] == h + + +def test_checkpoint_sha256_12_without_memo_ignores_the_sidecar(tmp_path: Path) -> None: + """``memoize=False`` hashes the bytes: it neither trusts a sidecar that matches the file nor leaves one.""" + ckpt = tmp_path / "model.ckpt" + ckpt.write_bytes(b"weights") + sidecar = tmp_path / "model.ckpt.sha256" + expected = hashlib.sha256(b"weights").hexdigest()[:12] + + assert checkpoint_sha256_12(ckpt, memoize=False) == expected + assert not sidecar.exists() + + stat = ckpt.stat() + forged = json.dumps( + { + "sha256": "0" * 64, + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "ctime_ns": stat.st_ctime_ns, + "ino": stat.st_ino, + } + ) + sidecar.write_text(forged + "\n") + assert checkpoint_sha256_12(ckpt) == "0" * 12 + assert checkpoint_sha256_12(ckpt, memoize=False) == expected + assert sidecar.read_text() == forged + "\n" + + +def test_prediction_run_hashes_the_checkpoint_bytes_not_a_sidecar(tmp_path: Path) -> None: + """The reviewer's probe: a same-size copy carrying the old mtime, made right after hashing, gets its own digest.""" + ckpt = tmp_path / "model.ckpt" + replacement = tmp_path / "replacement.ckpt" + ckpt.write_bytes(b"weights-a") + replacement.write_bytes(b"weights-b") + before = ckpt.stat() + os.utime(replacement, ns=(before.st_atime_ns, before.st_mtime_ns)) + + def identity() -> dict: + return prediction_run(array_key="0", z_window_size=4, z_reduction="blend", checkpoint_path=ckpt) + + first = identity() + shutil.copy2(replacement, ckpt) + second = identity() + + assert first["checkpoint_sha256_12"] == hashlib.sha256(b"weights-a").hexdigest()[:12] + assert second["checkpoint_sha256_12"] == hashlib.sha256(b"weights-b").hexdigest()[:12] + assert not (tmp_path / "model.ckpt.sha256").exists() + + +def test_checkpoint_sha256_12_read_only_dir(tmp_path: Path) -> None: + """Read-only parent dir does not raise; digest still returned.""" + ckpt_dir = tmp_path / "frozen" + ckpt_dir.mkdir() + ckpt = ckpt_dir / "last.ckpt" + ckpt.write_bytes(b"weights") + os.chmod(ckpt_dir, 0o555) + try: + h = checkpoint_sha256_12(ckpt) + assert len(h) == 12 + assert not (ckpt_dir / "last.ckpt.sha256").exists() + finally: + os.chmod(ckpt_dir, 0o755) + + +def test_marker_identity_strips_only_provenance_fields() -> None: + """A moved checkpoint keeps its identity; any field that shapes the voxels stays in it.""" + run = {"array_key": "0", "checkpoint_path": "/a/model.ckpt", "checkpoint_sha256_12": "abc"} + marker = prediction_metadata.completion_marker([1, 2, 3, 4], run) + assert prediction_metadata.marker_identity(marker) == { + "source_shape": [1, 2, 3, 4], + "array_key": "0", + "checkpoint_sha256_12": "abc", + } + assert prediction_metadata.marker_identity({**marker, "checkpoint_path": "/b/model.ckpt"}) == ( + prediction_metadata.marker_identity(marker) + ) + assert prediction_metadata.marker_identity(None) is None diff --git a/packages/viscy-utils/tests/test_prediction_writer.py b/packages/viscy-utils/tests/test_prediction_writer.py index 82aa56b6e..292a41986 100644 --- a/packages/viscy-utils/tests/test_prediction_writer.py +++ b/packages/viscy-utils/tests/test_prediction_writer.py @@ -1,9 +1,954 @@ """Tests for prediction writer blending utilities.""" +import hashlib +import json +import threading +import time +from types import SimpleNamespace + import numpy as np +import pytest import torch +from iohub import open_ome_zarr +from lightning.pytorch import LightningModule, Trainer + +from viscy_data import HCSDataModule +from viscy_data.sliding_window import SlidingWindowDataset +from viscy_utils.callbacks import prediction_writer +from viscy_utils.callbacks.prediction_writer import HCSPredictionWriter, _blend_in, _store_into, _torch_factors +from viscy_utils.prediction_metadata import ( + PREDICTED_Z_PLANES_KEY, + PREDICTION_COMPLETE_KEY, + completion_marker, + mark_complete, + mark_started, + plane_predict_unfinished, + predicted_z_planes, + prediction_complete, + prediction_run, + record_z_planes, + same_run, + started_marker, + z_planes_sha256_12, +) + +Z_SIZE = 16 +Z_WINDOW = 8 +#: The buffered writer blends on the prediction's device, so its bytes are checked on every device present. +ACCELERATORS = [ + "cpu", + pytest.param("gpu", marks=pytest.mark.skipif(not torch.cuda.is_available(), reason="no CUDA device")), +] + + +class _ZStampModule(LightningModule): + """Predict a per-plane stamp ``10 * z_window_start + offset_in_window``. + + Every plane of the assembled volume then names the forward pass and the + within-window position it came from, so the two ``z_reduction`` modes are + distinguishable from the store alone. + """ + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + + def predict_step(self, batch, batch_idx: int, dataloader_idx: int = 0) -> torch.Tensor: + source = batch["source"] + depth = source.shape[-3] + offsets = torch.arange(depth, dtype=torch.float32, device=source.device) + starts = torch.as_tensor([int(z) for z in batch["index"][2]], dtype=torch.float32, device=source.device) + stamp = 10.0 * starts.view(-1, 1) + offsets.view(1, -1) + return stamp.view(-1, 1, depth, 1, 1).expand(-1, 1, depth, *source.shape[-2:]).clone() + + +def _run_predict(tmp_path, z_reduction): + """Predict a synthetic single-position plate and return the written CZYX volume.""" + source_store = tmp_path / f"source_{z_reduction}.zarr" + with open_ome_zarr(source_store, layout="hcs", mode="w-", channel_names=["Phase3D", "Nuclei"]) as plate: + position = plate.create_position("0", "0", "0") + position.create_zeros("0", (1, 2, Z_SIZE, 8, 8), dtype=np.float32) + data_module = HCSDataModule( + data_path=str(source_store), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=Z_WINDOW, + batch_size=1, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + ) + output_store = tmp_path / f"prediction_{z_reduction}.zarr" + Trainer( + accelerator="cpu", + logger=False, + enable_progress_bar=False, + callbacks=[HCSPredictionWriter(str(output_store), z_reduction=z_reduction)], + ).predict(_ZStampModule(), datamodule=data_module, return_predictions=False) + with open_ome_zarr(output_store, mode="r") as plate: + return np.asarray(plate["0/0/0"]["0"][0, 0, :, 0, 0]) + + +def test_predict_z_reduction_blend_averages_every_covering_window(tmp_path): + """``blend`` makes each plane the unweighted mean of the windows covering it.""" + written = _run_predict(tmp_path, "blend") + n_windows = Z_SIZE - Z_WINDOW + 1 + expected = [ + np.mean([10 * s + (p - s) for s in range(max(0, p - Z_WINDOW + 1), min(n_windows - 1, p) + 1)]) + for p in range(Z_SIZE) + ] + np.testing.assert_allclose(written, expected, rtol=1e-5) + + +def test_predict_z_reduction_center_takes_one_pass_per_plane(tmp_path): + """``center`` gives each plane the window centered on it, and covers the edges.""" + written = _run_predict(tmp_path, "center") + center = Z_WINDOW // 2 + last = Z_SIZE - Z_WINDOW + expected = [] + for p in range(Z_SIZE): + # Interior planes come from their own window's center; the leading planes + # from window 0 and the trailing ones from the last window, since no + # window is centered on them. + start = min(max(0, p - center), last) + expected.append(10 * start + (p - start)) + np.testing.assert_array_equal(written, expected) + interior = range(center, last + center + 1) + assert all(written[p] % 10 == center for p in interior) + + +def test_completion_marker_layout(tmp_path): + """One position attribute maps each prediction channel to what produced it. + + The launcher's resume check and test fixtures read this literal layout, so a + change here is a contract change, not a refactor. + """ + _run_predict(tmp_path, "blend") + run = prediction_run(array_key="0", z_window_size=Z_WINDOW, z_reduction="blend", checkpoint_path=None) + with open_ome_zarr(tmp_path / "prediction_blend.zarr", mode="r") as plate: + position = plate["0/0/0"] + assert position.zattrs[PREDICTION_COMPLETE_KEY] == { + "Nuclei_prediction": { + "source_shape": [1, Z_SIZE, 8, 8], + "array_key": "0", + "z_window_size": Z_WINDOW, + "z_reduction": "blend", + "checkpoint_path": None, + "checkpoint_sha256_12": None, + "settings_sha256_12": None, + } + } + assert prediction_complete(position, ["Nuclei_prediction"], completion_marker([1, Z_SIZE, 8, 8], run)) + assert not prediction_complete(position, ["Nuclei_prediction"], completion_marker([2, Z_SIZE, 8, 8], run)) + + +def test_completion_helpers_keep_other_channels(tmp_path): + """Completing or restarting one channel must leave the other channels' markers intact.""" + run = prediction_run(array_key="0", z_window_size=1, z_reduction="blend", checkpoint_path=None) + marker = completion_marker([1, 2, 3, 4], run) + with open_ome_zarr(tmp_path / "plate.zarr", layout="hcs", mode="w-", channel_names=["A", "B"]) as plate: + position = plate.create_position("0", "0", "0") + mark_complete(position, ["A"], marker) + mark_complete(position, ["B"], marker) + mark_started(position, ["A"], run) + assert position.zattrs[PREDICTION_COMPLETE_KEY] == {"A": started_marker(run), "B": marker} + assert prediction_complete(position, ["B"], marker) + assert not prediction_complete(position, ["A", "B"], marker) + + +class _WindowFunctionModule(LightningModule): + """Predict a nonlinear function of each window's input and position, so blending and window order matter. + + ``center_plane`` returns only the window's center plane (a 2D model, ``target_2d``); ``dtype`` sets the + prediction dtype. + """ + + def __init__(self, center_plane: bool = False, dtype: torch.dtype = torch.float32) -> None: + super().__init__() + self.center_plane = center_plane + self.out_dtype = dtype + + def predict_step(self, batch, batch_idx: int, dataloader_idx: int = 0) -> torch.Tensor: + source = batch["source"] + offsets = torch.arange(source.shape[-3], dtype=source.dtype, device=source.device).view(1, 1, -1, 1, 1) + starts = torch.as_tensor([int(z) for z in batch["index"][2]], dtype=source.dtype, device=source.device) + out = torch.sin(source * (1.0 + 0.1 * offsets)) + 0.01 * starts.view(-1, 1, 1, 1, 1) + if self.center_plane: + mid = out.shape[-3] // 2 + out = out[:, :, mid : mid + 1] + return out.to(self.out_dtype) + + +class _PerWindowWriter(HCSPredictionWriter): + """Reference: the per-window zarr read-modify-write (the ``write_input`` path) in every mode.""" + + def on_predict_start(self, trainer: Trainer, pl_module: LightningModule) -> None: + super().on_predict_start(trainer, pl_module) + self._write_once = False + + +#: Per-timepoint planes of the two FOVs of ``_write_random_source``. +RANDOM_SOURCE_PLANES = {"0/0/0": [[0, 1, 2], [4, 5, 6]], "0/0/1": [[8, 9, 10], [3, 4, 5]]} + + +def _write_random_source(path) -> None: + """Two FOVs of random ``(2, 2, 11, 8, 8)`` float32 Phase3D/Nuclei.""" + rng = np.random.default_rng(0) + with open_ome_zarr(path, layout="hcs", mode="w-", channel_names=["Phase3D", "Nuclei"]) as plate: + for fov in RANDOM_SOURCE_PLANES: + plate.create_position(*fov.split("/")).create_image( + "0", rng.normal(size=(2, 2, 11, 8, 8)).astype(np.float32) + ) + + +def _read_store(path) -> dict: + with open_ome_zarr(path, mode="r") as plate: + return {name: (np.asarray(pos["0"]), pos["0"].chunks, dict(pos.zattrs)) for name, pos in plate.positions()} + + +def _assert_same_bytes(reference: dict, buffered: dict) -> None: + assert buffered.keys() == reference.keys() + for name, (voxels, chunks, attrs) in reference.items(): + new_voxels, new_chunks, new_attrs = buffered[name] + assert (new_voxels.shape, new_voxels.dtype, new_chunks) == (voxels.shape, voxels.dtype, chunks) + assert new_voxels.tobytes() == voxels.tobytes() + assert new_attrs == attrs + + +@pytest.mark.parametrize("accelerator", ACCELERATORS) +@pytest.mark.parametrize("mode", ["blend", "center", "target_2d", "z_planes"]) +@pytest.mark.parametrize("batch_size", [1, 3]) +@pytest.mark.parametrize("limit_batches", [None, 5]) +def test_buffered_volumes_store_the_same_bytes_as_per_window_writes( + tmp_path, accelerator, mode, batch_size, limit_batches +): + """Assembling each volume in memory and writing it once stores the bytes and markers per-window writes store. + + Covers 3D windows (blend, center) and a 2D model's padded center plane (``target_2d``); a plane selection + takes the per-window path on both sides. The buffered volume lives on the prediction's device, so on a GPU + the float64 blend runs there against the reference's numpy blend. + ``limit_batches=5`` stops inside a volume, so the partial volume flushed at predict end is compared too. The + reference reads per window too (``num_workers=1`` disables the volume cache). + """ + source = tmp_path / "source.zarr" + _write_random_source(source) + planes_file = None + if mode == "z_planes": + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": RANDOM_SOURCE_PLANES})) + stores = {} + for writer_cls in (_PerWindowWriter, HCSPredictionWriter): + data_module = HCSDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=1 if mode == "z_planes" else 4, + target_2d=mode == "target_2d", + batch_size=batch_size, + num_workers=1 if writer_cls is _PerWindowWriter else 0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + predict_z_planes=planes_file, + ) + output = tmp_path / f"pred_{writer_cls.__name__}.zarr" + writer = writer_cls(str(output), z_reduction="center" if mode == "center" else "blend") + Trainer( + accelerator=accelerator, + devices=1, + logger=False, + enable_progress_bar=False, + limit_predict_batches=limit_batches, + callbacks=[writer], + ).predict( + _WindowFunctionModule(center_plane=mode == "target_2d"), datamodule=data_module, return_predictions=False + ) + assert writer._write_once is (writer_cls is HCSPredictionWriter and mode != "z_planes") + assert data_module.predict_dataset.cache_volume is (writer_cls is HCSPredictionWriter and mode != "z_planes") + stores[writer_cls] = _read_store(output) + _assert_same_bytes(stores[_PerWindowWriter], stores[HCSPredictionWriter]) + + +def test_overwrite_keeps_the_planes_a_padded_run_does_not_predict(tmp_path): + """A ``target_2d`` run over a 3D store leaves its padding planes as stored, as per-window writes do.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + + def predict(output, writer, z_window_size: int, target_2d: bool) -> None: + data_module = HCSDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=z_window_size, + target_2d=target_2d, + batch_size=1, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + ) + Trainer(accelerator="cpu", logger=False, enable_progress_bar=False, callbacks=[writer]).predict( + _WindowFunctionModule(center_plane=target_2d), datamodule=data_module, return_predictions=False + ) + + stores = {} + for writer_cls in (_PerWindowWriter, HCSPredictionWriter): + output = tmp_path / f"pred_{writer_cls.__name__}.zarr" + predict(output, HCSPredictionWriter(str(output)), z_window_size=4, target_2d=False) + predict(output, writer_cls(str(output), overwrite=True), z_window_size=5, target_2d=True) + stores[writer_cls] = _read_store(output) + voxels = next(iter(stores[_PerWindowWriter].values()))[0] + assert np.abs(voxels[:, -1, :2]).sum() > 0, "the 3D run's planes below the padding must survive" + _assert_same_bytes(stores[_PerWindowWriter], stores[HCSPredictionWriter]) + + +class _DeeperModule(_WindowFunctionModule): + """Predict one plane more than the window, so the last windows reach past the source depth.""" + + def predict_step(self, batch, batch_idx: int, dataloader_idx: int = 0) -> torch.Tensor: + out = super().predict_step(batch, batch_idx, dataloader_idx) + return torch.cat([out, out[:, :, -1:] + 1.0], dim=2) + + +def test_buffered_volume_grows_for_a_prediction_deeper_than_its_window(tmp_path): + """Planes past the source depth extend the volume, as the per-window resize does.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + stores = {} + for writer_cls in (_PerWindowWriter, HCSPredictionWriter): + output = tmp_path / f"pred_{writer_cls.__name__}.zarr" + data_module = HCSDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=4, + batch_size=1, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + ) + Trainer( + accelerator="cpu", logger=False, enable_progress_bar=False, callbacks=[writer_cls(str(output))] + ).predict(_DeeperModule(), datamodule=data_module, return_predictions=False) + stores[writer_cls] = _read_store(output) + assert {voxels.shape[2] for voxels, _, _ in stores[HCSPredictionWriter].values()} == {12} + _assert_same_bytes(stores[_PerWindowWriter], stores[HCSPredictionWriter]) + + +def test_buffered_volume_takes_the_dtype_of_an_existing_array(tmp_path): + """Overwriting a float32 store with float16 predictions blends in float32, as per-window writes do.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + stores = {} + for writer_cls in (_PerWindowWriter, HCSPredictionWriter): + for dtype, overwrite in ((torch.float32, False), (torch.float16, True)): + output = tmp_path / f"pred_{writer_cls.__name__}.zarr" + data_module = HCSDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=4, + batch_size=1, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + ) + Trainer( + accelerator="cpu", + logger=False, + enable_progress_bar=False, + callbacks=[writer_cls(str(output), overwrite=overwrite)], + ).predict(_WindowFunctionModule(dtype=dtype), datamodule=data_module, return_predictions=False) + stores[writer_cls] = _read_store(output) + assert {voxels.dtype for voxels, _, _ in stores[HCSPredictionWriter].values()} == {np.dtype(np.float32)} + _assert_same_bytes(stores[_PerWindowWriter], stores[HCSPredictionWriter]) + + +@pytest.mark.parametrize("accelerator", ACCELERATORS) +def test_buffered_float16_volume_rounds_the_blend_like_numpy(tmp_path, accelerator): + """A float16 store from float16 predictions rounds each float64 blend to float16 as the numpy write does.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + stores = {} + for writer_cls in (_PerWindowWriter, HCSPredictionWriter): + output = tmp_path / f"pred_{writer_cls.__name__}.zarr" + Trainer( + accelerator=accelerator, + devices=1, + logger=False, + enable_progress_bar=False, + callbacks=[writer_cls(str(output))], + ).predict( + _WindowFunctionModule(dtype=torch.float16), datamodule=_ones_data_module(source), return_predictions=False + ) + stores[writer_cls] = _read_store(output) + assert {voxels.dtype for voxels, _, _ in stores[HCSPredictionWriter].values()} == {np.dtype(np.float16)} + _assert_same_bytes(stores[_PerWindowWriter], stores[HCSPredictionWriter]) + + +def test_store_into_rounds_float64_to_float16_once(): + """Torch rounds float64 to float16 through float32; the volume must hold numpy's single rounding.""" + # Just above the midpoint of 1 and the next float16: float32 rounds it onto the midpoint, then a tie to even. + value = 1 + 2.0**-11 + 2.0**-40 + assert torch.tensor(value, dtype=torch.float64).to(torch.float16).item() == 1.0 + volume = torch.zeros(1, 2, 1, 1, dtype=torch.float16) + _store_into(volume, slice(1, 2), torch.full((1, 1, 1, 1), value, dtype=torch.float64)) + expected = np.float16(value) + assert expected == 1 + 2.0**-10 + assert volume[0, 1, 0, 0].item() == expected + assert volume[0, 0, 0, 0].item() == 0 + + +class _SlowWriter(HCSPredictionWriter): + """Hold every volume write on the flush thread for a while, so a marker that does not wait for it shows.""" + + def _write_volume(self, *args, **kwargs) -> None: + time.sleep(0.2) + super()._write_volume(*args, **kwargs) + + +def test_completion_marker_follows_the_flushed_voxels(tmp_path, monkeypatch): + """A FOV is marked complete only after every one of its volumes is in the store, and the writes run off-thread.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + output = tmp_path / "pred.zarr" + marked = [] + real_mark_complete = prediction_writer.mark_complete + + def checked_mark_complete(position, channels, marker): + # Every voxel of this run is nonzero, so a zero names a volume not yet written. + with open_ome_zarr(output, mode="r") as plate: + voxels = np.asarray(plate[position.zgroup.path]["0"]) + marked.append((position.zgroup.path, voxels.shape, bool((voxels != 0).all()), threading.current_thread())) + real_mark_complete(position, channels, marker) + + monkeypatch.setattr(prediction_writer, "mark_complete", checked_mark_complete) + data_module = _ones_data_module(source, batch_size=3) + Trainer(accelerator="cpu", logger=False, enable_progress_bar=False, callbacks=[_SlowWriter(str(output))]).predict( + _OnesModule(), datamodule=data_module, return_predictions=False + ) + assert [(name, shape, full) for name, shape, full, _ in marked] == [ + (fov, (2, 1, 11, 8, 8), True) for fov in RANDOM_SOURCE_PLANES + ] + assert all(thread is not threading.main_thread() for *_, thread in marked) + + +class _FailingWriter(HCSPredictionWriter): + """Fail the write of one volume on the flush thread.""" + + def __init__(self, *args, fail_volume: int, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.fail_volume = fail_volume + self.n_volumes = 0 + self.closed = False + + def on_predict_start(self, trainer: Trainer, pl_module: LightningModule) -> None: + super().on_predict_start(trainer, pl_module) + close = self.plate.close + + def recording_close() -> None: + self.closed = True + close() + + self.plate.close = recording_close + + def _write_volume(self, *args, **kwargs) -> None: + self.n_volumes += 1 + if self.n_volumes == self.fail_volume: + raise OSError("disk full") + super()._write_volume(*args, **kwargs) + + +@pytest.mark.parametrize("fail_volume", [2, 4]) +def test_a_failed_volume_write_fails_the_predict(tmp_path, fail_volume): + """A write error on the flush thread stops the predict, and its FOV is never marked complete. + + Volume 2 is FOV 0's last: the error stops the run before FOV 1 is written. Volume 4 is FOV 1's last, so the + error surfaces at predict end, after FOV 0 completed. Either way the store is closed. + """ + source = tmp_path / "source.zarr" + _write_random_source(source) + writer = _FailingWriter(str(tmp_path / "pred.zarr"), fail_volume=fail_volume) + with pytest.raises(OSError, match="disk full"): + Trainer(accelerator="cpu", logger=False, enable_progress_bar=False, callbacks=[writer]).predict( + _OnesModule(), datamodule=_ones_data_module(source), return_predictions=False + ) + # Raised mid-run or at predict end, the last write finishes and the store is closed all the same. + assert writer.closed + assert writer._flush_pool._shutdown + with open_ome_zarr(tmp_path / "pred.zarr", mode="r") as plate: + markers = [pos.zattrs[PREDICTION_COMPLETE_KEY]["Nuclei_prediction"] for _, pos in plate.positions()] + assert ["source_shape" in marker for marker in markers] == ([False] if fail_volume == 2 else [True, False]) + + +class _RepeatFirstWindowDataModule(HCSDataModule): + """Serve the first window again after the first volume is done, as a stray retry would.""" + + def predict_dataloader(self): + # FOV 0, t=0 has 8 windows; its first comes back after the volume was flushed. + return torch.utils.data.DataLoader(torch.utils.data.Subset(self.predict_dataset, [*range(8), 0]), batch_size=1) + + +def test_a_window_arriving_twice_is_refused(tmp_path): + """A repeated window would blend into a flushed volume's zero buffer and re-mark the FOV; it must raise.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + data_module = _RepeatFirstWindowDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=4, + batch_size=1, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + ) + with pytest.raises(RuntimeError, match="arrived twice"): + _predict_ones_with(HCSPredictionWriter(str(tmp_path / "pred.zarr")), data_module) + -from viscy_utils.callbacks.prediction_writer import _blend_in +def test_cached_volume_windows_equal_per_window_reads(tmp_path): + """Windows cut from a cached (position, t) volume equal windows read from zarr one by one.""" + source = tmp_path / "source.zarr" + _write_random_source(source) + with open_ome_zarr(source, mode="r") as plate: + positions = [position for _, position in plate.positions()] + datasets = [ + SlidingWindowDataset( + positions=positions, + channels={"source": ["Phase3D", "Nuclei"]}, + z_window_size=4, + load_normalization_metadata=False, + cache_volume=cache_volume, + ) + for cache_volume in (False, True) + ] + assert len(datasets[0]) == len(datasets[1]) == 2 * 2 * 8 + for index in range(len(datasets[0])): + reference, cached = datasets[0][index], datasets[1][index] + assert reference["index"] == cached["index"] + assert torch.equal(reference["source"], cached["source"]) + assert datasets[1]._volume_key is not None + + +class _OnesModule(LightningModule): + def predict_step(self, batch, batch_idx: int, dataloader_idx: int = 0) -> torch.Tensor: + return torch.ones_like(batch["source"]) + + +def _write_source(path, fovs: list[str], z_size: int = 4) -> None: + """Create a two-channel source plate with one ``(1, 2, Z, 8, 8)`` array per FOV.""" + with open_ome_zarr(path, layout="hcs", mode="w-", channel_names=["Phase3D", "Nuclei"]) as plate: + for fov in fovs: + plate.create_position(*fov.split("/")).create_zeros("0", (1, 2, z_size, 8, 8), dtype=np.float32) + + +def _ones_data_module(source, *, exclude=None, target="Nuclei", batch_size=1) -> HCSDataModule: + """Depth-4 windows over every FOV of ``source``, predicting ``target``.""" + return HCSDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=[target], + z_window_size=4, + batch_size=batch_size, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + exclude_fov_names=exclude, + ) + + +def _predict_ones_with(writer: HCSPredictionWriter, data_module: HCSDataModule, *, limit_batches=None) -> None: + """Run ``_OnesModule`` through ``writer`` on the CPU.""" + Trainer( + accelerator="cpu", + logger=False, + enable_progress_bar=False, + limit_predict_batches=limit_batches, + callbacks=[writer], + ).predict(_OnesModule(), datamodule=data_module, return_predictions=False) + + +def _predict_ones( + source, output, *, checkpoint_path=None, exclude=None, overwrite=False, limit_batches=None, target="Nuclei" +) -> None: + """Predict all-ones for every FOV of ``source`` with depth-4 windows into ``output``.""" + writer = HCSPredictionWriter(str(output), overwrite=overwrite, checkpoint_path=checkpoint_path) + _predict_ones_with(writer, _ones_data_module(source, exclude=exclude, target=target), limit_batches=limit_batches) + + +def test_appending_a_channel_keeps_existing_predictions(tmp_path): + """A store holding another model's channel gains ours; its voxels and marker are untouched.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0", "0/0/1"]) + other_run = prediction_run(array_key="0", z_window_size=4, z_reduction="blend", checkpoint_path=None) + with open_ome_zarr(output, layout="hcs", mode="w-", channel_names=["Other_prediction"]) as plate: + for fov in ("0/0/0", "0/0/1"): + position = plate.create_position(*fov.split("/")) + position.create_zeros("0", (1, 1, 4, 8, 8), dtype=np.float32)[:] = 7 + mark_complete(position, ["Other_prediction"], completion_marker([1, 4, 8, 8], other_run)) + + _predict_ones(source, output) + + with open_ome_zarr(output, mode="r") as plate: + assert plate.channel_names == ["Other_prediction", "Nuclei_prediction"] + for fov in ("0/0/0", "0/0/1"): + position = plate[fov] + assert position.channel_names == ["Other_prediction", "Nuclei_prediction"] + np.testing.assert_array_equal(position["0"][:, 0], 7) + np.testing.assert_array_equal(position["0"][:, 1], 1) + marker = position.zattrs[PREDICTION_COMPLETE_KEY] + assert marker["Other_prediction"] == completion_marker([1, 4, 8, 8], other_run) + assert "Nuclei_prediction" in marker + + +def test_writer_refuses_positions_that_would_disagree_on_channel_order(tmp_path): + """Channel indices are plate-wide, so positions must end up with one channel order; check before writing.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0", "0/0/1"]) + with open_ome_zarr(output, layout="hcs", mode="w-", channel_names=["Other_prediction"]) as plate: + for fov in ("0/0/0", "0/0/1"): + plate.create_position(*fov.split("/")).create_zeros("0", (1, 1, 4, 8, 8), dtype=np.float32) + # Separate handles: positions created in one session share one channel list in memory. + for fov, channel in (("0/0/0", "Nuclei_prediction"), ("0/0/1", "Third")): + with open_ome_zarr(output, mode="r+") as plate: + plate[fov].append_channel(channel, resize_arrays=True) + + with pytest.raises(ValueError, match="channel order"): + _predict_ones(source, output, overwrite=True) + + with open_ome_zarr(output, mode="r") as plate: + assert plate["0/0/1"].channel_names == ["Other_prediction", "Third"] + + +def test_interrupted_fov_carries_a_started_marker(tmp_path): + """A FOV the writer created but never finished names the run writing it, unlike pre-marker outputs.""" + source = tmp_path / "source.zarr" + _write_source(source, ["0/0/0"], z_size=8) + _predict_ones(source, tmp_path / "pred.zarr", limit_batches=2) # 2 of 5 depth windows + + run = prediction_run(array_key="0", z_window_size=4, z_reduction="blend", checkpoint_path=None) + with open_ome_zarr(tmp_path / "pred.zarr", mode="r") as plate: + assert plate["0/0/0"].zattrs[PREDICTION_COMPLETE_KEY] == {"Nuclei_prediction": started_marker(run)} + + +def test_writer_refuses_fovs_predicted_before_markers_existed(tmp_path): + """Channels without any completion attribute cannot be verified, so the store is off limits.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0", "0/0/1"]) + with open_ome_zarr(output, layout="hcs", mode="w-", channel_names=["Nuclei_prediction"]) as plate: + for fov in ("0/0/0", "0/0/1"): + plate.create_position(*fov.split("/")).create_zeros("0", (1, 1, 4, 8, 8), dtype=np.float32) + + with pytest.raises(ValueError, match="before completion markers"): + _predict_ones(source, output, exclude=["0/0/0"], overwrite=True) + + with open_ome_zarr(output, mode="r") as plate: + assert all(PREDICTION_COMPLETE_KEY not in pos.zattrs for _, pos in plate.positions()) + np.testing.assert_array_equal(plate["0/0/1/0"][:], 0) + + +def test_writer_refuses_a_legacy_channel_after_another_channel_was_marked(tmp_path): + """Markers are per channel: predicting a new channel into a legacy store vouches for nothing else.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0", "0/0/1"]) + with open_ome_zarr(output, layout="hcs", mode="w-", channel_names=["Nuclei_prediction"]) as plate: + for fov in ("0/0/0", "0/0/1"): + plate.create_position(*fov.split("/")).create_zeros("0", (1, 1, 4, 8, 8), dtype=np.float32)[:] = 999 + _predict_ones(source, output, target="Other") + with open_ome_zarr(output, mode="r") as plate: + assert set(plate["0/0/0"].zattrs[PREDICTION_COMPLETE_KEY]) == {"Other_prediction"} + + with pytest.raises(ValueError, match="before completion markers"): + _predict_ones(source, output, exclude=["0/0/0"], overwrite=True) + + with open_ome_zarr(output, mode="r") as plate: + for fov in ("0/0/0", "0/0/1"): + np.testing.assert_array_equal(plate[fov]["0"][:, 0], 999) + assert set(plate[fov].zattrs[PREDICTION_COMPLETE_KEY]) == {"Other_prediction"} + + +def test_channels_appended_to_excluded_fovs_carry_this_runs_started_marker(tmp_path): + """Appending a channel allocates zeros in every FOV; excluded FOVs record who did, so a resume can finish them.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0", "0/0/1"]) + _predict_ones(source, output) + _predict_ones(source, output, exclude=["0/0/1"], target="Other") + + run = prediction_run(array_key="0", z_window_size=4, z_reduction="blend", checkpoint_path=None) + complete = completion_marker([1, 4, 8, 8], run) + with open_ome_zarr(output, mode="r") as plate: + assert plate["0/0/0"].zattrs[PREDICTION_COMPLETE_KEY] == { + "Nuclei_prediction": complete, + "Other_prediction": complete, + } + assert plate["0/0/1"].zattrs[PREDICTION_COMPLETE_KEY] == { + "Nuclei_prediction": complete, + "Other_prediction": started_marker(run), + } + np.testing.assert_array_equal(plate["0/0/1/0"][:, 1], 0) + + _predict_ones(source, output, exclude=["0/0/0"], target="Other", overwrite=True) + + with open_ome_zarr(output, mode="r") as plate: + for fov in ("0/0/0", "0/0/1"): + assert plate[fov].zattrs[PREDICTION_COMPLETE_KEY] == { + "Nuclei_prediction": complete, + "Other_prediction": complete, + } + np.testing.assert_array_equal(plate[fov]["0"][:], 1) + + +def test_marker_records_the_checkpoint_content(tmp_path): + """The marker names the checkpoint and hashes its content, so a moved copy still matches.""" + source = tmp_path / "source.zarr" + _write_source(source, ["0/0/0"]) + ckpt = tmp_path / "a.ckpt" + ckpt.write_bytes(b"weights-a") + _predict_ones(source, tmp_path / "pred.zarr", checkpoint_path=str(ckpt)) + + with open_ome_zarr(tmp_path / "pred.zarr", mode="r") as plate: + marker = plate["0/0/0"].zattrs[PREDICTION_COMPLETE_KEY]["Nuclei_prediction"] + assert marker["checkpoint_path"] == str(ckpt) + assert marker["checkpoint_sha256_12"] == hashlib.sha256(b"weights-a").hexdigest()[:12] + moved = tmp_path / "moved.ckpt" + moved.write_bytes(b"weights-a") + other = tmp_path / "b.ckpt" + other.write_bytes(b"weights-b") + assert same_run(marker, prediction_run(array_key="0", z_window_size=4, z_reduction="blend", checkpoint_path=moved)) + assert not same_run( + marker, prediction_run(array_key="0", z_window_size=4, z_reduction="blend", checkpoint_path=other) + ) + + +def test_writer_refuses_a_checkpoint_replaced_after_construction(tmp_path): + """The hash must name the weights the model loaded, so a file swapped after the writer saw it is refused.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0"]) + ckpt = tmp_path / "a.ckpt" + ckpt.write_bytes(b"weights-a") + writer = HCSPredictionWriter(str(output), checkpoint_path=str(ckpt)) + ckpt.write_bytes(b"weights-bb") + + with pytest.raises(RuntimeError, match="changed since this writer was constructed"): + _predict_ones_with(writer, _ones_data_module(source)) + assert not output.exists() + + +def test_writer_refuses_to_mix_checkpoints_across_fovs(tmp_path): + """FOVs outside the run that other weights completed make the store off limits, before any write.""" + source = tmp_path / "source.zarr" + output = tmp_path / "pred.zarr" + _write_source(source, ["0/0/0", "0/0/1"]) + ckpt_a = tmp_path / "a.ckpt" + ckpt_a.write_bytes(b"weights-a") + ckpt_b = tmp_path / "b.ckpt" + ckpt_b.write_bytes(b"weights-b") + _predict_ones(source, output, checkpoint_path=str(ckpt_a)) + with open_ome_zarr(output, mode="r") as plate: + before = {name: pos.zattrs[PREDICTION_COMPLETE_KEY] for name, pos in plate.positions()} + + with pytest.raises(ValueError, match="different checkpoint"): + _predict_ones(source, output, checkpoint_path=str(ckpt_b), exclude=["0/0/0"], overwrite=True) + + with open_ome_zarr(output, mode="r") as plate: + assert {name: pos.zattrs[PREDICTION_COMPLETE_KEY] for name, pos in plate.positions()} == before + + +def test_writer_refuses_multi_device_predict(tmp_path): + """Per-process completion bitmaps cannot certify a FOV split across ranks; fail before opening the store.""" + writer = HCSPredictionWriter(str(tmp_path / "pred.zarr")) + with pytest.raises(NotImplementedError, match="single device"): + writer.on_predict_start(SimpleNamespace(world_size=2), None) + assert not (tmp_path / "pred.zarr").exists() + + +class _SourcePlusOneModule(LightningModule): + """Predict ``source + 1``, so every written plane names the source plane it came from.""" + + def predict_step(self, batch, batch_idx: int, dataloader_idx: int = 0) -> torch.Tensor: + return batch["source"] + 1.0 + + +#: Per-timepoint planes for two FOVs of a (T=2, Z=7) source; caps and an interior slab. +SLAB_PLANES = {"0/0/0": [[0, 1], [3, 4, 5]], "0/0/1": [[5, 6], [2, 3, 4]]} + + +def _write_stamped_source(path) -> None: + """Two FOVs of ``(2, 2, 7, 8, 8)`` whose Phase3D plane ``(t, z)`` holds ``100 * t + z + 1``.""" + stamp = (100 * np.arange(2)[:, None] + np.arange(7)[None, :] + 1).astype(np.float32) + with open_ome_zarr(path, layout="hcs", mode="w-", channel_names=["Phase3D", "Nuclei"]) as plate: + for fov in SLAB_PLANES: + data = np.zeros((2, 2, 7, 8, 8), np.float32) + data[:, 0] = stamp[:, :, None, None] + plate.create_position(*fov.split("/")).create_image("0", data) + + +def _predict_planes(source, output, planes_file=None, settings=None) -> HCSDataModule: + data_module = HCSDataModule( + data_path=str(source), + source_channel=["Phase3D"], + target_channel=["Nuclei"], + z_window_size=1, + batch_size=2, + num_workers=0, + yx_patch_size=[8, 8], + normalizations=[], + augmentations=[], + predict_z_planes=planes_file, + ) + Trainer( + accelerator="cpu", + logger=False, + enable_progress_bar=False, + callbacks=[HCSPredictionWriter(str(output), overwrite=True, settings_sha256_12=settings)], + ).predict(_SourcePlusOneModule(), datamodule=data_module, return_predictions=False) + return data_module + + +def test_plane_restricted_predict_writes_only_the_listed_planes(tmp_path): + """Listed planes equal a full predict's; the rest stay at the fill value; the store keeps the full extent.""" + source = tmp_path / "source.zarr" + _write_stamped_source(source) + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": SLAB_PLANES, "focus_anchor": "test"})) + + full, slab = tmp_path / "full.zarr", tmp_path / "slab.zarr" + _predict_planes(source, full) + data_module = _predict_planes(source, slab, planes_file) + assert len(data_module.predict_dataset) == sum(len(p) for per_t in SLAB_PLANES.values() for p in per_t) + + with open_ome_zarr(full, mode="r") as full_plate, open_ome_zarr(slab, mode="r") as slab_plate: + for fov, per_t in SLAB_PLANES.items(): + full_arr = np.asarray(full_plate[fov]["0"][:, 0]) + slab_arr = np.asarray(slab_plate[fov]["0"][:, 0]) + assert slab_arr.shape == full_arr.shape == (2, 7, 8, 8) + for t, planes in enumerate(per_t): + others = [z for z in range(7) if z not in planes] + np.testing.assert_array_equal(slab_arr[t, planes], full_arr[t, planes]) + assert (slab_arr[t, others] == 0).all() + # Discriminates: the full store holds a prediction on those planes. + assert (full_arr[t, others] > 0).all() + position = slab_plate[fov] + assert predicted_z_planes(position, "Nuclei_prediction") == per_t + marker = position.zattrs[PREDICTION_COMPLETE_KEY]["Nuclei_prediction"] + assert marker["z_planes_sha256_12"] == z_planes_sha256_12(SLAB_PLANES) + assert marker["source_shape"] == [2, 7, 8, 8] + assert PREDICTED_Z_PLANES_KEY not in full_plate[fov].zattrs + assert "z_planes_sha256_12" not in full_plate[fov].zattrs[PREDICTION_COMPLETE_KEY]["Nuclei_prediction"] + + +def test_full_predict_over_a_plane_store_clears_the_recorded_planes(tmp_path): + """Re-predicting every plane into a plane-restricted store drops its plane record.""" + source = tmp_path / "source.zarr" + _write_stamped_source(source) + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": SLAB_PLANES})) + output = tmp_path / "pred.zarr" + _predict_planes(source, output, planes_file) + _predict_planes(source, output) + with open_ome_zarr(output, mode="r") as plate: + for fov in SLAB_PLANES: + assert predicted_z_planes(plate[fov], "Nuclei_prediction") is None + assert (np.asarray(plate[fov]["0"][:, 0]) > 0).all() + + +def test_plane_restricted_predict_refuses_a_store_holding_other_planes(tmp_path): + """A slab predict over another run's planes would leave them around the slab; over its own slab it may replace.""" + source = tmp_path / "source.zarr" + _write_stamped_source(source) + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": SLAB_PLANES})) + full = tmp_path / "full.zarr" + _predict_planes(source, full) + with pytest.raises(ValueError, match="other planes"): + _predict_planes(source, full, planes_file) + slab = tmp_path / "slab.zarr" + _predict_planes(source, slab, planes_file, settings="aaaaaaaaaaaa") + # The same slab from other weights or settings (e.g. --ckpt best) rewrites every plane it holds. + _predict_planes(source, slab, planes_file, settings="bbbbbbbbbbbb") + other_planes = tmp_path / "other_planes.json" + other_planes.write_text(json.dumps({"positions": {fov: [[2], [6]] for fov in SLAB_PLANES}})) + with pytest.raises(ValueError, match="other planes"): + _predict_planes(source, slab, other_planes, settings="cccccccccccc") + + +def test_an_interrupted_plane_predict_is_unfinished(tmp_path): + """Until a plane run completes its marker has no source_shape, whatever planes are recorded.""" + source = tmp_path / "source.zarr" + _write_stamped_source(source) + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": SLAB_PLANES})) + full, slab = tmp_path / "full.zarr", tmp_path / "slab.zarr" + _predict_planes(source, full) + _predict_planes(source, slab, planes_file) + with open_ome_zarr(full, mode="r") as full_plate, open_ome_zarr(slab, mode="r+") as slab_plate: + assert not plane_predict_unfinished(full_plate["0/0/0"], "Nuclei_prediction") + position = slab_plate["0/0/0"] + assert not plane_predict_unfinished(position, "Nuclei_prediction") + marker = position.zattrs[PREDICTION_COMPLETE_KEY]["Nuclei_prediction"] + # An interrupted re-predict of the same slab: started marker, the old planes still recorded. + mark_started(position, ["Nuclei_prediction"], {k: v for k, v in marker.items() if k != "source_shape"}) + assert predicted_z_planes(position, "Nuclei_prediction") == SLAB_PLANES["0/0/0"] + assert plane_predict_unfinished(position, "Nuclei_prediction") + # An interrupted first predict: started marker, no planes yet. + record_z_planes(position, ["Nuclei_prediction"], None) + assert plane_predict_unfinished(position, "Nuclei_prediction") + + +def test_record_z_planes_keeps_other_channels(tmp_path): + with open_ome_zarr(tmp_path / "p.zarr", layout="hcs", mode="w-", channel_names=["A", "B"]) as plate: + position = plate.create_position("0", "0", "0") + record_z_planes(position, ["A", "B"], [[3, 2, 2]]) + record_z_planes(position, ["A"], None) + assert predicted_z_planes(position, "A") is None + assert predicted_z_planes(position, "B") == [[2, 3]] + record_z_planes(position, ["B"], None) + assert PREDICTED_Z_PLANES_KEY not in position.zattrs + + +@pytest.mark.parametrize( + ("planes", "match"), + [ + ({"0/0/0": [[0]]}, "timepoints"), + ({"0/0/0": [[0], [7]], "0/0/1": [[0], [0]]}, "outside"), + ({"0/0/0": [[0], [0]]}, "no entry"), + ], +) +def test_plane_file_must_cover_every_position_and_timepoint(tmp_path, planes, match): + source = tmp_path / "source.zarr" + _write_stamped_source(source) + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": planes})) + with pytest.raises((ValueError, KeyError), match=match): + _predict_planes(source, tmp_path / "pred.zarr", planes_file) + + +def test_plane_restriction_needs_single_plane_windows(tmp_path): + source = tmp_path / "source.zarr" + _write_stamped_source(source) + planes_file = tmp_path / "planes.json" + planes_file.write_text(json.dumps({"positions": SLAB_PLANES})) + data_module = _ones_data_module(source) + data_module.predict_z_planes = planes_file + with pytest.raises(ValueError, match="z_window_size=1"): + data_module.setup("predict") + + +def test_writer_rejects_unknown_z_reduction(): + """An unknown reduction fails at construction, not silently mid-predict.""" + with pytest.raises(ValueError, match="z_reduction"): + HCSPredictionWriter("unused.zarr", z_reduction="mean") # type: ignore[arg-type] def test_blend_in_consistency(): @@ -25,6 +970,17 @@ def test_blend_in_consistency(): np.testing.assert_allclose(result_np, result_torch.squeeze(0).numpy(), rtol=1e-5, atol=1e-5) +def test_blend_factors_cached_under_inference_mode_serve_autograd(): + """Factors first cached inside inference mode (predict) still work in a later blend that records gradients.""" + _torch_factors.cache_clear() # an earlier test may have cached these factors outside inference mode + old = torch.ones(1, 2, 5, 8, 8, dtype=torch.float64) + with torch.inference_mode(): + _blend_in(old, old, slice(3, 8)) + new = torch.zeros(1, 2, 5, 8, 8, dtype=torch.float64, requires_grad=True) + _blend_in(old, new, slice(3, 8)).sum().backward() + assert new.grad is not None + + def test_blend_in_zero_start(): """Verify _blend_in returns new_stack unchanged when z_slice starts at 0.""" old = np.ones((2, 5, 8, 8), dtype=np.float32) From 7d60be268e09079fd7e179d9b211ff61d92c8a7e Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:47:55 -0700 Subject: [PATCH 04/39] feat(viscy-utils): guard every fit with checkpoint and health callbacks Port the training guardrails from dynacell-models. VisCyCLI now injects, on every `fit`: - a latest-weights ModelCheckpoint that owns last.ckpt (the monitored checkpoint gets save_last=False), so last.ckpt always means the latest epoch, inheriting the leaf's write cadence; - MonitorHealthCheck, which raises when the monitored metric is non-finite or bit-identical for 10 validation epochs; - OptimizerHealthCheck, which raises after 5 consecutive non-finite losses or on GradScaler collapse. Also adds the opt-in LinearLRCooldown callback and raises the CLI subprocess test timeout to 120 s (it timed out on main). Behavior change: this applies to cytoland, dynaclr and dynacell fits. Source on dynacell-models: 20e54db4, daa74776, bc0f444c, 74fa18a4 (#497), 92693ae6, 2f9ef319 (#528). Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_utils/callbacks/__init__.py | 8 + .../callbacks/linear_lr_cooldown.py | 86 +++++ .../callbacks/linear_lr_cooldown_test.py | 112 +++++++ .../viscy_utils/callbacks/monitor_health.py | 99 ++++++ .../callbacks/monitor_health_test.py | 296 ++++++++++++++++++ .../viscy_utils/callbacks/optimizer_health.py | 159 ++++++++++ .../callbacks/optimizer_health_test.py | 232 ++++++++++++++ packages/viscy-utils/src/viscy_utils/cli.py | 150 +++++++++ packages/viscy-utils/tests/test_cli.py | 4 +- 9 files changed, 1145 insertions(+), 1 deletion(-) create mode 100644 packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown.py create mode 100644 packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown_test.py create mode 100644 packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py create mode 100644 packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py create mode 100644 packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py create mode 100644 packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py b/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py index 6e41540e4..aba381445 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py @@ -1,11 +1,19 @@ from viscy_utils.callbacks.embedding_snapshot import EmbeddingSnapshotCallback from viscy_utils.callbacks.embedding_writer import EmbeddingWriter +from viscy_utils.callbacks.linear_lr_cooldown import LinearLRCooldown +from viscy_utils.callbacks.monitor_health import MonitorHealthCheck, MonitorHealthError from viscy_utils.callbacks.online_eval import OnlineEvalCallback +from viscy_utils.callbacks.optimizer_health import OptimizerHealthCheck, OptimizerHealthError from viscy_utils.callbacks.prediction_writer import HCSPredictionWriter __all__ = [ "EmbeddingSnapshotCallback", "EmbeddingWriter", + "LinearLRCooldown", + "MonitorHealthCheck", + "MonitorHealthError", "OnlineEvalCallback", + "OptimizerHealthCheck", + "OptimizerHealthError", "HCSPredictionWriter", ] diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown.py b/packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown.py new file mode 100644 index 000000000..92ba9641b --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown.py @@ -0,0 +1,86 @@ +"""Linear learning-rate cooldown over a fixed range of optimizer steps.""" + +from lightning.pytorch import Callback, LightningModule, Trainer +from torch.optim.lr_scheduler import ConstantLR + + +class LinearLRCooldown(Callback): + """Ramp every param group's LR linearly from its initial value to a final fraction. + + Before each training batch, sets ``lr = initial_lr * factor`` with + ``factor`` equal to 1 before ``start_step``, decreasing linearly to + ``final_factor`` at ``end_step``, and ``final_factor`` afterwards. The + factor depends only on ``trainer.global_step``, so the callback holds no + state and a resumed fit continues the ramp where the checkpoint left it. + + Only valid on top of a constant schedule: the module must have exactly one + optimizer whose scheduler is ``ConstantLR`` with ``factor=1`` (the + ``"Constant"`` schedule of + :func:`viscy_utils.optimizers.configure_adamw_scheduler`), which never + changes the LR. Any other scheduler would overwrite or compound the + cooldown. List this callback before ``LearningRateMonitor`` so the logged + LR is the applied one. + + Parameters + ---------- + start_step : int + Global step at which the cooldown begins (factor 1 at this step). + end_step : int + Global step at which the factor reaches ``final_factor``. + final_factor : float, default 0.0 + LR multiplier at and after ``end_step``, in ``[0, 1]``. + """ + + def __init__(self, start_step: int, end_step: int, final_factor: float = 0.0) -> None: + if not 0 <= start_step < end_step: + raise ValueError(f"Need 0 <= start_step < end_step, got start_step={start_step}, end_step={end_step}") + if not 0.0 <= final_factor <= 1.0: + raise ValueError(f"final_factor must be in [0, 1], got {final_factor}") + self.start_step = start_step + self.end_step = end_step + self.final_factor = final_factor + + def factor(self, step: int) -> float: + """Return the LR multiplier for a global step. + + Parameters + ---------- + step : int + Global optimizer step. + + Returns + ------- + float + LR multiplier. + """ + if step <= self.start_step: + return 1.0 + if step >= self.end_step: + return self.final_factor + progress = (step - self.start_step) / (self.end_step - self.start_step) + return 1.0 - (1.0 - self.final_factor) * progress + + def on_fit_start(self, trainer: Trainer, pl_module: LightningModule) -> None: + """Check that the LR is otherwise constant, so the cooldown is the only LR change.""" + if len(trainer.optimizers) != 1: + raise ValueError(f"LinearLRCooldown needs exactly one optimizer, got {len(trainer.optimizers)}") + configs = trainer.lr_scheduler_configs + if len(configs) != 1: + raise ValueError(f"LinearLRCooldown needs exactly one ConstantLR scheduler, got {len(configs)} schedulers") + scheduler = configs[0].scheduler + if not isinstance(scheduler, ConstantLR) or scheduler.factor != 1: + raise ValueError( + "LinearLRCooldown needs the constant schedule (ConstantLR with factor=1, schedule='Constant'); " + f"got {type(scheduler).__name__}, which would overwrite or compound the cooldown" + ) + + def on_train_batch_start(self, trainer: Trainer, pl_module: LightningModule, batch, batch_idx: int) -> None: + """Set each param group's LR for the step about to run.""" + factor = self.factor(trainer.global_step) + for group in trainer.optimizers[0].param_groups: + if "initial_lr" not in group: + raise KeyError( + "LinearLRCooldown needs param_group['initial_lr'], which torch LR schedulers set; " + "the optimizer has no scheduler attached" + ) + group["lr"] = group["initial_lr"] * factor diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown_test.py b/packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown_test.py new file mode 100644 index 000000000..eb48565ec --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown_test.py @@ -0,0 +1,112 @@ +"""Tests for the linear LR cooldown callback. + +Runs a real ``Trainer`` with the shared ``configure_adamw_scheduler`` so the +epoch-interval ``ConstantLR`` steps between epochs exactly as in a Constant- +schedule fit; the module records the LR its optimizer step actually uses. +""" + +import pytest +import torch +from lightning.pytorch import LightningModule, Trainer +from torch.utils.data import DataLoader, TensorDataset + +from viscy_utils.callbacks import LinearLRCooldown +from viscy_utils.optimizers import configure_adamw_scheduler + +LR = 0.1 +START, END, FINAL = 4, 12, 0.25 +MAX_STEPS = 16 + + +class _RecordingModule(LightningModule): + """Tiny regression module that records the LR applied at each global step.""" + + def __init__(self, schedule: str = "Constant") -> None: + super().__init__() + self.layer = torch.nn.Linear(2, 1) + self.schedule = schedule + self.applied_lr: dict[int, float] = {} + + def training_step(self, batch, batch_idx): + self.applied_lr[self.trainer.global_step] = self.trainer.optimizers[0].param_groups[0]["lr"] + x, y = batch + return torch.nn.functional.mse_loss(self.layer(x), y) + + def configure_optimizers(self): + return configure_adamw_scheduler(self, self.layer, LR, self.schedule, warmup_steps=2) + + +def _loader() -> DataLoader: + # 4 batches per epoch, so the ramp crosses several epoch-end scheduler steps. + return DataLoader(TensorDataset(torch.randn(4, 2), torch.randn(4, 1)), batch_size=1) + + +def _trainer(max_steps: int) -> Trainer: + return Trainer( + max_steps=max_steps, + accelerator="cpu", + devices=1, + logger=False, + enable_progress_bar=False, + enable_model_summary=False, + enable_checkpointing=False, + callbacks=[LinearLRCooldown(START, END, FINAL)], + ) + + +def _expected(step: int) -> float: + if step <= START: + return LR + if step >= END: + return LR * FINAL + return LR * (1 - (1 - FINAL) * (step - START) / (END - START)) + + +def test_cooldown_applies_the_linear_ramp(): + """Applied LR is initial_lr up to start, linear to its final fraction at end, constant after.""" + module = _RecordingModule() + _trainer(MAX_STEPS).fit(module, _loader()) + assert sorted(module.applied_lr) == list(range(MAX_STEPS)) + mid = (START + END) // 2 + assert module.applied_lr[START - 1] == pytest.approx(LR) + assert module.applied_lr[START] == pytest.approx(LR) + assert module.applied_lr[mid] == pytest.approx(LR * (1 + FINAL) / 2) + assert module.applied_lr[END] == pytest.approx(LR * FINAL) + assert module.applied_lr[MAX_STEPS - 1] == pytest.approx(LR * FINAL) + for step, lr in module.applied_lr.items(): + assert lr == pytest.approx(_expected(step)), step + + +def test_cooldown_continues_after_resume_from_mid_ramp(tmp_path): + """A fit resumed mid-cooldown continues the ramp from the restored global step.""" + first = _RecordingModule() + resume_step = 7 + trainer = _trainer(resume_step) + trainer.fit(first, _loader()) + ckpt = tmp_path / "mid.ckpt" + trainer.save_checkpoint(ckpt) + + resumed = _RecordingModule() + _trainer(MAX_STEPS).fit(resumed, _loader(), ckpt_path=ckpt) + # The resumed fit starts at the checkpoint's global step, not at 0, and + # the ramp picks up from there rather than restarting. + assert sorted(resumed.applied_lr) == list(range(resume_step, MAX_STEPS)) + for step, lr in resumed.applied_lr.items(): + assert lr == pytest.approx(_expected(step)), step + assert resumed.applied_lr[resume_step] < LR + + +def test_cooldown_rejects_a_stepwise_scheduler(): + """A step-wise scheduler would overwrite the cooldown, so fit refuses to start.""" + with pytest.raises(ValueError, match="ConstantLR"): + _trainer(MAX_STEPS).fit(_RecordingModule(schedule="WarmupCosine"), _loader()) + + +@pytest.mark.parametrize( + ("start", "end", "final"), + [(5, 5, 0.0), (6, 5, 0.0), (-1, 5, 0.0), (0, 5, -0.1), (0, 5, 1.5)], +) +def test_cooldown_rejects_invalid_arguments(start, end, final): + """Out-of-range steps or final_factor raise at construction.""" + with pytest.raises(ValueError): + LinearLRCooldown(start, end, final) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py new file mode 100644 index 000000000..66cb77705 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py @@ -0,0 +1,99 @@ +"""Fail loud when a checkpoint monitor stops carrying signal. + +``ModelCheckpoint(monitor=..., save_top_k=k)`` only writes a checkpoint while +``save_top_k`` is unfilled or when the monitored metric improves. A monitor that +goes non-finite or freezes therefore *silently* starves checkpointing: the first +``k`` epochs save, and nothing after that — including ``last.ckpt``, which +Lightning writes on the same cadence. A 200-epoch run can complete with +``Trainer.fit stopped: max_epochs=200 reached``, exit code 0, and no weights +newer than epoch ``k``. + +This callback converts that silent waste into an early crash, in keeping with +the repo's prefer-raising philosophy. It observes only ``trainer.callback_metrics`` +and never touches checkpoint selection, so adding it cannot change which +checkpoint a run considers "best". +""" + +from collections import deque + +import torch +from lightning.pytorch import Callback, LightningModule, Trainer + +__all__ = ["MonitorHealthCheck", "MonitorHealthError"] + + +class MonitorHealthError(RuntimeError): + """Raised when a checkpoint monitor is non-finite or frozen.""" + + +class MonitorHealthCheck(Callback): + """Raise when the checkpoint monitor goes non-finite or stops varying. + + Parameters + ---------- + monitor : str + Metric key to watch, matching the ``monitor`` of the run's monitored + ``ModelCheckpoint`` (e.g. ``loss/validate``). + patience : int + Number of consecutive validation epochs that must share one identical + value before the run is declared frozen. Must be >= 2. + check_finite : bool + Raise as soon as the monitored value is NaN or infinite. + + Raises + ------ + MonitorHealthError + When the monitored metric is non-finite, or has been bit-identical for + ``patience`` consecutive validation epochs. + + Notes + ----- + Sanity-check validation is skipped: it runs before training and would + contribute a value from untrained weights. + + A missing monitor key is *not* an error here. Lightning's own + ``ModelCheckpoint`` already raises for that case, and the metric can be + legitimately absent on the first epochs of some schedules. + """ + + def __init__(self, monitor: str = "loss/validate", patience: int = 10, check_finite: bool = True) -> None: + if patience < 2: + raise ValueError(f"patience must be >= 2 to detect a frozen metric, got {patience}") + self.monitor = monitor + self.patience = patience + self.check_finite = check_finite + self._recent: deque[float] = deque(maxlen=patience) + + def on_validation_end(self, trainer: Trainer, pl_module: LightningModule) -> None: + """Inspect metrics after the module's ``on_validation_epoch_end`` logs them.""" + if trainer.sanity_checking: + return + value = trainer.callback_metrics.get(self.monitor) + if value is None: + return + scalar = value.item() if isinstance(value, torch.Tensor) else float(value) + + if self.check_finite and not torch.isfinite(torch.tensor(scalar)): + raise MonitorHealthError( + f"checkpoint monitor {self.monitor!r} is non-finite ({scalar}) at epoch {trainer.current_epoch}. " + "ModelCheckpoint cannot rank a non-finite metric, so no further checkpoint will be written " + "and this run would finish with no usable weights." + ) + + self._recent.append(scalar) + if len(self._recent) == self.patience and len(set(self._recent)) == 1: + raise MonitorHealthError( + f"checkpoint monitor {self.monitor!r} has been identical ({scalar}) for {self.patience} " + f"consecutive validation epochs, through epoch {trainer.current_epoch}. A metric that does not " + "respond to the weights starves ModelCheckpoint: save_top_k fills once and nothing is saved " + "afterwards, so the run would end with no weights newer than the first few epochs. " + "Check that the validation loss actually depends on the model output." + ) + + def state_dict(self) -> dict: + """Persist the recent-value window so a resumed run keeps its history.""" + return {"recent": list(self._recent)} + + def load_state_dict(self, state_dict: dict) -> None: + """Restore the recent-value window from a checkpoint.""" + self._recent = deque(state_dict.get("recent", []), maxlen=self.patience) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py new file mode 100644 index 000000000..2e227114e --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py @@ -0,0 +1,296 @@ +"""Tests for the checkpoint-monitor guardrails. + +Runs a real ``Trainer`` so the interaction with Lightning's ``ModelCheckpoint`` +is exercised, not mocked — the failure this guards against (top-k fills, then +nothing is saved) only appears through the real callback. +""" + +import pytest +import torch +from jsonargparse import Namespace +from lightning.pytorch import LightningModule, Trainer +from lightning.pytorch.callbacks import ModelCheckpoint +from torch.utils.data import DataLoader, TensorDataset + +from viscy_utils.callbacks import MonitorHealthCheck, MonitorHealthError +from viscy_utils.cli import _inject_checkpoint_guardrails + + +class _ValLossModule(LightningModule): + """Minimal module that logs a scripted ``loss/validate`` sequence.""" + + def __init__(self, val_values: list[float]): + super().__init__() + self.val_values = val_values + self.layer = torch.nn.Linear(2, 1) + + def training_step(self, batch, batch_idx): + x, y = batch + return torch.nn.functional.mse_loss(self.layer(x), y) + + def on_validation_epoch_end(self): + # Index defensively: sanity-check validation also fires this hook. + idx = min(self.current_epoch, len(self.val_values) - 1) + self.log("loss/validate", torch.tensor(self.val_values[idx])) + + def validation_step(self, batch, batch_idx): + return None + + def configure_optimizers(self): + return torch.optim.SGD(self.parameters(), lr=0.01) + + +def _loaders() -> tuple[DataLoader, DataLoader]: + ds = TensorDataset(torch.randn(8, 2), torch.randn(8, 1)) + return DataLoader(ds, batch_size=4), DataLoader(ds, batch_size=4) + + +def _run(val_values: list[float], tmp_path, patience: int = 3, max_epochs: int = 8) -> Trainer: + train_dl, val_dl = _loaders() + trainer = Trainer( + max_epochs=max_epochs, + accelerator="cpu", + logger=False, + enable_progress_bar=False, + enable_model_summary=False, + num_sanity_val_steps=0, + callbacks=[MonitorHealthCheck(monitor="loss/validate", patience=patience)], + default_root_dir=tmp_path, + ) + trainer.fit(_ValLossModule(val_values), train_dl, val_dl) + return trainer + + +def test_frozen_monitor_raises(tmp_path): + """A monitor that never changes must raise rather than train silently.""" + with pytest.raises(MonitorHealthError, match="identical"): + _run([1.024275] * 3, tmp_path, patience=3, max_epochs=3) + + +def test_non_finite_monitor_raises(tmp_path): + """A NaN monitor must raise immediately.""" + with pytest.raises(MonitorHealthError, match="non-finite"): + _run([0.9, float("nan")], tmp_path, patience=3, max_epochs=2) + + +def test_improving_monitor_does_not_raise(tmp_path): + """A normally-varying monitor must train to completion untouched.""" + trainer = _run([0.9, 0.8, 0.7, 0.65, 0.6, 0.55, 0.5, 0.45], tmp_path, patience=3) + assert trainer.current_epoch == 8 + health = next(callback for callback in trainer.callbacks if isinstance(callback, MonitorHealthCheck)) + assert health.state_dict()["recent"] == pytest.approx([0.55, 0.5, 0.45]) + + +def test_brief_plateau_below_patience_does_not_raise(tmp_path): + """A plateau shorter than ``patience`` is normal training, not a dead metric.""" + trainer = _run([0.9, 0.8, 0.8, 0.7, 0.7, 0.6, 0.55, 0.5], tmp_path, patience=3) + assert trainer.current_epoch == 8 + + +def test_frozen_monitor_starves_real_model_checkpoint(tmp_path): + """Reproduce the underlying failure: a frozen monitor stops all saving. + + Without the guardrail a constant monitor fills ``save_top_k`` and then writes + nothing more — including ``last.ckpt``. This is the regression the injected + unmonitored checkpoint exists to prevent. + """ + train_dl, val_dl = _loaders() + ckpt_dir = tmp_path / "checkpoints" + monitored = ModelCheckpoint( + dirpath=ckpt_dir, + monitor="loss/validate", + filename="epoch={epoch}-step={step}", + auto_insert_metric_name=False, + every_n_epochs=1, + save_top_k=2, + save_last=True, + ) + trainer = Trainer( + max_epochs=8, + accelerator="cpu", + logger=False, + enable_progress_bar=False, + enable_model_summary=False, + num_sanity_val_steps=0, + callbacks=[monitored], + default_root_dir=tmp_path, + ) + trainer.fit(_ValLossModule([1.024275] * 8), train_dl, val_dl) + + saved = sorted(p.name for p in ckpt_dir.glob("epoch=*.ckpt")) + # save_top_k=2 fills at epochs 0 and 1; the constant metric never improves after. + assert len(saved) == 2, saved + assert saved == ["epoch=0-step=2.ckpt", "epoch=1-step=4.ckpt"], saved + # 8 epochs ran, yet nothing newer than epoch 1 exists. + assert trainer.current_epoch == 8 + + +def test_injection_adds_guardrails_and_preserves_selection(): + """Injection must add the safety net without touching selection semantics.""" + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace( + monitor="loss/validate", + mode="min", + filename="epoch={epoch}-step={step}-loss={loss/validate:.3f}", + save_top_k=5, + every_n_epochs=1, + save_last=True, + dirpath="/models/run/checkpoints", + ), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "fit") + callbacks = config["fit"]["trainer"]["callbacks"] + + assert len(callbacks) == 3 + # Selection semantics of the monitored callback are untouched... + assert monitored["init_args"]["monitor"] == "loss/validate" + assert monitored["init_args"]["mode"] == "min" + assert monitored["init_args"]["save_top_k"] == 5 + assert monitored["init_args"]["filename"] == "epoch={epoch}-step={step}-loss={loss/validate:.3f}" + # ...except that it no longer owns `last.ckpt`. + assert monitored["init_args"]["save_last"] is False + + latest = callbacks[1] + assert latest["class_path"] == "lightning.pytorch.callbacks.ModelCheckpoint" + assert latest["init_args"]["monitor"] is None + assert latest["init_args"]["save_last"] is True + assert latest["init_args"]["save_top_k"] == 1 + assert latest["init_args"]["dirpath"] == "/models/run/checkpoints" + + health = callbacks[2] + assert health["class_path"] == "viscy_utils.callbacks.MonitorHealthCheck" + assert health["init_args"]["monitor"] == "loss/validate" + + +def test_injection_is_idempotent(): + """Re-running injection must not stack duplicate callbacks.""" + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace(monitor="loss/validate", save_top_k=5, save_last=True, dirpath="/d"), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "fit") + _inject_checkpoint_guardrails(config, "fit") + assert len(config["fit"]["trainer"]["callbacks"]) == 3 + + +def test_injection_skips_non_fit_subcommands(): + """predict/test/validate write no checkpoints, so nothing is injected.""" + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace(monitor="loss/validate", save_last=True, dirpath="/d"), + ) + config = Namespace(predict=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "predict") + assert len(config["predict"]["trainer"]["callbacks"]) == 1 + assert monitored["init_args"]["save_last"] is True + + +def test_injection_noop_without_monitored_checkpoint(): + """A config with no monitored checkpoint gets no health check to attach to.""" + unmonitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace(monitor=None, save_last=True, dirpath="/d"), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[unmonitored]))) + _inject_checkpoint_guardrails(config, "fit") + assert len(config["fit"]["trainer"]["callbacks"]) == 1 + + +def test_patience_below_two_rejected(): + """patience<2 cannot distinguish a frozen metric from a single sample.""" + with pytest.raises(ValueError, match="patience must be >= 2"): + MonitorHealthCheck(patience=1) + + +def test_injection_inherits_the_leaf_write_cadence(): + """The latest-weights callback must not force ``every_n_epochs: 1``. + + A leaf that sets ``every_n_epochs`` is making a deliberate write-amplification + decision. The Phase 17 arms run ~1470 epochs with a 424 MB checkpoint and set + cadence 10 for exactly that reason; forcing 1 here rewrote both ``latest-*`` + and ``last.ckpt`` every epoch (~1.25 TB of NFS traffic per arm) and silently + overrode the leaf. + """ + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace( + monitor="loss/validate", + save_top_k=4, + every_n_epochs=10, + save_last=True, + dirpath="/models/run/checkpoints", + ), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "fit") + + latest = config["fit"]["trainer"]["callbacks"][1] + assert latest["init_args"]["every_n_epochs"] == 10 + # The monitored callback's own cadence is still its own. + assert monitored["init_args"]["every_n_epochs"] == 10 + + +def test_injection_defaults_cadence_to_every_epoch(): + """A leaf that sets no cadence keeps the original every-epoch behaviour.""" + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace(monitor="loss/validate", save_top_k=4, save_last=True, dirpath="/d"), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "fit") + + assert config["fit"]["trainer"]["callbacks"][1]["init_args"]["every_n_epochs"] == 1 + + +def test_inherited_cadence_still_saves_when_monitor_is_frozen(tmp_path): + """End-to-end: cadence 4 writes latest weights without writing every epoch. + + Runs the real Trainer with a frozen monitor, which starves the monitored + callback after ``save_top_k`` fills. The unmonitored callback must still + produce weights from the run's final epochs -- the guardrail's whole purpose -- + while writing 2 times over 8 epochs rather than 8. + """ + train_dl, val_dl = _loaders() + ckpt_dir = tmp_path / "checkpoints" + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace( + monitor="loss/validate", + filename="epoch={epoch}-step={step}", + auto_insert_metric_name=False, + save_top_k=2, + every_n_epochs=4, + save_last=True, + dirpath=str(ckpt_dir), + ), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "fit") + specs = config["fit"]["trainer"]["callbacks"] + + latest_cb = ModelCheckpoint(**{k: v for k, v in specs[1]["init_args"].items()}) + monitored_cb = ModelCheckpoint(**{k: v for k, v in specs[0]["init_args"].items()}) + trainer = Trainer( + max_epochs=8, + accelerator="cpu", + logger=False, + enable_progress_bar=False, + enable_model_summary=False, + num_sanity_val_steps=0, + callbacks=[monitored_cb, latest_cb], + default_root_dir=tmp_path, + ) + trainer.fit(_ValLossModule([1.024275] * 8), train_dl, val_dl) + + # Lightning fires every_n_epochs when (epoch + 1) % n == 0, so this writes at + # epochs 3 and 7 only -- 2 of 8 epochs -- and save_top_k=1 keeps just the last. + latest = sorted(p.name for p in ckpt_dir.glob("latest-*.ckpt")) + assert latest == ["latest-epoch=7-step=16.ckpt"], latest + assert (ckpt_dir / "last.ckpt").is_file() + # The point of the guardrail: weights exist from the END of the run (epoch 7 is + # the last of 8, zero-indexed), not just from where the frozen monitor stopped + # improving -- the monitored callback's top-k filled at epochs 3 and never moved. + assert torch.load(ckpt_dir / "last.ckpt", map_location="cpu", weights_only=False)["epoch"] == 7 diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py new file mode 100644 index 000000000..432e33439 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py @@ -0,0 +1,159 @@ +"""Fail loud when the optimizer stops making progress at the step level. + +Two failure modes hide from every loss-based monitor, and one of them cost 24 +GPU-days of FCMAE-2D fits that ran to ``epoch 199/200`` with the weights still at +their initialization: + +**Under ``16-mixed``, a NaN *gradient* is silent.** ``GradScaler`` inspects the +gradients, skips the optimizer step when any is non-finite, and halves its scale. +The forward pass — and therefore the logged loss — stays perfectly finite. From +``2**16`` it takes ~166 halvings to reach exactly ``0.0``, after which *every* +step is skipped forever: the weights never move again, and the run still exits 0. +The trigger there was a ``sqrt`` at 0 (infinite derivative) inside an SSIM +Cauchy-Schwarz bound, but any NaN-producing backward does the same thing. + +**Under ``bf16-mixed`` there is no scaler at all**, so a NaN gradient instead +lands in the weights and the loss goes non-finite on the next forward. That is +loud in a log, but nothing *stops* the run, so it burns the rest of its wall +allocation. + +This callback covers both: it watches the scaler's scale where there is one, and +the training loss everywhere. It only reads state — it never rescales, skips, or +touches optimization — so enabling it cannot change a healthy run's numerics. + +Related but distinct: :class:`~viscy_utils.callbacks.MonitorHealthCheck` watches +the *checkpoint monitor* once per validation epoch, so it catches the same freeze +only after ``patience`` epochs and cannot say why. This one trips within tens of +steps and names the mechanism. +""" + +from collections.abc import Mapping + +import torch +from lightning.pytorch import Callback, LightningModule, Trainer + +__all__ = ["OptimizerHealthCheck", "OptimizerHealthError"] + + +class OptimizerHealthError(RuntimeError): + """Raised when the optimizer can no longer be making progress.""" + + +class OptimizerHealthCheck(Callback): + """Raise when the AMP scale collapses or the training loss goes non-finite. + + Parameters + ---------- + min_scale : float + Raise once ``GradScaler``'s scale falls below this value. fp16 AMP + normally operates between ``2**10`` and ``2**16``; below ``1.0`` the + scaler is shrinking gradients rather than protecting them, which no + healthy run needs. Ignored when the run has no scaler. + max_consecutive_skips : int + Raise after this many consecutive scale *reductions* with no growth in + between. Each reduction is one skipped optimizer step. A handful during + AMP warmup is normal and expected; a couple of dozen in a row is not. + max_consecutive_nonfinite : int + Raise after this many consecutive training batches whose loss is NaN or + infinite. Small but non-zero so a single transient overflow does not kill + a run. + + Raises + ------ + OptimizerHealthError + When the scale falls below ``min_scale``, when ``max_consecutive_skips`` + steps are skipped in a row, or when the loss is non-finite for + ``max_consecutive_nonfinite`` batches in a row. + + Notes + ----- + Skips are counted only when the scale actually *changes*, so gradient + accumulation — which fires ``on_train_batch_end`` on batches where no + optimizer step happened, leaving the scale flat — cannot reset the counter + and mask a genuine run of skipped steps. + + The loss arm reads the value ``training_step`` returned rather than a logged + metric, so it also covers modules that do not log a training loss. Manual- + optimization modules that return ``None`` (e.g. the GAN engine) are skipped + by the loss arm; their scaler arm still applies. + """ + + def __init__( + self, + min_scale: float = 1.0, + max_consecutive_skips: int = 25, + max_consecutive_nonfinite: int = 5, + ) -> None: + if max_consecutive_skips < 1: + raise ValueError(f"max_consecutive_skips must be >= 1, got {max_consecutive_skips}") + if max_consecutive_nonfinite < 1: + raise ValueError(f"max_consecutive_nonfinite must be >= 1, got {max_consecutive_nonfinite}") + self.min_scale = min_scale + self.max_consecutive_skips = max_consecutive_skips + self.max_consecutive_nonfinite = max_consecutive_nonfinite + self._prev_scale: float | None = None + self._consecutive_skips = 0 + self._consecutive_nonfinite = 0 + + def on_train_batch_end(self, trainer: Trainer, pl_module: LightningModule, outputs, batch, batch_idx: int) -> None: + """Check the batch loss, then the AMP scale, after every training batch.""" + self._check_loss(outputs, trainer) + self._check_scale(trainer) + + def _check_loss(self, outputs, trainer: Trainer) -> None: + """Count consecutive non-finite training losses and raise past the limit.""" + loss = outputs.get("loss") if isinstance(outputs, Mapping) else outputs + if not isinstance(loss, torch.Tensor): + return + if torch.isfinite(loss).all(): + self._consecutive_nonfinite = 0 + return + self._consecutive_nonfinite += 1 + if self._consecutive_nonfinite >= self.max_consecutive_nonfinite: + raise OptimizerHealthError( + f"training loss has been non-finite for {self._consecutive_nonfinite} consecutive batches " + f"(step {trainer.global_step}, last value {loss.detach().float().mean().item()}). Without a " + "GradScaler to skip the step -- bf16-mixed and 32-true have none -- a NaN gradient lands in " + "the weights and every later forward is non-finite, so this run cannot recover." + ) + + def _check_scale(self, trainer: Trainer) -> None: + """Track the ``GradScaler`` scale and raise on collapse or sustained skips.""" + scaler = getattr(trainer.precision_plugin, "scaler", None) + if scaler is None: + return + scale = float(scaler.get_scale()) + prev = self._prev_scale + self._prev_scale = scale + if prev is not None and scale != prev: + # A reduction means GradScaler found a non-finite gradient and skipped + # the step; growth means a run of successful steps cleared the counter. + self._consecutive_skips = self._consecutive_skips + 1 if scale < prev else 0 + + if scale < self.min_scale: + raise OptimizerHealthError( + f"GradScaler scale collapsed to {scale} (floor {self.min_scale}) at step {trainer.global_step}. " + "The scale only halves when a gradient is non-finite, so the backward pass is producing NaN " + "or inf while the forward stays finite. At scale 0 every optimizer step is skipped forever " + "and the weights stay at their last value for the rest of the run." + ) + if self._consecutive_skips >= self.max_consecutive_skips: + raise OptimizerHealthError( + f"GradScaler halved its scale {self._consecutive_skips} times in a row without growing " + f"(now {scale}, step {trainer.global_step}). Every one of those optimizer steps was skipped " + "for a non-finite gradient, so the weights are not moving even though the loss is finite." + ) + + def state_dict(self) -> dict: + """Persist the counters so a resumed run does not restart mid-collapse.""" + return { + "prev_scale": self._prev_scale, + "consecutive_skips": self._consecutive_skips, + "consecutive_nonfinite": self._consecutive_nonfinite, + } + + def load_state_dict(self, state_dict: dict) -> None: + """Restore the counters from a checkpoint.""" + self._prev_scale = state_dict.get("prev_scale") + self._consecutive_skips = state_dict.get("consecutive_skips", 0) + self._consecutive_nonfinite = state_dict.get("consecutive_nonfinite", 0) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py new file mode 100644 index 000000000..c5d0a1023 --- /dev/null +++ b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py @@ -0,0 +1,232 @@ +"""Tests for the step-level optimizer guardrail. + +Runs a real ``Trainer`` throughout: the failure being guarded against is produced +by the interaction of a NaN backward with Lightning's precision plugin, so a +mocked scaler would prove nothing. The NaN-gradient module reproduces the actual +mechanism that froze the 24 FCMAE-2D fits — a ``sqrt`` evaluated at exactly 0, +whose derivative is infinite, multiplied by a zero incoming gradient — rather +than asserting on a hand-set scale. +""" + +import pytest +import torch +from jsonargparse import Namespace +from lightning.pytorch import LightningModule, Trainer +from torch.utils.data import DataLoader, TensorDataset + +from viscy_utils.callbacks import OptimizerHealthCheck, OptimizerHealthError +from viscy_utils.cli import _inject_optimizer_health_guard + +_needs_cuda = pytest.mark.skipif( + not torch.cuda.is_available(), + reason="fp16 AMP has no GradScaler on CPU -- Lightning silently downgrades 16-mixed to bf16-mixed", +) + + +class _HealthyModule(LightningModule): + """Ordinary regression module with finite gradients everywhere.""" + + def __init__(self) -> None: + super().__init__() + self.layer = torch.nn.Linear(2, 1) + + def training_step(self, batch, batch_idx): + x, y = batch + return torch.nn.functional.mse_loss(self.layer(x), y) + + def configure_optimizers(self): + return torch.optim.SGD(self.parameters(), lr=0.01) + + +class _NaNGradientModule(_HealthyModule): + """Finite forward, NaN backward — the FCMAE-2D freeze, minimally reproduced. + + ``sqrt`` at 0 has an infinite derivative, and the incoming gradient of the + zeroed product is 0, so autograd evaluates ``0 * inf = nan`` for the whole + term while its forward contribution is exactly 0. The loss therefore stays + finite and only the gradient is poisoned. + """ + + def training_step(self, batch, batch_idx): + x, y = batch + pred = self.layer(x) + bound = torch.sqrt(pred * torch.zeros_like(pred)) + loss = torch.nn.functional.mse_loss(pred, y) + bound.mean() + assert torch.isfinite(loss).all(), "forward must stay finite for this test to mean anything" + return loss + + +class _NaNLossModule(_HealthyModule): + """Loss itself goes non-finite, as it does under bf16 with no scaler.""" + + def training_step(self, batch, batch_idx): + x, y = batch + return torch.nn.functional.mse_loss(self.layer(x), y) * float("nan") + + +def _loader() -> DataLoader: + return DataLoader(TensorDataset(torch.randn(32, 2), torch.randn(32, 1)), batch_size=4) + + +def _run(module: LightningModule, tmp_path, precision: str, accelerator: str, **guard_kwargs) -> Trainer: + trainer = Trainer( + max_epochs=4, + accelerator=accelerator, + devices=1, + precision=precision, + logger=False, + enable_progress_bar=False, + enable_model_summary=False, + enable_checkpointing=False, + num_sanity_val_steps=0, + callbacks=[OptimizerHealthCheck(**guard_kwargs)], + default_root_dir=tmp_path, + ) + trainer.fit(module, _loader()) + return trainer + + +@_needs_cuda +def test_nan_gradient_under_fp16_raises_while_the_loss_stays_finite(tmp_path): + """The silent case: finite loss, skipped steps, scale walking to zero.""" + with pytest.raises(OptimizerHealthError, match="halved its scale|scale collapsed"): + _run( + _NaNGradientModule(), + tmp_path, + precision="16-mixed", + accelerator="gpu", + max_consecutive_skips=3, + ) + + +@_needs_cuda +def test_healthy_fp16_run_completes_untouched(tmp_path): + """A normal fp16 run must not trip the guard, and must actually move weights.""" + module = _HealthyModule() + before = module.layer.weight.detach().clone() + trainer = _run(module, tmp_path, precision="16-mixed", accelerator="gpu", max_consecutive_skips=3) + assert trainer.current_epoch == 4 + assert not torch.allclose(before, module.layer.weight.detach().cpu()) + + +def test_non_finite_loss_raises_without_a_scaler(tmp_path): + """bf16 has no GradScaler, so the loss arm is the only thing that can catch it.""" + with pytest.raises(OptimizerHealthError, match="non-finite for"): + _run( + _NaNLossModule(), + tmp_path, + precision="bf16-mixed", + accelerator="cpu", + max_consecutive_nonfinite=2, + ) + + +def test_healthy_bf16_run_completes_untouched(tmp_path): + """The precision the 2D FCMAE fits actually use must train to completion.""" + module = _HealthyModule() + before = module.layer.weight.detach().clone() + trainer = _run(module, tmp_path, precision="bf16-mixed", accelerator="cpu", max_consecutive_nonfinite=2) + assert trainer.current_epoch == 4 + assert not torch.allclose(before, module.layer.weight.detach()) + + +def test_transient_non_finite_loss_is_tolerated(tmp_path): + """One overflow must not kill a run — only a sustained streak may. + + The NaN is added as a *constant*, so its gradient contribution is zero and the + weights stay healthy. Multiplying the loss by NaN instead would send NaN into + the weights and every later batch would be non-finite too — which is precisely + why bf16 needs this guard, and which made an earlier version of this test fail + for the right reason. + """ + + class _OneNaNModule(_HealthyModule): + def training_step(self, batch, batch_idx): + x, y = batch + loss = torch.nn.functional.mse_loss(self.layer(x), y) + if self.global_step == 1: + return loss + torch.tensor(float("nan")) + return loss + + module = _OneNaNModule() + trainer = _run(module, tmp_path, precision="bf16-mixed", accelerator="cpu", max_consecutive_nonfinite=3) + assert trainer.current_epoch == 4 + assert torch.isfinite(module.layer.weight).all() + + +def test_accumulation_flat_scale_does_not_reset_the_skip_counter(): + """Batches with no optimizer step leave the scale flat; that must not clear skips. + + Under ``accumulate_grad_batches>1`` the scale only changes on real steps, so a + counter that reset on every unchanged sample would never reach its limit and + the guard would be dead exactly where the bug lives. + """ + guard = OptimizerHealthCheck(max_consecutive_skips=3, min_scale=0.0) + + class _FakeScaler: + def __init__(self) -> None: + self.scale = 65536.0 + + def get_scale(self) -> float: + return self.scale + + scaler = _FakeScaler() + trainer = Namespace(precision_plugin=Namespace(scaler=scaler), global_step=0) + + guard._check_scale(trainer) # first observation, nothing to compare against + for _ in range(2): + scaler.scale /= 2 # a skipped step + guard._check_scale(trainer) + guard._check_scale(trainer) # accumulation batch: scale unchanged + assert guard._consecutive_skips == 2 + + scaler.scale /= 2 + with pytest.raises(OptimizerHealthError, match="halved its scale 3 times"): + guard._check_scale(trainer) + + +def test_scale_growth_clears_the_skip_counter(): + """A successful growth interval means the run recovered; forget the earlier skips.""" + guard = OptimizerHealthCheck(max_consecutive_skips=3, min_scale=0.0) + + class _FakeScaler: + def __init__(self) -> None: + self.scale = 1024.0 + + def get_scale(self) -> float: + return self.scale + + scaler = _FakeScaler() + trainer = Namespace(precision_plugin=Namespace(scaler=scaler), global_step=0) + guard._check_scale(trainer) + scaler.scale /= 2 + guard._check_scale(trainer) + assert guard._consecutive_skips == 1 + scaler.scale *= 2 + guard._check_scale(trainer) + assert guard._consecutive_skips == 0 + + +def test_guard_is_injected_into_a_fit_config(): + """``fit`` configs get the guard even though leaves override ``trainer.callbacks``.""" + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[]))) + _inject_optimizer_health_guard(config, "fit") + paths = [callback.class_path for callback in config.fit.trainer.callbacks] + assert paths == ["viscy_utils.callbacks.OptimizerHealthCheck"] + + +def test_guard_injection_is_idempotent(): + """Re-running the injector (e.g. a resumed resolved config) must not duplicate it.""" + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[]))) + _inject_optimizer_health_guard(config, "fit") + _inject_optimizer_health_guard(config, "fit") + assert len(config.fit.trainer.callbacks) == 1 + + +@pytest.mark.parametrize("subcommand", ["predict", "validate", "test", None]) +def test_guard_is_not_injected_outside_fit(subcommand): + """Only training can stall this way; predict/validate configs stay untouched.""" + config = Namespace(**{str(subcommand): Namespace(trainer=Namespace(callbacks=[]))}) + _inject_optimizer_health_guard(config, subcommand) + root = config.get(str(subcommand)) + assert root.trainer.callbacks == [] diff --git a/packages/viscy-utils/src/viscy_utils/cli.py b/packages/viscy-utils/src/viscy_utils/cli.py index 8d9e6b2ad..4329daeff 100644 --- a/packages/viscy-utils/src/viscy_utils/cli.py +++ b/packages/viscy-utils/src/viscy_utils/cli.py @@ -69,6 +69,154 @@ def _configure_wandb_logger( init_args["group"] = base_name +_MODEL_CHECKPOINT_CLASS_PATH = "lightning.pytorch.callbacks.ModelCheckpoint" +_MONITOR_HEALTH_CLASS_PATH = "viscy_utils.callbacks.MonitorHealthCheck" +_OPTIMIZER_HEALTH_CLASS_PATH = "viscy_utils.callbacks.OptimizerHealthCheck" +_LATEST_CKPT_FILENAME = "latest-epoch={epoch}-step={step}" + + +def _callback_field(callback, key: str): + """Return ``key`` from a callback config entry (Namespace or dict), else None.""" + if isinstance(callback, Namespace): + return callback.get(key) + if isinstance(callback, dict): + return callback.get(key) + return None + + +def _init_args_of(callback) -> Namespace | dict | None: + """Return the mutable ``init_args`` mapping of a callback config entry.""" + init_args = _callback_field(callback, "init_args") + return init_args if isinstance(init_args, (Namespace, dict)) else None + + +def _inject_checkpoint_guardrails(config: Namespace, subcommand: str | None) -> None: + """Guarantee latest-weights checkpoints and fail loud on a dead monitor. + + A monitored ``ModelCheckpoint`` writes nothing once ``save_top_k`` is filled + and the metric stops improving — ``last.ckpt`` included, because Lightning + writes it on the same cadence. A frozen or non-finite monitor therefore ends + a run with no weights newer than the first few epochs. + + For ``fit`` only, this appends two callbacks to the resolved config: + + 1. an **unmonitored** ``ModelCheckpoint`` sharing the monitored callback's + ``dirpath`` and **inheriting its ``every_n_epochs``**, which saves on that + cadence unconditionally and owns ``save_last``; and + 2. a :class:`~viscy_utils.callbacks.MonitorHealthCheck` on the same metric. + + ``save_last`` is turned off on the monitored callback so exactly one callback + writes ``last.ckpt`` and that file means "latest weights" rather than "weights + at the last improvement". + + The monitored callback's ``monitor``, ``mode``, ``save_top_k`` and ``filename`` + are deliberately left untouched: those determine which checkpoint a run calls + "best", so changing them would alter model selection and invalidate + comparisons against already-published results. + + This runs on the resolved config rather than the shared trainer recipe because + leaves override ``trainer.callbacks`` wholesale to set their own ``dirpath`` — + a recipe-level default would be silently dropped by every real leaf. + """ + if subcommand != "fit": + return + root = config.get(subcommand) if subcommand is not None else config + if not isinstance(root, Namespace): + return + trainer = root.get("trainer") + if not isinstance(trainer, Namespace): + return + callbacks = trainer.get("callbacks") + if not isinstance(callbacks, list): + return + + # Idempotent: a config that already carries the guardrails is left alone. + for callback in callbacks: + if _callback_field(callback, "class_path") == _MONITOR_HEALTH_CLASS_PATH: + return + + monitored = None + for callback in callbacks: + if _callback_field(callback, "class_path") != _MODEL_CHECKPOINT_CLASS_PATH: + continue + init_args = _init_args_of(callback) + if init_args is not None and init_args.get("monitor"): + monitored = callback + break + if monitored is None: + return + + monitored_args = _init_args_of(monitored) + monitor = monitored_args.get("monitor") + dirpath = monitored_args.get("dirpath") + # Hand `last.ckpt` to the unconditional callback so it tracks the newest epoch. + monitored_args["save_last"] = False + + # Inherit the leaf's write cadence instead of forcing every epoch. A leaf that + # sets `every_n_epochs` is making a deliberate write-amplification decision: + # the Phase 17 arms run ~1470 epochs, so `every_n_epochs: 10` is the difference + # between 147 and 1470 write cycles of a 424 MB checkpoint, and this callback + # writes *two* files (`latest-*` and `last.ckpt`) each time -- ~1.25 TB of NFS + # traffic per arm at cadence 1. Forcing 1 here silently overrode that choice. + # + # The guarantee is unaffected: an *unmonitored* callback writes on its cadence + # unconditionally, so the newest weights are always within `every_n_epochs` + # epochs of where the run stopped, however dead the monitor is. Defaults to 1 + # when the leaf is silent, preserving the original behaviour. + every_n_epochs = monitored_args.get("every_n_epochs") or 1 + + latest_args = { + "monitor": None, + "filename": _LATEST_CKPT_FILENAME, + "auto_insert_metric_name": False, + "every_n_epochs": every_n_epochs, + "save_top_k": 1, + "save_last": True, + } + if dirpath is not None: + latest_args["dirpath"] = dirpath + callbacks.append(Namespace(class_path=_MODEL_CHECKPOINT_CLASS_PATH, init_args=Namespace(**latest_args))) + callbacks.append( + Namespace(class_path=_MONITOR_HEALTH_CLASS_PATH, init_args=Namespace(monitor=monitor, patience=10)) + ) + + +def _inject_optimizer_health_guard(config: Namespace, subcommand: str | None) -> None: + """Attach the step-level optimizer guard to every ``fit`` run. + + A NaN *gradient* is invisible to loss-based monitoring. Under ``16-mixed``, + ``GradScaler`` skips the step and halves its scale while the forward — and the + logged loss — stays finite; once the scale reaches ``0.0`` every step is + skipped for the rest of the run and the weights never move again. Under + ``bf16-mixed`` there is no scaler, so the NaN lands in the weights instead. + :class:`~viscy_utils.callbacks.OptimizerHealthCheck` covers both. + + Injected here rather than in the shared trainer recipe for the same reason as + the checkpoint guardrails: leaves override ``trainer.callbacks`` wholesale to + set their own ``dirpath``, so a recipe-level default is silently dropped by + every real leaf. Injecting on the resolved config makes it unforgettable. + + Unlike :func:`_inject_checkpoint_guardrails` this does not depend on the leaf + declaring a monitored ``ModelCheckpoint`` — a run with no checkpointing at all + can still stall — so it only requires a ``trainer.callbacks`` list to append to. + """ + if subcommand != "fit": + return + root = config.get(subcommand) if subcommand is not None else config + if not isinstance(root, Namespace): + return + trainer = root.get("trainer") + if not isinstance(trainer, Namespace): + return + callbacks = trainer.get("callbacks") + if not isinstance(callbacks, list): + return + for callback in callbacks: + if _callback_field(callback, "class_path") == _OPTIMIZER_HEALTH_CLASS_PATH: + return + callbacks.append(Namespace(class_path=_OPTIMIZER_HEALTH_CLASS_PATH)) + + class VisCyCLI(LightningCLI): """Extending lightning CLI arguments and defaults.""" @@ -127,6 +275,8 @@ def _parse_ckpt_path(self) -> None: def before_instantiate_classes(self) -> None: """Apply shared config rewrites before Lightning object creation.""" _configure_wandb_logger(self.config, self.subcommand) + _inject_checkpoint_guardrails(self.config, self.subcommand) + _inject_optimizer_health_guard(self.config, self.subcommand) def _setup_environment() -> None: diff --git a/packages/viscy-utils/tests/test_cli.py b/packages/viscy-utils/tests/test_cli.py index e8835f983..a086b535c 100644 --- a/packages/viscy-utils/tests/test_cli.py +++ b/packages/viscy-utils/tests/test_cli.py @@ -19,7 +19,9 @@ def _run(*args): [sys.executable, "-m", "viscy_utils.cli", *args], capture_output=True, text=True, - timeout=30, + # Hang guard only. A cold `--help` imports torch/lightning from NFS: + # 12-22 s serially, and past 30 s under `pytest -n 11`. + timeout=120, ) return _run From 13173137233a91c4f3a0d9269a3f99080a104f7d Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:04 -0700 Subject: [PATCH 05/39] fix(viscy-utils): keep SSIM finite under mixed precision; lazy exports Port two viscy-utils changes from dynacell-models: - ssim_25d / ms_ssim_25d: clamp the variances, bound the covariance by Cauchy-Schwarz through a safe sqrt, floor the stability constants symmetrically, and run MS-SSIM outside autocast, so 16-mixed training cannot NaN the gradient. SSIM/MS-SSIM loss values change slightly for MixedLoss users. - viscy_utils/__init__ resolves its exports lazily (PEP 562), keeping torch and lightning off light import paths. Source on dynacell-models: a556bc9d (#484), 130e0340, cee4e41f, 272051c0, 59343ad0, 8b2413f5 (#509). Co-Authored-By: Claude Opus 5.5 --- .../viscy-utils/src/viscy_utils/__init__.py | 42 ++-- .../src/viscy_utils/evaluation/metrics.py | 119 +++++++++-- .../viscy-utils/tests/test_lazy_imports.py | 38 ++++ packages/viscy-utils/tests/test_metrics.py | 200 +++++++++++++++++- 4 files changed, 370 insertions(+), 29 deletions(-) create mode 100644 packages/viscy-utils/tests/test_lazy_imports.py diff --git a/packages/viscy-utils/src/viscy_utils/__init__.py b/packages/viscy-utils/src/viscy_utils/__init__.py index c1ad90903..266c0387e 100644 --- a/packages/viscy-utils/src/viscy_utils/__init__.py +++ b/packages/viscy-utils/src/viscy_utils/__init__.py @@ -1,17 +1,27 @@ -from viscy_utils.log_images import detach_sample, render_images -from viscy_utils.mp_utils import get_val_stats, mp_wrapper -from viscy_utils.normalize import hist_clipping, unzscore, zscore -from viscy_utils.optimizers import configure_adamw_scheduler -from viscy_utils.tensor_utils import to_numpy +import importlib -__all__ = [ - "configure_adamw_scheduler", - "detach_sample", - "get_val_stats", - "hist_clipping", - "mp_wrapper", - "render_images", - "to_numpy", - "unzscore", - "zscore", -] +# Resolved lazily (PEP 562) so importing a light submodule such as +# ``viscy_utils.prediction_metadata`` does not pull in torch and lightning. +_LAZY_ATTRS = { + "configure_adamw_scheduler": "viscy_utils.optimizers", + "detach_sample": "viscy_utils.log_images", + "get_val_stats": "viscy_utils.mp_utils", + "hist_clipping": "viscy_utils.normalize", + "mp_wrapper": "viscy_utils.mp_utils", + "render_images": "viscy_utils.log_images", + "to_numpy": "viscy_utils.tensor_utils", + "unzscore": "viscy_utils.normalize", + "zscore": "viscy_utils.normalize", +} + +__all__ = list(_LAZY_ATTRS) + + +def __getattr__(name: str): + if name not in _LAZY_ATTRS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(importlib.import_module(_LAZY_ATTRS[name]), name) + + +def __dir__() -> list[str]: + return sorted([*globals(), *__all__]) diff --git a/packages/viscy-utils/src/viscy_utils/evaluation/metrics.py b/packages/viscy-utils/src/viscy_utils/evaluation/metrics.py index de293b5c8..4148db300 100644 --- a/packages/viscy-utils/src/viscy_utils/evaluation/metrics.py +++ b/packages/viscy-utils/src/viscy_utils/evaluation/metrics.py @@ -171,6 +171,42 @@ def mean_average_precision( return map_metric.compute() +# Floors the SSIM/CS denominators so a zero data_range (c1=c2=0) on a flat window +# cannot produce 0/0; negligible relative to the c1/c2 stability constants otherwise. +_SSIM_DENOM_EPS: float = 1e-8 + + +def _safe_sqrt(x: torch.Tensor) -> torch.Tensor: + """Square root whose derivative at exactly zero is 0 rather than infinite. + + ``torch.sqrt`` backward is ``grad_out * 0.5 / sqrt(x)``, which is ``inf`` at + ``x == 0``. For the elements whose incoming ``grad_out`` is *itself* zero that + evaluates to ``0 * inf == nan``, so a single zero entry poisons the gradient + of every element in its convolution window — the forward pass stays perfectly + finite while the backward pass silently fills with NaN. + + Guarding with ``clamp_min(eps)`` or ``sqrt(x + eps)`` does not fix this: both + perturb the forward value and still leave a ``1 / (2 * sqrt(eps))`` gradient + spike. Instead substitute a dummy positive value *before* the square root and + restore the true zero after, so the ``sqrt`` node never sees zero and its + backward never divides by it. The forward result is bit-identical to + ``x.sqrt()`` and the gradient is bit-identical wherever ``x > 0``; only the + ``x == 0`` entries change, from NaN to 0. + + Parameters + ---------- + x : torch.Tensor + Non-negative input tensor. + + Returns + ------- + torch.Tensor + ``sqrt(x)``, with a zero subgradient at the exact zeros of ``x``. + """ + positive = x > 0 + return torch.where(positive, torch.where(positive, x, torch.ones_like(x)).sqrt(), torch.zeros_like(x)) + + def _compute_ssim_and_cs_bf16( y_pred: torch.Tensor, y: torch.Tensor, @@ -259,12 +295,64 @@ def _compute_ssim_and_cs_bf16( c1 = (k1 * data_range) ** 2 c2 = (k2 * data_range) ** 2 - sigma_x = mu_xx - mu_x * mu_x - sigma_y = mu_yy - mu_y * mu_y - sigma_xy = mu_xy - mu_x * mu_y - - contrast_sensitivity = (2 * sigma_xy + c2) / (sigma_x + sigma_y + c2) - ssim_full = ((2 * mu_x * mu_y + c1) / (mu_x * mu_x + mu_y * mu_y + c1)) * contrast_sensitivity + # Repair bf16 catastrophic cancellation in the second moments. With bf16 + # mean / mean-of-squares convolutions, ``mu_xx - mu_x**2`` cancels badly for + # windows over large-magnitude inputs (e.g. z-scored FCMAE targets with a tiny + # iqr → normalized values in the hundreds) and can round to a slightly *negative* + # variance. At depth=1 (2D) each window has only ``1*H_k*W_k`` samples, so the + # cancellation is far more likely than at depth=15 (3D, where the extra Z samples + # keep the estimate stable) — a near-flat window then drives the SSIM denominator + # to ~0 and produces intermittent NaN loss that trains fine for a while then + # diverges. + # + # A variance is non-negative by definition, so clamp both diagonal terms to >=0. + # The covariance must then be bounded by Cauchy-Schwarz (|sigma_xy| <= + # sqrt(sigma_x*sigma_y)): exact fp32 recovers cs=1 on a flat window because the + # equally-negative sigma_x/sigma_y/sigma_xy cancel in the ratio, so clamping only + # the diagonal terms would leave an unbalanced negative sigma_xy and inflate |cs| + # to >10 on background windows. With the bound a flat window gives + # sigma_x=sigma_y=sigma_xy=0 -> cs = c2 / c2 = 1, matching exact fp32; on + # well-conditioned windows the bound is slack and both clamps are no-ops. + # + # The bound goes through ``_safe_sqrt``, not ``sqrt``: the same cancellation that + # motivates the clamp also drives ``sigma_x * sigma_y`` to *exactly* zero (either + # term rounds to 0), and ``sqrt`` has an infinite derivative there. Autograd then + # produces ``0 * inf == nan`` for the clamp-inactive elements, which spreads + # through the conv backward and NaNs most of the input gradient while the forward + # loss stays finite. + # + # That guards the fp32 path only. A NaN also reaches this line's ``MulBackward0`` + # when an enclosing 16-mixed autocast is left enabled, which ``ssim_25d`` now + # prevents -- see the note at its call site. Do not "fix" that by detaching the + # bound: the clamp binds often enough on ordinary inputs that the bound carries + # real gradient, and detaching drops the helper's gradient agreement with the + # fp32 monai reference from cosine 0.99 to 0.79 + # (``test_ssim_helper_gradient_flow``). + # + # Exposure depends on the per-window mean-to-variance ratio, so it is a latent + # trap rather than a constant hazard. Synthetic z-scored fields with a flat + # background at a nonzero offset reach 41-48% of ``pred.grad``; real z-scored + # iPSC FOVs measured 0% at depth=15 and 0-6484 NaN entries at depth=1, and a + # 60-step run of the real FCMAE-2D model on a real batch did not trip + # ``GradScaler`` at all. Treat this as a correctness fix for a reachable + # gradient path, not as a diagnosis of any particular run. + sigma_x = (mu_xx - mu_x * mu_x).clamp_min(0.0) + sigma_y = (mu_yy - mu_y * mu_y).clamp_min(0.0) + sigma_xy_bound = _safe_sqrt(sigma_x * sigma_y) + sigma_xy = torch.clamp(mu_xy - mu_x * mu_y, min=-sigma_xy_bound, max=sigma_xy_bound) + + # ``_SSIM_DENOM_EPS`` floors the stability constants so a zero data_range (→ c1=c2=0) + # on a flat window cannot yield 0/0. It goes in the NUMERATOR as well as the + # denominator -- exactly c1 -> c1 + eps, c2 -> c2 + eps -- because SSIM(x, x) == 1 + # only holds when the same constant appears in both. Flooring the denominator alone + # gave SSIM(y, y) = 0.082 at data_range 1e-3 and 0.0 at data_range 0, and + # ``ms_ssim_25d`` recomputes data_range from a downsampled target per scale, so an + # all-zero crop scored loss 1.0 for a numerically perfect prediction. Negligible vs + # c1/c2 otherwise: max 2.2e-4 relative on ordinary inputs. + contrast_sensitivity = (2 * sigma_xy + c2 + _SSIM_DENOM_EPS) / (sigma_x + sigma_y + c2 + _SSIM_DENOM_EPS) + ssim_full = ( + (2 * mu_x * mu_y + c1 + _SSIM_DENOM_EPS) / (mu_x * mu_x + mu_y * mu_y + c1 + _SSIM_DENOM_EPS) + ) * contrast_sensitivity return ssim_full, contrast_sensitivity @@ -291,12 +379,19 @@ def ssim_25d( depth = preds.shape[2] if depth > 15: warn(f"Input depth {depth} is potentially too large for 2.5D SSIM.") - ssim_img, cs_img = _compute_ssim_and_cs_bf16( - preds, - target, - kernel_size=(depth, *in_plane_window_size), - data_range=target.max(), - ) + # Outside autocast: this helper picks its own precision on purpose (bf16 convs, + # fp32 for everything after), and letting an enclosing 16-mixed autocast re-enter + # it puts backward intermediates in fp16, where the ~1e8 gradients the floored + # SSIM denominator produces overflow to inf and a later multiply turns them into + # NaN. Measured on the captured failing batch: 0.02% NaN under autocast, 0.00% in + # fp32, with the forward differing by ~3e-6. + with torch.amp.autocast(device_type=preds.device.type, enabled=False): + ssim_img, cs_img = _compute_ssim_and_cs_bf16( + preds, + target, + kernel_size=(depth, *in_plane_window_size), + data_range=target.max(), + ) # aggregate to one scalar per batch ssim = ssim_img.view(ssim_img.shape[0], -1).mean(1) if return_contrast_sensitivity: diff --git a/packages/viscy-utils/tests/test_lazy_imports.py b/packages/viscy-utils/tests/test_lazy_imports.py new file mode 100644 index 000000000..85036d8d4 --- /dev/null +++ b/packages/viscy-utils/tests/test_lazy_imports.py @@ -0,0 +1,38 @@ +import subprocess +import sys + +import pytest + +import viscy_utils + +# Submodules that evaluation code imports without training anything. +LIGHT_SUBMODULES = [ + "viscy_utils.compose", + "viscy_utils.mp_utils", + "viscy_utils.normalize", + "viscy_utils.prediction_metadata", +] + + +def test_light_submodules_skip_torch_and_lightning(): + """Importing light submodules must not load torch or lightning via the package.""" + code = ( + f"import sys, {', '.join(LIGHT_SUBMODULES)}; " + "print(sorted(m for m in ('torch', 'lightning') if m in sys.modules))" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True) + assert result.stdout.strip() == "[]" + + +@pytest.mark.parametrize("name", viscy_utils.__all__) +def test_public_names_importable(name): + exec(f"from viscy_utils import {name}", {}) + + +def test_unknown_attribute_raises(): + with pytest.raises(AttributeError): + viscy_utils.not_a_name # noqa: B018 + + +def test_dir_lists_public_names(): + assert set(viscy_utils.__all__) <= set(dir(viscy_utils)) diff --git a/packages/viscy-utils/tests/test_metrics.py b/packages/viscy-utils/tests/test_metrics.py index 6f1d3eb51..0dfc6688b 100644 --- a/packages/viscy-utils/tests/test_metrics.py +++ b/packages/viscy-utils/tests/test_metrics.py @@ -7,13 +7,27 @@ - aggregate SSIM equivalence on correlated-pair inputs (closer to training) - gradient-flow correctness via cosine similarity and sign-flip fraction - output dtype invariance to input dtype +- finiteness at zero data_range (the eps denominator floor) +- flat-window contrast-sensitivity ≈ 1 (the Cauchy-Schwarz covariance bound) +- finite gradients on flat-background windows (sqrt-at-zero inside that same bound) +- MS-SSIM computes outside autocast, so fp16 backward intermediates cannot overflow + to inf and become NaN in a later multiply + +The two regression tests guard the deterministic, hardware-independent halves of +the bf16 NaN fix. The remaining half — the negative-variance denominator collapse +that produced intermittent NaN loss in 2D FCMAE training — is a bf16 rounding +knife-edge (the denominator must round to exactly 0) that only surfaces over +millions of windows across many steps and varies by GPU architecture, so it is not +unit-reproducible; it is validated end-to-end (the exact failing fit ran clean +after the clamp). """ import pytest import torch import torch.nn.functional as F -from viscy_utils.evaluation.metrics import _compute_ssim_and_cs_bf16 +from viscy_utils.evaluation import metrics as metrics_module +from viscy_utils.evaluation.metrics import _compute_ssim_and_cs_bf16, _safe_sqrt # monai is not a hard dep of viscy-utils — skip the suite if absent rather # than failing at import time. @@ -167,3 +181,187 @@ def test_ssim_helper_dtypes(input_dtype): assert ssim_helper.dtype == torch.float32 assert cs_helper.dtype == torch.float32 + + +@_skip_no_bf16 +def test_ssim_helper_finite_on_zero_data_range(): + """Regression: a zero ``data_range`` (→ c1=c2=0) must not yield 0/0 NaN. + + ``ms_ssim_25d`` recomputes ``data_range = target.max()`` per scale; a flat or + all-nonpositive target zeroes ``data_range`` and with it the c1/c2 stability + constants, so the SSIM/CS ratios become 0/0. ``clamp=True`` in the loss cannot + repair that — ``clamp`` bounds magnitude, not NaN — so the poisoned gradient + silently corrupts every weight. The ``_SSIM_DENOM_EPS`` denominator floor keeps + the helper finite. This case NaNs on the unfloored implementation on every device + (deterministic, hardware-independent). + """ + kernel_1 = (1, 11, 11) + shape = (2, 1, 1, 64, 64) + y = torch.zeros(*shape, device="cuda") + y_pred = torch.zeros_like(y) + + ssim_full, cs = _compute_ssim_and_cs_bf16(y_pred, y, kernel_size=kernel_1, data_range=y.max()) + + assert torch.isfinite(ssim_full).all(), "SSIM NaN/Inf at data_range=0 (denominator not floored)" + assert torch.isfinite(cs).all(), "contrast-sensitivity NaN/Inf at data_range=0" + + +@_skip_no_bf16 +def test_ssim_helper_identity_holds_at_small_data_range(): + """SSIM(y, y) == 1 for every ``data_range``, including 0. + + ``_SSIM_DENOM_EPS`` must floor c1/c2 SYMMETRICALLY -- numerator and denominator. + Flooring only the denominator keeps the helper finite (the sibling test above) + but breaks the identity: measured SSIM(y, y) = 0.082158 at data_range 1e-3 and + exactly 0.0 at data_range 0. Since ``ms_ssim_25d`` recomputes + ``data_range = target.max()`` per scale from a downsampled target, an all-zero + or masked-out crop then scored loss 1.0 for a numerically perfect prediction. + """ + shape = (2, 1, 1, 64, 64) + for value in (0.0, 1e-4, 1e-3, 3e-3, 1e-2, 1.0): + y = torch.full(shape, value, device="cuda") + ssim_full, cs = _compute_ssim_and_cs_bf16(y, y, kernel_size=(1, 11, 11), data_range=y.max()) + assert torch.isfinite(ssim_full).all() + assert abs(ssim_full.mean().item() - 1.0) < 1e-6, f"SSIM(y, y) != 1 at data_range={value}" + assert abs(cs.mean().item() - 1.0) < 1e-6, f"cs(y, y) != 1 at data_range={value}" + + zeros = torch.zeros(1, 1, 5, 256, 256, device="cuda") + assert abs(metrics_module.ms_ssim_25d(zeros, zeros, clamp=True).item() - 1.0) < 1e-6 + + +@_skip_no_bf16 +def test_ssim_helper_flat_window_contrast_is_unity(): + """Regression: near-flat windows over large-magnitude inputs keep cs≈1. + + FCMAE targets are z-scored (``NormalizeSampled``); a channel with a tiny iqr + (e.g. iPSC/A549 nucleus, iqr~1.8) normalizes to values in the hundreds. At + depth=1 (2D FCMAE) each SSIM window has only ``1*H_k*W_k`` samples, so the bf16 + ``mu_xx - mu_x**2`` variance estimate catastrophically cancels and rounds + *negative* (empirically ~-100 at magnitude 150). Exact fp32 recovers cs=1 on a + flat window because the equally-negative sigma_x/sigma_y/sigma_xy cancel in the + ratio; clamping only the diagonal variances to >=0 breaks that cancellation and + inflates |cs| past 10 on background windows (which dominate a VS image). The + Cauchy-Schwarz bound on sigma_xy restores cs=1. This guards that balance — the + unbounded-covariance variant fails it with |cs-1| > 10, deterministically. + """ + torch.manual_seed(5) + kernel_1 = (1, 11, 11) + shape = (1, 1, 1, 64, 64) + worst = 0.0 + for magnitude in (150.0, 300.0): + y = torch.full(shape, magnitude, device="cuda") + 0.03 * torch.randn(*shape, device="cuda") + y_pred = torch.full(shape, magnitude, device="cuda") + 0.03 * torch.randn(*shape, device="cuda") + _, cs = _compute_ssim_and_cs_bf16( + y_pred, y, kernel_size=kernel_1, data_range=torch.tensor(magnitude, device="cuda") + ) + assert torch.isfinite(cs).all() + worst = max(worst, (cs - 1.0).abs().max().item()) + assert worst < 0.5, f"near-flat contrast-sensitivity deviates from 1 by {worst:.3f} (unbounded covariance?)" + + +@_skip_no_bf16 +def test_ssim_helper_gradient_finite_on_flat_background(): + """Regression: the Cauchy-Schwarz bound must not NaN the gradient at zero variance. + + ``sigma_xy_bound = sqrt(sigma_x * sigma_y)`` is zero wherever either clamped + variance rounds to zero, and ``sqrt`` has an infinite derivative at zero. The + clamp-inactive elements contribute ``0 * inf == nan``, which the conv backward + then spreads across each window — so the forward loss stays finite while most of + the input gradient becomes NaN. On the unguarded ``sqrt`` this fires on ordinary + normalized batches: 41-48% of ``pred.grad`` for a z-scored field with a flat + background at a nonzero offset, at both depth=15 and depth=1. + + Uses an offset flat region rather than ``torch.rand``: the trigger is a window + whose mean dominates its variance, so ``mu_xx`` and ``mu_x**2`` round to the same + bf16 value and cancel exactly. + """ + torch.manual_seed(6) + shape = (2, 1, 15, 192, 192) + half = shape[-2] // 2 + + def _field(seed: int) -> torch.Tensor: + torch.manual_seed(seed) + field = torch.randn(*shape, device="cuda") * 0.1 - 0.5 + # Bottom half carries structure; the top half stays near-flat background. + field[..., half:, :] = torch.randn(shape[0], shape[1], shape[2], shape[-2] - half, shape[-1], device="cuda") + return field + + y_pred = _field(6).detach().requires_grad_(True) + y = _field(7) + + ssim_full, _ = _compute_ssim_and_cs_bf16(y_pred, y, kernel_size=_KERNEL, data_range=y.max()) + (1 - ssim_full.mean()).backward() + + assert y_pred.grad is not None + n_nan = torch.isnan(y_pred.grad).sum().item() + assert n_nan == 0, f"{n_nan}/{y_pred.grad.numel()} NaN gradients (sqrt of zero variance not guarded)" + + +@_skip_no_bf16 +def test_ms_ssim_runs_outside_autocast(): + """``ms_ssim_25d`` must compute in fp32 even inside a 16-mixed autocast region. + + The helper chooses its own precision deliberately -- bf16 convolutions, fp32 for + everything after. Letting an enclosing autocast re-enter it puts *backward* + intermediates in fp16, where the ~1e8 gradients produced by the floored SSIM + denominator overflow to inf and a later multiply turns them into NaN. That is the + failure that stalled three joint FCMAE resumes: ``detect_anomaly`` blamed + ``MulBackward0`` on the Cauchy-Schwarz bound, the forward loss stayed finite, and + every one of the model's 32.1 M parameter gradients came back NaN. + + Pins the structural invariant, because reproducing the NaN itself needs one + specific real batch (z-scored targets ranging to ~264) far too large to ship as a + fixture: on that batch the rate was 0.02% of the input gradient under autocast and + exactly 0% in fp32. Hardening ``sqrt`` does not cover this and neither does + splitting the product -- both were measured and both still NaN'd. + """ + seen = [] + real = metrics_module._compute_ssim_and_cs_bf16 + + def spy(*args, **kwargs): + seen.append(torch.is_autocast_enabled()) + return real(*args, **kwargs) + + y = torch.rand(*_BATCH, device="cuda") + y_pred = (y + 0.05 * torch.randn_like(y)).detach().requires_grad_(True) + + monkey = pytest.MonkeyPatch() + monkey.setattr("viscy_utils.evaluation.metrics._compute_ssim_and_cs_bf16", spy) + try: + with torch.autocast("cuda", dtype=torch.float16): + ms_ssim = metrics_module.ms_ssim_25d(y_pred, y, clamp=True) + (1 - ms_ssim).backward() + finally: + monkey.undo() + + assert seen, "ms_ssim_25d did not reach the bf16 helper" + assert not any(seen), ( + f"autocast was enabled for {sum(seen)}/{len(seen)} MS-SSIM scales; fp16 backward intermediates are the NaN path" + ) + assert torch.isfinite(y_pred.grad).all(), "non-finite gradient under 16-mixed autocast" + + +def test_safe_sqrt_matches_sqrt_forward_and_zeroes_gradient_at_zero(): + """``_safe_sqrt`` is bit-exact vs ``sqrt`` forward, and finite in backward at 0. + + Runs on CPU — the sqrt-at-zero derivative is a pure autograd property with no + bf16 or CUDA involvement. Pins both halves of the contract the SSIM bound relies + on: identical forward values, identical gradients where the input is positive, + and a zero (not NaN) gradient at the exact zeros. + """ + values = torch.tensor([0.0, 1e-12, 0.25, 1.0, 4.0]) + + plain = values.detach().clone().requires_grad_(True) + plain.sqrt().sum().backward() + + safe = values.detach().clone().requires_grad_(True) + safe_out = _safe_sqrt(safe) + safe_out.sum().backward() + + torch.testing.assert_close(safe_out, values.sqrt(), rtol=0, atol=0) + assert torch.isfinite(safe.grad).all(), f"safe grad not finite: {safe.grad}" + assert safe.grad[0] == 0.0, f"expected zero gradient at x=0, got {safe.grad[0]}" + # Unguarded sqrt is the failure being guarded against. + assert not torch.isfinite(plain.grad[0]), "expected unguarded sqrt to blow up at x=0" + # Everywhere the input is positive the two must agree bit-for-bit. + torch.testing.assert_close(safe.grad[1:], plain.grad[1:], rtol=0, atol=0) From 4a6943acfc854e633231d8b0e5246c9bb6379c15 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:15 -0700 Subject: [PATCH 06/39] feat(viscy-data): slab-only and read-once predict, uint8 foreground masks Port the viscy-data side of the dynacell-models predict and Spotlight work: - HCSDataModule.predict_z_planes and SlidingWindowDataset z_planes / read_z_planes, so a 2D model predicts only its focus slab; - cache_volume: with num_workers == 0 predict reads each (position, t) volume once and, when every transform is pointwise, normalizes it once; - foreground masks are read in their stored dtype (uint8) and cast to float on the device in on_after_batch_transfer. Behavior change: every app's predict at num_workers=0 takes the read-once path. Source on dynacell-models: 40de7939 (#498), 996adb47 (#526), 6c83fff4 (#531), 8e74a7eb (#532). Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_data/foreground_masks.py | 9 +- packages/viscy-data/src/viscy_data/hcs.py | 21 ++- .../src/viscy_data/sliding_window.py | 136 +++++++++++++++++- packages/viscy-data/tests/test_hcs.py | 122 +++++++++++++++- 4 files changed, 278 insertions(+), 10 deletions(-) diff --git a/packages/viscy-data/src/viscy_data/foreground_masks.py b/packages/viscy-data/src/viscy_data/foreground_masks.py index 2489f72f3..58404fd22 100644 --- a/packages/viscy-data/src/viscy_data/foreground_masks.py +++ b/packages/viscy-data/src/viscy_data/foreground_masks.py @@ -159,11 +159,14 @@ def read_window( Returns ------- list[Tensor] - Per-channel mask tensors with shape ``(1, Z, Y, X)``. + Per-channel mask tensors with shape ``(1, Z, Y, X)``, in the + stored dtype (not upcast: a float32 mask quadruples the mask's + share of every worker buffer and pinned host copy, which + OOM-killed the 4-GPU 3D seg-aux fits at their first validation). """ - kwargs = {} + kwargs = {"as_float": False} if self._preloaded_masks is not None: - kwargs = {"arr_idx": arr_idx, "_preloaded": self._preloaded_masks} + kwargs.update(arr_idx=arr_idx, _preloaded=self._preloaded_masks) mask_images, _ = read_fn( self._mask_arrays[arr_idx], self._mask_ch_indices[arr_idx], diff --git a/packages/viscy-data/src/viscy_data/hcs.py b/packages/viscy-data/src/viscy_data/hcs.py index eb7bc7502..28df3869e 100644 --- a/packages/viscy-data/src/viscy_data/hcs.py +++ b/packages/viscy-data/src/viscy_data/hcs.py @@ -28,7 +28,7 @@ from viscy_data._typing import Sample from viscy_data._utils import _collate_samples, _ensure_channel_list from viscy_data.foreground_masks import ForegroundMaskSupport -from viscy_data.sliding_window import MaskTestDataset, SlidingWindowDataset +from viscy_data.sliding_window import MaskTestDataset, SlidingWindowDataset, read_z_planes _logger = logging.getLogger("lightning.pytorch") @@ -119,6 +119,12 @@ class HCSDataModule(LightningDataModule): ``include_fov_names``. Stored as a ``set`` for O(1) lookup. Honored during fit/validate (including mmap staging) and predict. Test stage uses all plate positions. + predict_z_planes : str or Path or None, optional + Plane file (see :func:`viscy_data.sliding_window.read_z_planes`) that + restricts predict to the listed Z planes per position and timepoint, e.g. + the in-focus slab of a 2D model. Requires ``z_window_size == 1``. The + prediction writer allocates the full source shape and records the planes + on each position. Default None predicts every plane. Predict only. """ def __init__( @@ -151,6 +157,7 @@ def __init__( val_gpu_augmentations: list[MapTransform] | None = None, include_fov_names: Iterable[str] | None = None, exclude_fov_names: Iterable[str] | None = None, + predict_z_planes: str | Path | None = None, ): super().__init__() self.data_path = Path(data_path) @@ -185,6 +192,7 @@ def __init__( self.val_augmentations = val_augmentations or [] self.include_fov_names = set(include_fov_names) if include_fov_names is not None else None self.exclude_fov_names = set(exclude_fov_names) if exclude_fov_names is not None else None + self.predict_z_planes = Path(predict_z_planes) if predict_z_planes is not None else None if gpu_augmentations and self.fg_mask_key is not None: ForegroundMaskSupport.patch_spatial_transforms(gpu_augmentations, ("target",), ("fg_mask",)) if val_gpu_augmentations and self.fg_mask_key is not None: @@ -670,9 +678,16 @@ def _setup_predict( # never reads it, and inference datasets may not have the array. dataset_settings.pop("fg_mask_key", None) predict_transform = Compose(self.normalizations) + z_planes = read_z_planes(self.predict_z_planes) if self.predict_z_planes is not None else None self.predict_dataset = SlidingWindowDataset( positions=self._positions_maybe_single(), transform=predict_transform, + z_planes=z_planes, + # Read each volume once and cut its depth windows from memory. A plane + # selection reads single planes, where a whole-volume read would only cost, + # and loader workers each take every Nth batch, so each would read the whole + # volume. + cache_volume=z_planes is None and self.num_workers == 0, **dataset_settings, ) @@ -691,6 +706,10 @@ def on_after_batch_transfer(self, batch: Sample, dataloader_idx: int) -> Sample: """ if isinstance(batch, Tensor): return batch + # Masks travel host-side in their stored dtype (uint8) to keep worker + # and pinned buffers small; augmentations and losses expect float. + if "fg_mask" in batch and not batch["fg_mask"].is_floating_point(): + batch["fg_mask"] = batch["fg_mask"].float() if self.trainer and self.trainer.training and self._gpu_augmentations is not None: batch = self._gpu_augmentations(batch) elif ( diff --git a/packages/viscy-data/src/viscy_data/sliding_window.py b/packages/viscy-data/src/viscy_data/sliding_window.py index 7e109f555..0978aed66 100644 --- a/packages/viscy-data/src/viscy_data/sliding_window.py +++ b/packages/viscy-data/src/viscy_data/sliding_window.py @@ -1,7 +1,10 @@ """Sliding window datasets for HCS NGFF stores.""" import bisect +import json import logging +from collections.abc import Mapping, Sequence +from os import PathLike from pathlib import Path import numpy as np @@ -18,6 +21,29 @@ _logger = logging.getLogger("lightning.pytorch") +def read_z_planes(path: str | PathLike) -> dict[str, list[list[int]]]: + """Read a predict plane file: position name -> one Z-plane list per timepoint. + + The file is JSON with a ``positions`` mapping (``{"A/1/0": [[11, 12, 13], ...]}``); + any other top-level keys are provenance (how the planes were chosen) and are + ignored here. + + Parameters + ---------- + path : str or PathLike + Plane file written by the producer of the planes (dynacell writes them with + ``precompute-gt build.focus_planes``). + + Returns + ------- + dict[str, list[list[int]]] + Plate-relative position name to per-timepoint plane lists. + """ + with open(path) as f: + payload = json.load(f) + return {name: [[int(z) for z in planes] for planes in per_t] for name, per_t in payload["positions"].items()} + + class SlidingWindowDataset(Dataset): """Sliding window dataset over HCS NGFF positions. @@ -61,6 +87,24 @@ class SlidingWindowDataset(Dataset): ``(T, C, Z, Y, X)``. Channels are ``source + target`` in order. When set, bypasses zarr reads via ``.clone()`` copies from the preloaded data. Default None reads from zarr. + z_planes : Mapping[str, Sequence[Sequence[int]]] or None + Restrict the windows to these Z planes: plate-relative position name + (``"A/1/0"``) to one plane list per timepoint (see :func:`read_z_planes`). + Requires ``z_window_size == 1``; every position needs an entry with one list + per timepoint. Windows stay in increasing ``z`` order within a timepoint. + Default None takes every plane. + cache_volume : bool + Read each (position, timepoint) volume from zarr once and cut its windows + from that copy, instead of re-reading every window. Stride-1 depth windows + otherwise read each plane once per window covering it. Pays off when the + windows of a volume are requested consecutively (the sequential predict + loader); random access (training) would read a whole volume per window. + Each DataLoader worker would keep its own copy and read the whole volume, + so :class:`HCSDataModule` enables it only with ``num_workers == 0``. Not + combinable with ``fg_mask_key`` or ``min_nonzero_fraction``. When every + transform is ``pointwise`` (each voxel mapped through precomputed + statistics alone, e.g. ``NormalizeSampled``), the cached volume is + transformed once instead of every window. Default False. """ def __init__( @@ -77,8 +121,29 @@ def __init__( max_nonzero_retries: int = 100, fg_mask_key: str | None = None, preloaded_fovs: list[Tensor] | None = None, + z_planes: Mapping[str, Sequence[Sequence[int]]] | None = None, + cache_volume: bool = False, ) -> None: super().__init__() + if cache_volume and fg_mask_key is not None: + raise ValueError("cache_volume caches one array per (position, t); it cannot serve fg_mask reads too") + if cache_volume and min_nonzero_fraction > 0: + # The check would also see transformed values when the cached volume is transformed once. + raise ValueError("cache_volume serves windows in order; min_nonzero_fraction retries random windows") + self.cache_volume = cache_volume + transforms = getattr(transform, "transforms", [transform]) + self._transform_volume = ( + cache_volume + and preloaded_fovs is None # preloaded windows bypass the cache + and bool(transform) # an empty Compose has nothing to apply + and all(getattr(t, "pointwise", False) for t in transforms) + ) + self._transform_windows = bool(transform) and not self._transform_volume + self._volume_key: tuple | None = None + self._volume: np.ndarray | None = None + if z_planes is not None and z_window_size != 1: + raise ValueError(f"z_planes selects single planes and needs z_window_size=1, got {z_window_size}") + self.z_planes = z_planes if not 0.0 <= min_nonzero_fraction <= 1.0: raise ValueError(f"min_nonzero_fraction must be in [0, 1], got {min_nonzero_fraction}") if max_nonzero_retries < 0: @@ -138,9 +203,35 @@ def _get_windows(self) -> None: if self.fg_mask_support is not None: self.fg_mask_support.validate_and_store(fov, img_arr, self.target_ch_idx) self._max_window = w + self._planned: list[tuple[int, int]] | None = None + if self.z_planes is not None: + self._planned = self._planned_windows() + self._max_window = len(self._planned) + + def _planned_windows(self) -> list[tuple[int, int]]: + """``(array index, window index)`` of every ``z_planes`` plane, in dataset order.""" + planned = [] + for arr_idx, img_arr in enumerate(self.window_arrays): + name = img_arr.path.rsplit("/", 1)[0] + if name not in self.z_planes: + raise KeyError(f"z_planes has no entry for position {name!r}") + per_t = self.z_planes[name] + if len(per_t) != img_arr.frames: + raise ValueError(f"z_planes[{name!r}] has {len(per_t)} timepoints; the array has {img_arr.frames}") + for t, planes in enumerate(per_t): + zs = sorted(set(int(z) for z in planes)) + if not zs or zs[0] < 0 or zs[-1] >= img_arr.slices: + raise ValueError( + f"z_planes[{name!r}][{t}] = {list(planes)} is empty or outside [0, {img_arr.slices})" + ) + planned.extend((arr_idx, t * img_arr.slices + z) for z in zs) + return planned def _find_window(self, index: int) -> tuple[ImageArray, int, NormMeta | None, int]: """Look up window given index.""" + if self._planned is not None: + arr_idx, tz = self._planned[index] + return (self.window_arrays[arr_idx], tz, self.window_norm_meta[arr_idx], arr_idx) arr_idx = bisect.bisect_right(self.window_keys, index) tz = index - self.window_keys[arr_idx - 1] if arr_idx > 0 else index return (self.window_arrays[arr_idx], tz, self.window_norm_meta[arr_idx], arr_idx) @@ -170,6 +261,7 @@ def _read_img_window( tz: int, arr_idx: int = -1, _preloaded: list[Tensor] | None = None, + as_float: bool = True, ) -> tuple[list[Tensor], HCSStackIndex]: """Read image window as tensor. @@ -188,6 +280,10 @@ def _read_img_window( _preloaded : list[Tensor] or None Override for ``self._preloaded``. When set, uses this list instead of the dataset-level preloaded data. + as_float : bool + Cast the window to float32 (images). When False, keep the stored + dtype (foreground masks: uint8 is 4x smaller in every worker + buffer and pinned host copy; the datamodule casts on GPU). Returns ------- @@ -200,15 +296,49 @@ def _read_img_window( z = tz - t * zs preloaded = _preloaded if _preloaded is not None else self._preloaded if preloaded is not None and arr_idx >= 0: - data = preloaded[arr_idx][t : t + 1, :, z : z + self.z_window_size].to(torch.float32, copy=True) + window = preloaded[arr_idx][t : t + 1, :, z : z + self.z_window_size] + data = window.to(torch.float32 if as_float else window.dtype, copy=True) return data.unbind(dim=1), (f"/{img.path}", t, z) + if self.cache_volume: + # Keyed on the array object: positions of different plates can share a path. + key = (id(img), t, tuple(ch_idx), as_float) + if self._volume_key != key: + # Release the previous volume first, so two never coexist; windows own copies. + self._volume, self._volume_key = None, None + # Per-channel basic slices, not oindex: the coordinate indexer peaks at ~7x + # the output size, which a whole volume makes large (see HCSDataModule's + # mmap preload). Filling one preallocated array keeps the peak at its size. + volume = np.empty((1, len(ch_idx), *img.shape[-3:]), dtype=np.float32 if as_float else img.dtype) + for j, c in enumerate(ch_idx): + volume[:, j] = img[t : t + 1, int(c)] + if self._transform_volume: + self._transform_cached(volume, arr_idx, t) + self._volume = volume + self._volume_key = key + data = self._volume[:, :, z : z + self.z_window_size] + if self._transform_windows: + # Copy: transforms must not reach the cached volume the next window is cut from. + data = data.copy() + return torch.from_numpy(data).unbind(dim=1), (f"/{img.path}", t, z) data = img.oindex[ slice(t, t + 1), [int(i) for i in ch_idx], slice(z, z + self.z_window_size), - ].astype(np.float32) + ] + if as_float: + data = data.astype(np.float32) return torch.from_numpy(data).unbind(dim=1), (f"/{img.path}", t, z) + def _transform_cached(self, volume: np.ndarray, arr_idx: int, t: int) -> None: + """Apply the pointwise transforms to a cached ``(1, C, Z, Y, X)`` volume in place.""" + images = {name: torch.from_numpy(volume[:, j]) for j, name in enumerate(self._all_ch_names)} + norm_meta = self._resolve_timepoint_norm_meta(self.window_norm_meta[arr_idx], t) + if norm_meta is not None: + images["norm_meta"] = norm_meta + images = self.transform(images) + for j, name in enumerate(self._all_ch_names): + volume[:, j] = np.asarray(images[name]) + def __len__(self) -> int: """Return total number of windows.""" return self._max_window @@ -269,7 +399,7 @@ def __getitem__(self, index: int) -> Sample: if norm_meta is not None: norm_meta = self._resolve_timepoint_norm_meta(norm_meta, sample_index[1]) sample_images["norm_meta"] = norm_meta - if self.transform: + if self._transform_windows: sample_images = self.transform(sample_images) if "weight" in sample_images: del sample_images["weight"] diff --git a/packages/viscy-data/tests/test_hcs.py b/packages/viscy-data/tests/test_hcs.py index 1242e1d81..72d487192 100644 --- a/packages/viscy-data/tests/test_hcs.py +++ b/packages/viscy-data/tests/test_hcs.py @@ -11,7 +11,7 @@ from viscy_data import HCSDataModule from viscy_data.sliding_window import SlidingWindowDataset -from viscy_transforms import BatchedRandFlipd, NormalizeSampled, RandSpatialCropd +from viscy_transforms import BatchedRandFlipd, MinMaxSampled, NormalizeSampled, RandSpatialCropd @mark.parametrize("multi_sample_augmentation", [True, False]) @@ -1061,7 +1061,7 @@ def test_mmap_preload_preserves_native_dtype_and_casts_on_read(tmp_path): def test_mmap_preload_fg_mask_preserves_native_dtype(hcs_with_fg_mask, tmp_path): - """fg_mask.mmap preserves native uint8 dtype; sampled masks cast to float32.""" + """fg_mask.mmap preserves native uint8 dtype, and so do the sampled masks.""" importorskip("tensordict") from tensordict.memmap import MemoryMappedTensor @@ -1093,7 +1093,50 @@ def test_mmap_preload_fg_mask_preserves_native_dtype(hcs_with_fg_mask, tmp_path) dm.setup(stage="fit") batch = next(iter(dm.train_dataloader())) - assert batch["fg_mask"].dtype == torch.float32 + assert batch["fg_mask"].dtype == torch.uint8 + + +@mark.parametrize("mmap_preload", [False, True]) +def test_fg_mask_stays_uint8_on_host_and_is_float_after_transfer(hcs_with_fg_mask, tmp_path, mmap_preload): + """Host-side fg_mask batches keep the stored uint8; the GPU hook casts to float32. + + Regression: masks were upcast to float32 in every sample, so full-FOV + validation batches carried a mask as large as the target through the + worker buffers and the power-of-two pinned-memory cache, and the 4-GPU + 3D seg-aux fits were OOM-killed at their first validation. + """ + if mmap_preload: + importorskip("tensordict") + from viscy_transforms import BatchedCenterSpatialCropd + + dm = HCSDataModule( + data_path=hcs_with_fg_mask, + source_channel="Phase", + target_channel="Fluorescence", + fg_mask_key="fg_mask", + z_window_size=4, + batch_size=2, + num_workers=0, + yx_patch_size=[16, 16], + split_ratio=0.5, + mmap_preload=mmap_preload, + scratch_dir=tmp_path, + val_gpu_augmentations=[BatchedCenterSpatialCropd(keys=["source", "target"], roi_size=[4, 16, 16])], + ) + dm.prepare_data() + dm.setup(stage="fit") + for loader in (dm.train_dataloader(), dm.val_dataloader()): + host = next(iter(loader)) + assert host["fg_mask"].dtype == torch.uint8 + assert host["fg_mask"].nbytes * 4 == host["target"].nbytes + assert set(host["fg_mask"].unique().tolist()).issubset({0, 1}) + + host_mask = host["fg_mask"].clone() + dm.trainer = MagicMock(training=False, validating=True, sanity_checking=False) + result = dm.on_after_batch_transfer(host, 0) + assert result["fg_mask"].dtype == torch.float32 + assert result["fg_mask"].shape == result["target"].shape == (2, 1, 4, 16, 16) + assert torch.equal(result["fg_mask"], host_mask[..., 8:24, 8:24].float()) @fixture(scope="function") @@ -1226,3 +1269,76 @@ def test_mmap_preload_matches_oindex(preprocessed_hcs_dataset, tmp_path): expected = np.asarray(pos["0"].oindex[:, ch_idx, :]) assert staged.shape == expected.shape np.testing.assert_array_equal(staged, expected) + + +class _WindowMeanCenter: + """Subtract each window's own mean: per-window statistics, so not pointwise.""" + + def __init__(self, key: str) -> None: + self.key = key + + def __call__(self, sample: dict) -> dict: + sample[self.key] = sample[self.key] - sample[self.key].mean() + return sample + + +@mark.parametrize("level", ["fov_statistics", "timepoint_statistics", "min_max", "per_window"]) +def test_cached_volume_transformed_once_equals_per_window_transforms(tmp_path, level): + """A pointwise normalization of the cached volume gives every window the bytes per-window normalization gives. + + Statistics differ per FOV and timepoint, so a volume normalized with another FOV's or frame's statistics shows. + A transform with per-window statistics is not pointwise and must keep running per window. + """ + path = tmp_path / "norm.zarr" + rng = np.random.default_rng(3) + with open_ome_zarr(path, layout="hcs", mode="w", channel_names=["Phase", "Fluorescence"]) as plate: + for i, fov in enumerate(("0", "1")): + pos = plate.create_position("A", "1", fov) + pos.create_image("0", (rng.normal(size=(2, 2, 7, 16, 16)) * 3 + 10).astype(np.float32)) + stats = [ + {"mean": 10.0 + i + 0.37 * t, "std": 2.9 + 0.11 * i - 0.05 * t, "p1": 5.0 + i, "p99": 15.0 - t} + for t in range(2) + ] + pos.zattrs["normalization"] = { + ch: {"fov_statistics": stats[0], "timepoint_statistics": {str(t): s for t, s in enumerate(stats)}} + for ch in ("Phase", "Fluorescence") + } + if level == "per_window": + transform = Compose([_WindowMeanCenter("Phase")]) + elif level == "min_max": + transform = Compose([MinMaxSampled(keys=["Phase"], level="timepoint_statistics")]) + else: + transform = Compose([NormalizeSampled(keys=["Phase", "Fluorescence"], level=level)]) + samples = {} + with open_ome_zarr(path, mode="r") as plate: + positions = [pos for _, pos in plate.positions()] + for cache_volume in (False, True): + dataset = SlidingWindowDataset( + positions=positions, + channels={"source": ["Phase"], "target": ["Fluorescence"]}, + z_window_size=4, + transform=transform, + cache_volume=cache_volume, + ) + assert dataset._transform_volume is (cache_volume and level != "per_window") + samples[cache_volume] = [dataset[i] for i in range(len(dataset))] + assert len(samples[True]) == 2 * 2 * 4 + for reference, cached in zip(samples[False], samples[True]): + assert reference["index"] == cached["index"] + for key in ("source", "target"): + assert reference[key].dtype == cached[key].dtype == torch.float32 + assert reference[key].numpy().tobytes() == cached[key].numpy().tobytes() + + +def test_cached_volume_refuses_nonzero_retries(hcs_with_fg_mask): + """Retries jump to random windows and would judge transformed values, so the volume cache refuses them.""" + with open_ome_zarr(hcs_with_fg_mask, mode="r") as ds: + positions = [pos for _, pos in ds.positions()] + with raises(ValueError, match="min_nonzero_fraction"): + SlidingWindowDataset( + positions=positions, + channels={"source": ["Phase"], "target": ["Fluorescence"]}, + z_window_size=4, + min_nonzero_fraction=0.1, + cache_volume=True, + ) From 879a8b2bc4eb18c066b16b9a96ace6d9a20a5041 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:30 -0700 Subject: [PATCH 07/39] feat(viscy-models): per-axis CellDiff patches and mask-conditioning channels Port from dynacell-models: - patch_size may be a per-axis (z, y, x) tuple through the patch embedding, transformer unpatchify and ViT bottleneck, which lets CELLDiffNet run Z-preserving at Z=1 for the 2D track (an int is still accepted); - CELLDiffNet(cond_channels=1) adds extra conditioning input channels (default keeps the old single-channel net). Source on dynacell-models: 0126fe6f, 0617ed71, 40de7939 (#498). Co-Authored-By: Claude Opus 5.5 --- .../viscy_models/celldiff/celldiff_2d_test.py | 82 +++++++++++++++++++ .../src/viscy_models/celldiff/celldiff_net.py | 27 ++++-- .../celldiff/modules/patch_embed_3d.py | 41 ++++++++-- .../celldiff/modules/transformer.py | 30 ++++--- .../src/viscy_models/celldiff/unet_vit_3d.py | 10 ++- .../viscy_models/celldiff/vit_bottleneck.py | 31 ++++--- .../tests/test_celldiff/test_celldiff_net.py | 2 +- .../tests/test_celldiff/test_unet_vit_3d.py | 2 +- 8 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 packages/viscy-models/src/viscy_models/celldiff/celldiff_2d_test.py diff --git a/packages/viscy-models/src/viscy_models/celldiff/celldiff_2d_test.py b/packages/viscy-models/src/viscy_models/celldiff/celldiff_2d_test.py new file mode 100644 index 000000000..1a9767455 --- /dev/null +++ b/packages/viscy-models/src/viscy_models/celldiff/celldiff_2d_test.py @@ -0,0 +1,82 @@ +"""CELLDiffNet at Z=1 with a per-axis patch (the CellDiff-2D configuration).""" + +import pytest +import torch + +from viscy_models.celldiff.celldiff_net import CELLDiffNet +from viscy_models.celldiff.modules.patch_embed_3d import normalize_patch_size + +#: Small enough to build and run on CPU in a test; same topology as the recipe. +_NET_KWARGS = dict( + in_channels=1, + dims=[8, 16, 32, 32], + num_res_block=[2, 2, 2], + hidden_size=64, + num_heads=4, + dim_head=16, + num_hidden_layers=1, +) + + +def test_normalize_patch_size_expands_int_and_validates() -> None: + """An int stays cubic; a triple passes through; anything else raises.""" + assert normalize_patch_size(4) == (4, 4, 4) + assert normalize_patch_size([1, 4, 4]) == (1, 4, 4) + assert normalize_patch_size((2, 3, 4)) == (2, 3, 4) + for bad in (0, -1, [1, 4], [0, 4, 4], [1, 4, 4, 4]): + with pytest.raises(ValueError): + normalize_patch_size(bad) + + +@pytest.mark.parametrize( + ("spatial", "patch"), + [([1, 64, 64], [1, 4, 4]), ([1, 32, 32], [1, 2, 2]), ([8, 64, 64], 4)], +) +def test_celldiff_net_roundtrips_spatial_shape(spatial: list[int], patch: int | list[int]) -> None: + """Z=1 with a (1, p, p) patch runs, and the cubic 3D path is unchanged.""" + net = CELLDiffNet(input_spatial_size=spatial, patch_size=patch, **_NET_KWARGS) + x = torch.randn(2, 1, *spatial) + out = net(x, cond=torch.randn(2, 1, *spatial), t=torch.rand(2)) + assert out.shape == (2, 1, *spatial) + assert torch.isfinite(out).all() + + +def test_celldiff_net_z1_backward_reaches_every_parameter() -> None: + """A frozen/disconnected patch path would silently starve gradients at Z=1.""" + spatial = [1, 64, 64] + net = CELLDiffNet(input_spatial_size=spatial, patch_size=[1, 4, 4], **_NET_KWARGS) + out = net(torch.randn(1, 1, *spatial), cond=torch.randn(1, 1, *spatial), t=torch.rand(1)) + out.square().mean().backward() + starved = [ + name + for name, p in net.named_parameters() + if p.requires_grad and (p.grad is None or not torch.isfinite(p.grad).all()) + ] + assert not starved, f"parameters with missing/non-finite grads: {starved[:5]}" + + +def test_cubic_patch_is_rejected_at_z1() -> None: + """The failure that motivated per-axis patches must stay a loud error.""" + with pytest.raises(ValueError, match="not divisible by its patch extent"): + CELLDiffNet(input_spatial_size=[1, 64, 64], patch_size=4, **_NET_KWARGS) + + +def test_z1_patch_grid_drops_the_z_axis() -> None: + """The token grid must be (1, H/2**n/p, W/2**n/p) — no phantom Z tokens.""" + net = CELLDiffNet(input_spatial_size=[1, 64, 64], patch_size=[1, 4, 4], **_NET_KWARGS) + # 3 downsamples of stride (1, 2, 2): 64 -> 8, then patch 4 -> 2 + assert net.bottleneck.latent_grid_size == [1, 2, 2] + assert net.bottleneck.img_pos_embed.shape[1] == 4 + + +def test_cond_channels_widens_the_conditioning_conv_only() -> None: + """Extra conditioning channels (C-cond) are accepted and every other shape is unchanged.""" + spatial = [1, 32, 32] + base = CELLDiffNet(input_spatial_size=spatial, patch_size=[1, 4, 4], **_NET_KWARGS) + net = CELLDiffNet(input_spatial_size=spatial, patch_size=[1, 4, 4], cond_channels=2, **_NET_KWARGS) + widened = {name for name, p in net.state_dict().items() if p.shape != base.state_dict()[name].shape} + assert widened == {"_cond_inconv.weight"} + out = net(torch.randn(2, 1, *spatial), cond=torch.randn(2, 2, *spatial), t=torch.rand(2)) + assert out.shape == (2, 1, *spatial) + with pytest.raises(ValueError, match="cond must have 2 channel"): + net(torch.randn(2, 1, *spatial), cond=torch.randn(2, 1, *spatial), t=torch.rand(2)) diff --git a/packages/viscy-models/src/viscy_models/celldiff/celldiff_net.py b/packages/viscy-models/src/viscy_models/celldiff/celldiff_net.py index 537cdc360..b478e279b 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/celldiff_net.py +++ b/packages/viscy-models/src/viscy_models/celldiff/celldiff_net.py @@ -9,6 +9,8 @@ application layer and is not part of this package. """ +from collections.abc import Sequence + import torch from torch import Tensor @@ -51,8 +53,14 @@ class CELLDiffNet(UNet3DBase): Feed-forward output dropout rate. num_hidden_layers : int Number of transformer blocks in the bottleneck. - patch_size : int - Cubic patch size for the 3D patch embedding. + patch_size : int | Sequence[int] + Cubic patch size for the 3D patch embedding, or per-axis + ``(D, H, W)`` extents. Use ``(1, p, p)`` at ``D=1`` for the + Z-preserving 2D configuration. + cond_channels : int + Number of conditioning channels, fused additively with the noisy + target after the first convolution. ``1`` is phase alone; extra + channels carry further pixel-aligned conditions (e.g. a mask). """ def __init__( @@ -67,8 +75,11 @@ def __init__( dropout: float = 0.0, final_dropout: float = 0.0, num_hidden_layers: int = 2, - patch_size: int = 4, + patch_size: int | Sequence[int] = 4, + cond_channels: int = 1, ) -> None: + if cond_channels < 1: + raise ValueError(f"cond_channels must be >= 1, got {cond_channels}") if input_spatial_size is None: input_spatial_size = [8, 512, 512] if dims is None: @@ -97,9 +108,10 @@ def __init__( bottleneck=bottleneck, downsample_z=False, time_embed_dim=hidden_size, - cond_channels=1, + cond_channels=cond_channels, ) self.input_spatial_size = input_spatial_size + self.cond_channels = cond_channels def forward(self, x: Tensor, cond: Tensor, t: Tensor) -> Tensor: """Predict velocity field for flow-matching. @@ -109,7 +121,8 @@ def forward(self, x: Tensor, cond: Tensor, t: Tensor) -> Tensor: x : Tensor Noisy target volume of shape ``(B, in_channels, D, H, W)``. cond : Tensor - Phase contrast conditioning of shape ``(B, 1, D, H, W)``. + Conditioning of shape ``(B, cond_channels, D, H, W)``; channel 0 + is phase. t : Tensor Diffusion timesteps of shape ``(B,)``. @@ -124,6 +137,6 @@ def forward(self, x: Tensor, cond: Tensor, t: Tensor) -> Tensor: raise ValueError( f"cond spatial size {list(cond.shape[2:])} does not match expected {self.input_spatial_size}" ) - if cond.shape[1] != 1: - raise ValueError(f"cond must have 1 channel, got {cond.shape[1]}") + if cond.shape[1] != self.cond_channels: + raise ValueError(f"cond must have {self.cond_channels} channel(s), got {cond.shape[1]}") return super().forward(x, cond=cond, t=t) diff --git a/packages/viscy-models/src/viscy_models/celldiff/modules/patch_embed_3d.py b/packages/viscy-models/src/viscy_models/celldiff/modules/patch_embed_3d.py index 507a6f3f2..7a01d6a64 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/modules/patch_embed_3d.py +++ b/packages/viscy-models/src/viscy_models/celldiff/modules/patch_embed_3d.py @@ -1,19 +1,49 @@ """3D patch embedding for vision transformers.""" +from collections.abc import Sequence + import torch import torch.nn as nn +__all__ = ["PatchEmbed3D", "normalize_patch_size"] + + +def normalize_patch_size(patch_size: int | Sequence[int]) -> tuple[int, int, int]: + """Expand a patch size to a per-axis ``(D, H, W)`` triple. + + An ``int`` means a cubic patch, matching the original CELL-Diff recipe. A + 3-sequence gives per-axis control, which is what lets the same network run + Z-preserving at ``D=1`` — a cubic ``patch_size=4`` would need a Conv3d with + a size-4 kernel over a size-1 axis. + + Parameters + ---------- + patch_size : int | Sequence[int] + Cubic side length, or ``(D, H, W)`` patch extents. + + Returns + ------- + tuple[int, int, int] + Per-axis patch extents. + """ + dims = (patch_size,) * 3 if isinstance(patch_size, int) else tuple(int(p) for p in patch_size) + if len(dims) != 3: + raise ValueError(f"patch_size must be an int or a length-3 sequence, got {patch_size!r}") + if any(p < 1 for p in dims): + raise ValueError(f"patch_size entries must be >= 1, got {patch_size!r}") + return dims + class PatchEmbed3D(nn.Module): """Convert a 3D volume into a sequence of patch embeddings. Uses a single strided 3D convolution to project non-overlapping - cubic patches into an embedding space. + patches into an embedding space. Parameters ---------- - patch_size : int - Cubic patch side length. + patch_size : int | Sequence[int] + Cubic patch side length, or per-axis ``(D, H, W)`` extents. in_chans : int Number of input channels. embed_dim : int @@ -22,9 +52,10 @@ class PatchEmbed3D(nn.Module): Whether to include bias in the projection convolution. """ - def __init__(self, patch_size: int, in_chans: int, embed_dim: int, bias: bool = True) -> None: + def __init__(self, patch_size: int | Sequence[int], in_chans: int, embed_dim: int, bias: bool = True) -> None: super().__init__() - self.proj = nn.Conv3d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size, bias=bias) + patch = normalize_patch_size(patch_size) + self.proj = nn.Conv3d(in_chans, embed_dim, kernel_size=patch, stride=patch, bias=bias) def forward(self, x: torch.Tensor) -> torch.Tensor: """Project input volume into patch token sequence. diff --git a/packages/viscy-models/src/viscy_models/celldiff/modules/transformer.py b/packages/viscy-models/src/viscy_models/celldiff/modules/transformer.py index 40e096704..2d4211c35 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/modules/transformer.py +++ b/packages/viscy-models/src/viscy_models/celldiff/modules/transformer.py @@ -1,10 +1,14 @@ """Transformer blocks and output layers for CellDiff models.""" +from collections.abc import Sequence + import torch import torch.nn as nn from diffusers.models.attention import FeedForward from diffusers.models.attention_processor import Attention +from viscy_models.celldiff.modules.patch_embed_3d import normalize_patch_size + def modulate(x: torch.Tensor, shift: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: """Apply adaptive layer norm modulation: ``x * (1 + scale) + shift``.""" @@ -151,35 +155,35 @@ def unpatchify( x: torch.Tensor, out_channels: int, latent_grid_size: list[int], - patch_size: int, + patch_size: int | Sequence[int], ) -> torch.Tensor: """Reconstruct a 3D volume from patch tokens. Parameters ---------- x : torch.Tensor - Patch tokens of shape ``(N, T, patch_size**3 * out_channels)``. + Patch tokens of shape ``(N, T, prod(patch_size) * out_channels)``. out_channels : int Number of output channels. latent_grid_size : list[int] Latent grid dimensions ``[D, H, W]`` such that ``D*H*W == T``. - patch_size : int - Cubic patch side length. + patch_size : int | Sequence[int] + Cubic patch side length, or per-axis ``(D, H, W)`` extents. Returns ------- torch.Tensor - Reconstructed volume of shape ``(N, out_channels, D*p, H*p, W*p)``. + Reconstructed volume of shape ``(N, out_channels, D*pd, H*ph, W*pw)``. """ c = out_channels - p = patch_size + pd, ph, pw = normalize_patch_size(patch_size) d, h, w = latent_grid_size if d * h * w != x.shape[1]: raise ValueError(f"Expected {d * h * w} tokens (grid {d}x{h}x{w}), got {x.shape[1]}") - x = x.reshape(x.shape[0], d, h, w, p, p, p, c) + x = x.reshape(x.shape[0], d, h, w, pd, ph, pw, c) x = x.permute(0, 7, 1, 4, 2, 5, 3, 6) - imgs = x.reshape(x.shape[0], c, d * p, h * p, w * p) + imgs = x.reshape(x.shape[0], c, d * pd, h * ph, w * pw) return imgs @@ -190,8 +194,9 @@ class FinalLayer(nn.Module): ---------- hidden_size : int Transformer hidden dimension. - patch_size : int - Cubic patch size (output is ``patch_size**3 * out_channels`` per token). + patch_size : int | Sequence[int] + Cubic patch size, or per-axis ``(D, H, W)`` extents (output is + ``prod(patch_size) * out_channels`` per token). out_channels : int Number of output channels after unpatchifying. time_embed_dim : int | None @@ -202,13 +207,14 @@ class FinalLayer(nn.Module): def __init__( self, hidden_size: int, - patch_size: int, + patch_size: int | Sequence[int], out_channels: int, time_embed_dim: int | None = None, ) -> None: super().__init__() + pd, ph, pw = normalize_patch_size(patch_size) self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) - self.linear = nn.Linear(hidden_size, patch_size * patch_size * patch_size * out_channels, bias=True) + self.linear = nn.Linear(hidden_size, pd * ph * pw * out_channels, bias=True) if time_embed_dim is not None: self.adaLN = nn.Sequential( nn.SiLU(), diff --git a/packages/viscy-models/src/viscy_models/celldiff/unet_vit_3d.py b/packages/viscy-models/src/viscy_models/celldiff/unet_vit_3d.py index 301f0d168..9f115b903 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/unet_vit_3d.py +++ b/packages/viscy-models/src/viscy_models/celldiff/unet_vit_3d.py @@ -5,6 +5,8 @@ transformer bottleneck, CNN decoder with skip connections. """ +from collections.abc import Sequence + import torch from torch import Tensor @@ -50,8 +52,10 @@ class UNetViT3D(UNet3DBase): Feed-forward output dropout rate. num_hidden_layers : int Number of transformer blocks in the bottleneck. - patch_size : int - Cubic patch size for the 3D patch embedding. + patch_size : int | Sequence[int] + Cubic patch size for the 3D patch embedding, or per-axis + ``(D, H, W)`` extents. Use ``(1, p, p)`` at ``D=1`` for the + Z-preserving 2D configuration. """ def __init__( @@ -67,7 +71,7 @@ def __init__( dropout: float = 0.0, final_dropout: float = 0.0, num_hidden_layers: int = 2, - patch_size: int = 4, + patch_size: int | Sequence[int] = 4, ) -> None: if input_spatial_size is None: input_spatial_size = [8, 512, 512] diff --git a/packages/viscy-models/src/viscy_models/celldiff/vit_bottleneck.py b/packages/viscy-models/src/viscy_models/celldiff/vit_bottleneck.py index 6d4fc6e9f..c92f03cbe 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/vit_bottleneck.py +++ b/packages/viscy-models/src/viscy_models/celldiff/vit_bottleneck.py @@ -8,11 +8,13 @@ Requires ``diffusers`` (for Attention and FeedForward). """ +from collections.abc import Sequence + import torch import torch.nn as nn from torch import Tensor -from viscy_models.celldiff.modules.patch_embed_3d import PatchEmbed3D +from viscy_models.celldiff.modules.patch_embed_3d import PatchEmbed3D, normalize_patch_size from viscy_models.celldiff.modules.positional_embedding import get_3d_sincos_pos_embed from viscy_models.celldiff.modules.transformer import ( FinalLayer, @@ -52,8 +54,10 @@ class ViTBottleneck3D(nn.Module): Feed-forward output dropout rate. num_hidden_layers : int Number of transformer blocks. - patch_size : int - Cubic patch size for the 3D patch embedding. + patch_size : int | Sequence[int] + Cubic patch size for the 3D patch embedding, or per-axis + ``(D, H, W)`` extents. Use ``(1, p, p)`` for a Z-preserving 2D + configuration at ``D=1``. time_embed_dim : int | None Timestep embedding dimension for adaLN-Zero conditioning. Pass None for unconditional (deterministic) models. @@ -71,13 +75,13 @@ def __init__( dropout: float = 0.0, final_dropout: float = 0.0, num_hidden_layers: int = 2, - patch_size: int = 4, + patch_size: int | Sequence[int] = 4, time_embed_dim: int | None = None, ) -> None: super().__init__() self._in_channels = in_channels - self._patch_size = patch_size + self._patch_size = normalize_patch_size(patch_size) # ── Compute latent spatial size after encoder downsamples ──────── # Stride-2 convolutions require exact divisibility; floor division @@ -98,20 +102,23 @@ def __init__( latent_size = input_spatial_size[:1] + [s // factor for s in input_spatial_size[1:]] # ── Validate patch divisibility ───────────────────────────────── - for dim_val, name, orig in zip(latent_size, dim_names, input_spatial_size): - if dim_val % patch_size != 0: + # Checked per axis: a Z-preserving 2D configuration runs D=1 with a + # patch of (1, p, p), where a cubic p would be neither divisible nor + # convolvable. + for dim_val, name, orig, p in zip(latent_size, dim_names, input_spatial_size, self._patch_size): + if dim_val % p != 0: raise ValueError( f"Latent {name} dimension {dim_val} (from input {name}={orig}) " - f"is not divisible by patch_size={patch_size}. " + f"is not divisible by its patch extent {p} (patch_size={patch_size}). " f"Each spatial dimension after {num_downsamples} encoder downsamples " - f"must be divisible by patch_size." + f"must be divisible by the patch extent on that axis." ) - self.latent_grid_size = [s // patch_size for s in latent_size] + self.latent_grid_size = [s // p for s, p in zip(latent_size, self._patch_size)] # ── Patch embedding ───────────────────────────────────────────── self.img_embedding = PatchEmbed3D( - patch_size=patch_size, + patch_size=self._patch_size, in_chans=in_channels, embed_dim=hidden_size, bias=True, @@ -141,7 +148,7 @@ def __init__( # ── Output projection ─────────────────────────────────────────── self.proj_out = FinalLayer( hidden_size=hidden_size, - patch_size=patch_size, + patch_size=self._patch_size, out_channels=in_channels, time_embed_dim=time_embed_dim, ) diff --git a/packages/viscy-models/tests/test_celldiff/test_celldiff_net.py b/packages/viscy-models/tests/test_celldiff/test_celldiff_net.py index 225150911..8b84c9c1b 100644 --- a/packages/viscy-models/tests/test_celldiff/test_celldiff_net.py +++ b/packages/viscy-models/tests/test_celldiff/test_celldiff_net.py @@ -56,5 +56,5 @@ def test_wrong_cond_channels_raises(small_config): def test_indivisible_patch_size_raises(small_config): """Constructor rejects spatial sizes not divisible by patch_size after downsampling.""" - with pytest.raises(ValueError, match="not divisible by patch_size"): + with pytest.raises(ValueError, match="not divisible by its patch extent"): CELLDiffNet(**{**small_config, "input_spatial_size": [10, 64, 64]}) diff --git a/packages/viscy-models/tests/test_celldiff/test_unet_vit_3d.py b/packages/viscy-models/tests/test_celldiff/test_unet_vit_3d.py index 3260305bc..be65bc242 100644 --- a/packages/viscy-models/tests/test_celldiff/test_unet_vit_3d.py +++ b/packages/viscy-models/tests/test_celldiff/test_unet_vit_3d.py @@ -52,5 +52,5 @@ def test_wrong_spatial_raises(small_config): def test_indivisible_patch_size_raises(small_config): """Constructor rejects spatial sizes not divisible by patch_size after downsampling.""" # D=10: after 2 downsamples at stride (1,2,2), latent D=10, not divisible by patch_size=4 - with pytest.raises(ValueError, match="not divisible by patch_size"): + with pytest.raises(ValueError, match="not divisible by its patch extent"): UNetViT3D(**{**small_config, "input_spatial_size": [10, 64, 64]}) From 0d3e690f5adf07010842d03c60ef551b5d484ca8 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:40 -0700 Subject: [PATCH 08/39] perf(viscy-models): non-uniform ODE time grids for CellDiff sampling Port the time_schedule option of the CellDiff ODE integrators from dynacell-models: uniform (default, unchanged), cosine, or a Beta(p, q) grid packed toward the data end, which is what lets the DynaCell-lite recipe sample in 14 velocity evaluations. scipy joins the celldiff extra for betaincinv. Source on dynacell-models: 19765e71 (#520). Co-Authored-By: Claude Opus 5.5 --- packages/viscy-models/pyproject.toml | 2 +- .../celldiff/modules/transport/integrators.py | 37 +++++++++- .../celldiff/modules/transport/transport.py | 7 ++ .../tests/test_celldiff/test_transport.py | 73 +++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/viscy-models/pyproject.toml b/packages/viscy-models/pyproject.toml index 9a71806d9..fe563943e 100644 --- a/packages/viscy-models/pyproject.toml +++ b/packages/viscy-models/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "transformers>=4.40", ] -optional-dependencies.celldiff = [ "diffusers", "torchdiffeq" ] +optional-dependencies.celldiff = [ "diffusers", "scipy", "torchdiffeq" ] urls.Homepage = "https://github.com/mehta-lab/VisCy" urls.Issues = "https://github.com/mehta-lab/VisCy/issues" diff --git a/packages/viscy-models/src/viscy_models/celldiff/modules/transport/integrators.py b/packages/viscy-models/src/viscy_models/celldiff/modules/transport/integrators.py index db3962d28..6e16e1917 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/modules/transport/integrators.py +++ b/packages/viscy-models/src/viscy_models/celldiff/modules/transport/integrators.py @@ -6,7 +6,9 @@ from collections.abc import Callable +import numpy as np import torch +from scipy.special import betaincinv from torch import Tensor from torchdiffeq import odeint @@ -143,12 +145,20 @@ class ODESolver: sampler_type : str ODE solver method (e.g. ``"dopri5"``, ``"euler"``). num_steps : int - Number of time points (for fixed-step methods, this is the step count; - for adaptive methods, this controls the output grid). + Number of time points (for fixed-step methods, the solver takes + ``num_steps - 1`` steps on them; for adaptive methods, this controls + the output grid). atol : float Absolute error tolerance. rtol : float Relative error tolerance. + time_schedule : str or tuple of float + ``"uniform"`` (linearly spaced time points), ``"cosine"`` + (``t0 + (t1 - t0) * (1 - cos(pi * s)) / 2`` over uniform ``s``, which + clusters the points at both ends of the interval), or ``(p, q)``: the + Beta(p, q) quantiles of uniform ``s``. Cosine is Beta(0.5, 0.5) and + uniform is Beta(1, 1); ``p`` or ``q`` below 1 packs the points toward + ``t0`` or ``t1``. """ def __init__( @@ -161,11 +171,32 @@ def __init__( num_steps: int, atol: float, rtol: float, + time_schedule: str | tuple[float, float] = "uniform", ) -> None: if t0 >= t1: raise ValueError("ODE solver requires t0 < t1") self.drift = drift - self.t = torch.linspace(t0, t1, num_steps) + if time_schedule == "uniform": + self.t = torch.linspace(t0, t1, num_steps) + elif time_schedule == "cosine": + s = torch.linspace(0.0, 1.0, num_steps, dtype=torch.float64) + self.t = (t0 + (t1 - t0) * (1 - torch.cos(torch.pi * s)) / 2).float() + elif ( + isinstance(time_schedule, tuple | list) + and len(time_schedule) == 2 + and all(0 < v < np.inf for v in time_schedule) + ): + p, q = time_schedule + self.t = torch.from_numpy(t0 + (t1 - t0) * betaincinv(p, q, np.linspace(0.0, 1.0, num_steps))).float() + else: + raise ValueError( + f"time_schedule must be 'uniform', 'cosine' or finite positive (p, q), got {time_schedule!r}" + ) + if not bool((self.t.diff() > 0).all()): + raise ValueError( + f"time_schedule {time_schedule!r} puts {num_steps} time points on [{t0}, {t1}] that are not" + " strictly increasing in float32; use fewer points or a less extreme shape" + ) self.atol = atol self.rtol = rtol self.sampler_type = sampler_type diff --git a/packages/viscy-models/src/viscy_models/celldiff/modules/transport/transport.py b/packages/viscy-models/src/viscy_models/celldiff/modules/transport/transport.py index 24eefbcbd..961a58411 100644 --- a/packages/viscy-models/src/viscy_models/celldiff/modules/transport/transport.py +++ b/packages/viscy-models/src/viscy_models/celldiff/modules/transport/transport.py @@ -450,6 +450,7 @@ def sample_ode( atol: float = 1e-6, rtol: float = 1e-3, reverse: bool = False, + time_schedule: str | tuple[float, float] = "uniform", ) -> Callable: """Return an ODE sampling function. @@ -465,6 +466,11 @@ def sample_ode( Relative error tolerance. reverse : bool Whether to integrate in reverse (data to noise). + time_schedule : str or tuple of float + Spacing of the ``num_steps`` time points: ``"uniform"``, + ``"cosine"`` (``(1 - cos(pi * s)) / 2`` over uniform ``s``, dense at + both ends of the interval) or ``(p, q)`` for Beta(p, q) quantiles + (see :class:`ODESolver`). Fixed-grid solvers step on these points. Returns ------- @@ -496,6 +502,7 @@ def drift(x, t, model, **kwargs): num_steps=num_steps, atol=atol, rtol=rtol, + time_schedule=time_schedule, ) return _ode.sample diff --git a/packages/viscy-models/tests/test_celldiff/test_transport.py b/packages/viscy-models/tests/test_celldiff/test_transport.py index d92fa635b..0c7e35b0d 100644 --- a/packages/viscy-models/tests/test_celldiff/test_transport.py +++ b/packages/viscy-models/tests/test_celldiff/test_transport.py @@ -110,3 +110,76 @@ def dummy_model(x, t): # Result is a trajectory tensor from odeint. assert result.shape[0] == 5 assert result.shape[1:] == x_init.shape + + +def test_ode_solver_cosine_schedule_packs_points_at_both_ends(): + """The cosine grid keeps the endpoints and spaces points densest next to t0 and t1.""" + kwargs = dict(drift=lambda x, t, model: x, t0=0.0, t1=1.0, sampler_type="euler", num_steps=9, atol=1e-5, rtol=1e-3) + uniform = ODESolver(**kwargs).t + cosine = ODESolver(**kwargs, time_schedule="cosine").t + torch.testing.assert_close(uniform, torch.linspace(0, 1, 9)) + assert cosine[0] == 0.0 and cosine[-1] == 1.0 + gaps = cosine.diff() + assert gaps[0] < gaps[4] and gaps[-1] < gaps[4] + torch.testing.assert_close(gaps, gaps.flip(0)) + + +def test_ode_solver_beta_schedule_spans_cosine_and_uniform(): + """Beta(0.5, 0.5) is the cosine grid, Beta(1, 1) the uniform one, and p < q packs points toward t0.""" + kwargs = dict(drift=lambda x, t, model: x, t0=0.0, t1=1.0, sampler_type="euler", num_steps=8, atol=1e-5, rtol=1e-3) + torch.testing.assert_close( + ODESolver(**kwargs, time_schedule=(0.5, 0.5)).t, ODESolver(**kwargs, time_schedule="cosine").t + ) + torch.testing.assert_close(ODESolver(**kwargs, time_schedule=(1.0, 1.0)).t, ODESolver(**kwargs).t) + skewed = ODESolver(**kwargs, time_schedule=(0.3, 0.6)).t + assert skewed[0] == 0.0 and skewed[-1] == 1.0 + gaps = skewed.diff() + assert (gaps > 0).all() and gaps[0] < gaps[-1] + inner = ODESolver(**{**kwargs, "t0": 0.1, "t1": 0.9}, time_schedule=(0.3, 0.6)).t + torch.testing.assert_close(inner, 0.1 + 0.8 * skewed) + + +def test_ode_solver_rejects_a_beta_grid_that_collapses_in_float32(): + """Beta(1, 0.1) at 8 points puts its last two quantiles within float32 rounding of 1.0, which odeint + would only reject mid-predict.""" + with pytest.raises(ValueError, match="strictly increasing"): + ODESolver( + drift=lambda x, t, model: x, + t0=0.0, + t1=1.0, + sampler_type="euler", + num_steps=8, + atol=1e-5, + rtol=1e-3, + time_schedule=(1.0, 0.1), + ) + + +@pytest.mark.parametrize("schedule", ["cos", (0.0, 0.5), (0.5,), (0.5, float("nan")), (0.5, float("inf"))]) +def test_ode_solver_rejects_unknown_schedule(schedule): + """A misspelled schedule or a non-positive or non-finite Beta shape raises instead of building a bad grid.""" + with pytest.raises(ValueError, match="time_schedule"): + ODESolver( + drift=lambda x, t, model: x, + t0=0.0, + t1=1.0, + sampler_type="euler", + num_steps=5, + atol=1e-5, + rtol=1e-3, + time_schedule=schedule, + ) + + +def test_sampler_euler_with_cosine_schedule_steps_on_the_grid(): + """A fixed-grid sampler evaluates the drift once per step, at the cosine grid's left points.""" + sampler = Sampler(create_transport()) + seen = [] + + def model(x, t): + seen.append(float(t[0])) + return torch.zeros_like(x) + + sampler.sample_ode(sampling_method="euler", num_steps=5, time_schedule="cosine")(torch.randn(1, 1, 2, 2, 2), model) + expected = (1 - torch.cos(torch.pi * torch.linspace(0, 1, 5, dtype=torch.float64))) / 2 + assert seen == pytest.approx(expected[:-1].tolist(), abs=1e-6) From 859f9905f7b2dde863b68c42821481c54f9859af Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:42 -0700 Subject: [PATCH 09/39] feat(viscy-models): add the MorphEm feature extractor Port MorphEmModel from dynacell-models, the fifth deep-feature extractor of the DynaCell evaluation. transformers is imported lazily and the trust_remote_code model is pinned to a Hub commit. Source on dynacell-models: d3f6422f (#479), 4918d2ee, 13cb3363, c9c69141. Co-Authored-By: Claude Opus 5.5 --- .../viscy-models/src/viscy_models/__init__.py | 3 +- .../src/viscy_models/foundation/__init__.py | 3 +- .../src/viscy_models/foundation/morphem.py | 160 ++++++++++++++++++ .../tests/test_foundation/test_morphem.py | 110 ++++++++++++ 4 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 packages/viscy-models/src/viscy_models/foundation/morphem.py create mode 100644 packages/viscy-models/tests/test_foundation/test_morphem.py diff --git a/packages/viscy-models/src/viscy_models/__init__.py b/packages/viscy-models/src/viscy_models/__init__.py index 436054985..0b66cee5a 100644 --- a/packages/viscy-models/src/viscy_models/__init__.py +++ b/packages/viscy-models/src/viscy_models/__init__.py @@ -6,7 +6,7 @@ from viscy_models.components.heads import MLP, CosineClassifier from viscy_models.contrastive import ContrastiveEncoder, NTXentHCL, ResNet3dEncoder -from viscy_models.foundation import DINOv3Model, OpenPhenomModel +from viscy_models.foundation import DINOv3Model, MorphEmModel, OpenPhenomModel from viscy_models.unet import FullyConvolutionalMAE, Unet2d, Unet3d, Unet25d, UNeXt2 from viscy_models.vae import BetaVae25D, BetaVaeMonai @@ -16,6 +16,7 @@ "ContrastiveEncoder", "CosineClassifier", "DINOv3Model", + "MorphEmModel", "NTXentHCL", "OpenPhenomModel", "FullyConvolutionalMAE", diff --git a/packages/viscy-models/src/viscy_models/foundation/__init__.py b/packages/viscy-models/src/viscy_models/foundation/__init__.py index 12f1da3ee..fa52ba881 100644 --- a/packages/viscy-models/src/viscy_models/foundation/__init__.py +++ b/packages/viscy-models/src/viscy_models/foundation/__init__.py @@ -2,6 +2,7 @@ from viscy_models.foundation.cell_dino import CellDinoModel from viscy_models.foundation.dinov3 import DINOv3Model +from viscy_models.foundation.morphem import MorphEmModel from viscy_models.foundation.openphenom import OpenPhenomModel -__all__ = ["CellDinoModel", "DINOv3Model", "OpenPhenomModel"] +__all__ = ["CellDinoModel", "DINOv3Model", "MorphEmModel", "OpenPhenomModel"] diff --git a/packages/viscy-models/src/viscy_models/foundation/morphem.py b/packages/viscy-models/src/viscy_models/foundation/morphem.py new file mode 100644 index 000000000..f94cfe253 --- /dev/null +++ b/packages/viscy-models/src/viscy_models/foundation/morphem.py @@ -0,0 +1,160 @@ +"""MorphEm foundation model wrapper for frozen feature extraction.""" + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch import Tensor + + +class MorphEmModel(nn.Module): + """Wrap CaicedoLab/MorphEm (DINO-pretrained ViT-S/16 on microscopy). + + MorphEm is a single-channel ViT: each microscopy channel is encoded + independently and the CLS tokens are reduced to a fixed embedding. + :meth:`preprocess_2d` handles Z-squeeze, resize to 224, and per-image + per-channel spatial z-score (the published ``PerImageNormalize`` recipe). + + Parameters + ---------- + model_name : str + HuggingFace id (``"CaicedoLab/MorphEm"``) or a local snapshot dir. + revision : str | None + Hub commit SHA (or tag) to load. MorphEm ships its ViT as + ``trust_remote_code`` Python, so an unpinned id would execute + whatever is at the repository head on a cache miss. Ignored for a + local snapshot dir. + img_size : int + Spatial size inputs are resized to, by default 224. + freeze : bool + If ``True`` (default), backbone params are frozen and kept in eval mode. + projection : nn.Module | None + Optional projection head applied to the pooled embedding. + """ + + def __init__( + self, + model_name: str, + revision: str | None = None, + img_size: int = 224, + freeze: bool = True, + projection: nn.Module | None = None, + ) -> None: + super().__init__() + + import transformers + from transformers import AutoModel + + # transformers 5.x compatibility shim. MorphEm's trust_remote_code + # VisionTransformer (authored against transformers 4.x) + # never sets `all_tied_weights_keys`, which 5.x's meta-device loader + # (_move_missing_keys_from_meta_to_device) requires -> AttributeError on load. + # Provide a no-tie class-level default; models whose tie_weights() runs set + # their own instance attr, which shadows this, so other loads are unaffected. + # Idempotent: only set when absent. Verified to load CaicedoLab/MorphEm on 5.9.0. + if not isinstance(getattr(transformers.PreTrainedModel, "all_tied_weights_keys", None), dict): + transformers.PreTrainedModel.all_tied_weights_keys = {} + + self.model = AutoModel.from_pretrained(model_name, revision=revision, trust_remote_code=True) + self.target_size = (img_size, img_size) + self.projection = projection + # Expose embed dim for foundation-wrapper parity (dynaclr foundation_engine + # may read it pre-forward). Mirror the katamari reference's 3-tier + # `_infer_embed_dim` (embed_dim -> num_features -> patch_embed.proj.out_channels); + # raise loud if none resolve rather than silently defaulting. + # Verified at load: CaicedoLab/MorphEm self-reports embed_dim == num_features == 384. + self.embed_dim = self._infer_embed_dim(self.model) + + self.freeze = freeze + if freeze: + self.model.requires_grad_(False) + self.model.eval() + # Single-channel contract: MorphEm is a 1-channel ViT (CHAMMI feeds one + # channel at a time). Assert at load so a 3-channel patch_embed surfaces + # immediately instead of erroring deep in forward(). + proj = getattr(getattr(self.model, "patch_embed", None), "proj", None) + in_ch = getattr(proj, "in_channels", None) + if in_ch is not None and in_ch != 1: + raise ValueError( + f"MorphEm expected a single-channel patch_embed (in_channels=1), got {in_ch}. " + "The single-channel feed in forward() would error; revisit channel handling." + ) + + @staticmethod + def _infer_embed_dim(model: nn.Module) -> int: + """Resolve the backbone embedding dimension from common ViT attributes.""" + for attr in ("embed_dim", "num_features"): + value = getattr(model, attr, None) + if value is not None: + return int(value) + proj = getattr(getattr(model, "patch_embed", None), "proj", None) + out_channels = getattr(proj, "out_channels", None) + if out_channels is not None: + return int(out_channels) + raise ValueError("Unable to infer embed_dim from the MorphEm backbone.") + + def train(self, mode: bool = True) -> "MorphEmModel": + """Keep the backbone in eval when frozen.""" + super().train(mode) + if self.freeze: + self.model.eval() + return self + + def preprocess_2d(self, x: Tensor) -> Tensor: + """Convert a raw dataloader tensor to MorphEm input. + + Squeezes a singleton Z (middle slice if Z>1), applies per-image + per-channel spatial z-score (zero mean, unit std), THEN resizes to + ``target_size`` — matching the published recipe ordering + ``Compose([PerImageNormalize(), Resize(224)])`` (normalize before + resize; reversing the order shifts the post-interp mean/scale). + + Parameters + ---------- + x : Tensor + ``(B, C, D, H, W)`` or ``(B, C, H, W)``. + + Returns + ------- + Tensor + ``(B, C, target, target)`` float ready for :meth:`forward`. + """ + if x.ndim == 5: + x = x[:, :, 0] if x.shape[2] == 1 else x[:, :, x.shape[2] // 2] + x = x.to(torch.float32) + # PerImageNormalize: per-image per-channel spatial z-score. Uses the + # (x-m)/(std+eps) form matching CellDinoModel.preprocess_2d (the VisCy + # self_normalize convention) — numerically equivalent to the reference + # InstanceNorm2d's (x-m)/sqrt(var+eps) for any non-degenerate crop. + m = x.mean(dim=(-2, -1), keepdim=True) + s = x.std(dim=(-2, -1), unbiased=False, keepdim=True) + x = (x - m) / (s + 1e-7) + # Resize AFTER normalize (published Compose order). antialias=True matches + # the reference v2.Resize(antialias=True); inert on upscale, correct on downscale. + if x.shape[-2:] != self.target_size: + x = F.interpolate(x, size=self.target_size, mode="bilinear", align_corners=False, antialias=True) + return x + + def forward(self, x: Tensor) -> tuple[Tensor, Tensor]: + """Encode a (preprocessed) image batch to a channel-mean CLS embedding. + + Each channel is encoded independently (MorphEm is single-channel), + and the CLS tokens are mean-pooled over channels to a fixed ``(B, D)``. + + Parameters + ---------- + x : Tensor + ``(B, C, H, W)``. + + Returns + ------- + tuple[Tensor, Tensor] + ``(features, projection(features) or features)``, each ``(B, D)``. + """ + b, c = x.shape[0], x.shape[1] + x = x.reshape(b * c, 1, x.shape[-2], x.shape[-1]) + out = self.model.forward_features(x) + cls = out["x_norm_clstoken"] # (B*C, D) + cls = cls.reshape(b, c, -1).mean(dim=1) # (B, D) + if self.projection is not None: + return cls, self.projection(cls) + return cls, cls diff --git a/packages/viscy-models/tests/test_foundation/test_morphem.py b/packages/viscy-models/tests/test_foundation/test_morphem.py new file mode 100644 index 000000000..a403c17ce --- /dev/null +++ b/packages/viscy-models/tests/test_foundation/test_morphem.py @@ -0,0 +1,110 @@ +"""Tests for the MorphEm foundation wrapper. + +``transformers.AutoModel`` is stubbed so the test never touches the network or +the HF cache; it exercises the real :class:`MorphEmModel` preprocessing and +channel-mean forward contract against a fake ViT-S backbone. +""" + +import pytest +import torch + +from viscy_models.foundation import MorphEmModel + +_EMBED_DIM = 384 +_N_PATCHES = 196 # (224 / 16) ** 2 + + +class _StubBackbone(torch.nn.Module): + """Minimal MorphEm stand-in: single-channel patch embed + forward_features.""" + + def __init__(self, embed_dim: int = _EMBED_DIM) -> None: + super().__init__() + self.embed_dim = embed_dim + self.num_features = embed_dim + self.patch_embed = torch.nn.Module() + self.patch_embed.proj = torch.nn.Conv2d(1, embed_dim, kernel_size=16, stride=16) + + def forward_features(self, x: torch.Tensor) -> dict[str, torch.Tensor]: + n = x.shape[0] + # Make the CLS token depend on the input so embeddings are non-constant. + cls = x.flatten(1).mean(dim=1, keepdim=True).expand(n, self.embed_dim).clone() + return { + "x_norm_clstoken": cls, + "x_norm_patchtokens": torch.zeros(n, _N_PATCHES, self.embed_dim), + } + + +@pytest.fixture +def captured_load(monkeypatch): + """Stub ``AutoModel.from_pretrained`` and capture its call args.""" + captured: dict = {} + + def _fake_from_pretrained(name, **kwargs): + captured["name"] = name + captured["kwargs"] = kwargs + return _StubBackbone() + + monkeypatch.setattr("transformers.AutoModel.from_pretrained", _fake_from_pretrained) + return captured + + +def test_load_passes_trust_remote_code(captured_load): + """The wrapper loads via trust_remote_code and infers the embed dim.""" + model = MorphEmModel(model_name="CaicedoLab/MorphEm") + assert captured_load["name"] == "CaicedoLab/MorphEm" + assert captured_load["kwargs"]["trust_remote_code"] is True + assert captured_load["kwargs"]["revision"] is None + assert model.embed_dim == _EMBED_DIM + + +def test_load_forwards_pinned_revision(captured_load): + """A pinned Hub commit reaches ``from_pretrained`` so the remote code is frozen.""" + MorphEmModel(model_name="CaicedoLab/MorphEm", revision="0e8d58787421f83f975634d72420d85c5dfc9c2c") + assert captured_load["kwargs"]["revision"] == "0e8d58787421f83f975634d72420d85c5dfc9c2c" + assert captured_load["kwargs"]["trust_remote_code"] is True + + +def test_preprocess_2d_normalizes_and_resizes(captured_load): + """5-D input is Z-squeezed, per-image z-scored, then resized to 224.""" + model = MorphEmModel(model_name="CaicedoLab/MorphEm") + x = torch.randn(2, 1, 1, 40, 40) + out = model.preprocess_2d(x) + assert out.shape == (2, 1, 224, 224) + # Per-image z-score happens before resize; upscale bilinear keeps mean ~0. + per_image_mean = out.mean(dim=(-2, -1)) + assert torch.all(per_image_mean.abs() < 0.05) + assert torch.all(out.std(dim=(-2, -1)) > 0.1) + + +def test_forward_returns_features_projection_tuple(captured_load): + """Forward on a single-channel batch returns a (features, proj) (B, D) tuple.""" + model = MorphEmModel(model_name="CaicedoLab/MorphEm") + x = torch.randn(2, 1, 224, 224) + features, projection = model(x) + assert features.shape == (2, _EMBED_DIM) + assert projection.shape == (2, _EMBED_DIM) + assert torch.equal(features, projection) # no projection head -> identity + + +def test_forward_channel_mean(captured_load): + """Multi-channel input is encoded per-channel and mean-pooled to (B, D).""" + model = MorphEmModel(model_name="CaicedoLab/MorphEm") + x = torch.randn(2, 3, 224, 224) + features, _ = model(x) + assert features.shape == (2, _EMBED_DIM) + + +def test_single_channel_assert(monkeypatch): + """A 3-channel patch_embed is rejected at load time.""" + + class _ThreeChannel(_StubBackbone): + def __init__(self) -> None: + super().__init__() + self.patch_embed.proj = torch.nn.Conv2d(3, _EMBED_DIM, kernel_size=16, stride=16) + + monkeypatch.setattr( + "transformers.AutoModel.from_pretrained", + lambda name, **kwargs: _ThreeChannel(), + ) + with pytest.raises(ValueError, match="single-channel"): + MorphEmModel(model_name="CaicedoLab/MorphEm") From 1bf182163196223049ac0a442ba67cfeb7c83af8 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:51 -0700 Subject: [PATCH 10/39] feat(viscy-models): let the PatchGAN discriminator preserve the Z axis Port preserve_z from dynacell-models, so the 3D PatchGAN runs on Z=1 inputs for pix2pix2d (default off, unchanged). Source on dynacell-models: c2d04813. Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_models/gan/patchgan3d.py | 29 ++++++- .../tests/test_gan/test_patchgan3d.py | 76 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/viscy-models/src/viscy_models/gan/patchgan3d.py b/packages/viscy-models/src/viscy_models/gan/patchgan3d.py index de6ff00a1..e53c420d2 100644 --- a/packages/viscy-models/src/viscy_models/gan/patchgan3d.py +++ b/packages/viscy-models/src/viscy_models/gan/patchgan3d.py @@ -45,6 +45,13 @@ class PatchGAN3D(nn.Module): channel. Default is 64. use_spectral_norm : bool, optional If True, apply ``spectral_norm`` to every conv. Default is True. + preserve_z : bool, optional + If True, layers 1-4 take ``kernel_size=(1, 4, 4)``, ``padding=(0, 1, 1)`` + and unit Z stride, so the Z extent passes through untouched. Required + at ``Z=1``: the default cubic ``kernel_size=4`` with ``padding=1`` + gives a padded Z extent of 3 against a size-4 kernel, which raises. + Leave False for 3D, where it is the published topology. Default is + False. Returns ------- @@ -59,6 +66,7 @@ def __init__( in_channels: int = 2, base_channels: int = 64, use_spectral_norm: bool = True, + preserve_z: bool = False, ) -> None: super().__init__() c1 = base_channels @@ -66,9 +74,16 @@ def __init__( c3 = base_channels * 4 c4 = base_channels * 8 + # Z geometry of layers 1-4. The 3D branch reproduces the published + # cubic kernel exactly; the Z-preserving branch collapses the kernel + # and padding on that axis and drops the Z stride of layers 3-4. + kernel = (1, 4, 4) if preserve_z else 4 + padding = (0, 1, 1) if preserve_z else 1 + stride_z = 1 if preserve_z else 2 + self.layer1 = nn.Sequential( _maybe_spectral_norm( - nn.Conv3d(in_channels, c1, kernel_size=4, stride=(1, 2, 2), padding=1), + nn.Conv3d(in_channels, c1, kernel_size=kernel, stride=(1, 2, 2), padding=padding), use_spectral_norm, ), nn.LeakyReLU(0.2, inplace=True), @@ -76,7 +91,7 @@ def __init__( self.layer2 = nn.Sequential( _maybe_spectral_norm( - nn.Conv3d(c1, c2, kernel_size=4, stride=(1, 2, 2), padding=1), + nn.Conv3d(c1, c2, kernel_size=kernel, stride=(1, 2, 2), padding=padding), use_spectral_norm, ), nn.InstanceNorm3d(c2, affine=True), @@ -85,7 +100,7 @@ def __init__( self.layer3 = nn.Sequential( _maybe_spectral_norm( - nn.Conv3d(c2, c3, kernel_size=4, stride=(2, 2, 2), padding=1), + nn.Conv3d(c2, c3, kernel_size=kernel, stride=(stride_z, 2, 2), padding=padding), use_spectral_norm, ), nn.InstanceNorm3d(c3, affine=True), @@ -94,7 +109,7 @@ def __init__( self.layer4 = nn.Sequential( _maybe_spectral_norm( - nn.Conv3d(c3, c4, kernel_size=4, stride=(2, 2, 2), padding=1), + nn.Conv3d(c3, c4, kernel_size=kernel, stride=(stride_z, 2, 2), padding=padding), use_spectral_norm, ), nn.InstanceNorm3d(c4, affine=True), @@ -153,6 +168,10 @@ class MultiScalePatchGAN3D(nn.Module): use_spectral_norm : bool, optional If True, apply spectral normalization to every conv in each scale. Default is True. + preserve_z : bool, optional + Forwarded to every :class:`PatchGAN3D`. Set True for a Z-preserving + (``Z=1``) discriminator. The inter-scale pooling is already + YX-only, so no other change is needed. Default is False. Returns ------- @@ -166,6 +185,7 @@ def __init__( base_channels: int = 64, num_scales: int = 2, use_spectral_norm: bool = True, + preserve_z: bool = False, ) -> None: super().__init__() if num_scales < 1: @@ -177,6 +197,7 @@ def __init__( in_channels=in_channels, base_channels=base_channels, use_spectral_norm=use_spectral_norm, + preserve_z=preserve_z, ) for _ in range(num_scales) ] diff --git a/packages/viscy-models/tests/test_gan/test_patchgan3d.py b/packages/viscy-models/tests/test_gan/test_patchgan3d.py index b2bc8af9d..d7bf48915 100644 --- a/packages/viscy-models/tests/test_gan/test_patchgan3d.py +++ b/packages/viscy-models/tests/test_gan/test_patchgan3d.py @@ -1,5 +1,6 @@ """Tests for 3D PatchGAN discriminators.""" +import pytest import torch from viscy_models.gan import MultiScalePatchGAN3D, PatchGAN3D @@ -63,3 +64,78 @@ def test_multi_scale_patchgan3d_single_scale_ablation(): out = model(x) assert isinstance(out, list) assert len(out) == 1 + + +def test_patchgan3d_default_raises_at_unit_depth(): + """The cubic default cannot run at Z=1 — this is what preserve_z exists for.""" + model = PatchGAN3D(in_channels=2) + with pytest.raises(RuntimeError, match="Kernel size can't be greater than actual input size"): + model(torch.randn(1, 2, 1, 64, 64)) + + +def test_patchgan3d_preserve_z_runs_at_unit_depth(): + """preserve_z=True accepts Z=1 and keeps the Z extent at 1.""" + model = PatchGAN3D(in_channels=2, preserve_z=True) + y = model(torch.randn(2, 2, 1, 64, 64)) + assert y.ndim == 5 + assert y.shape[:2] == (2, 1) + assert y.shape[2] == 1, f"Z must pass through, got {y.shape}" + assert y.shape[-1] == y.shape[-2], f"Expected square YX, got {y.shape}" + + +def test_patchgan3d_preserve_z_keeps_depth_for_a_slab() -> None: + """Z is preserved, not merely tolerated: a 5-plane slab stays 5 planes. + + Layers 3-4 stride Z by 2 in the 3D topology, which would halve a slab + twice. ``preserve_z`` drops that stride, so the discriminator never mixes + planes the Z-preserving generator kept separate. + """ + model = PatchGAN3D(in_channels=2, preserve_z=True) + y = model(torch.randn(1, 2, 5, 64, 64)) + assert y.shape[2] == 5, f"expected Z=5 through the discriminator, got {y.shape}" + + +def test_patchgan3d_preserve_z_leaves_the_3d_topology_untouched() -> None: + """preserve_z=False must reproduce the published 3D discriminator exactly. + + Guards the shared code path: the 2D arm was added by parameterizing the + same convs the 14 pix2pix3d leaves train, so an accidental change to the + default would silently alter every published GAN number. + """ + torch.manual_seed(0) + default = PatchGAN3D(in_channels=2) + torch.manual_seed(0) + explicit = PatchGAN3D(in_channels=2, preserve_z=False) + for (name, a), (_, b) in zip(default.named_parameters(), explicit.named_parameters(), strict=True): + assert torch.equal(a, b), f"{name} differs" + for layer in (default.layer1, default.layer2): + assert layer[0].kernel_size == (4, 4, 4) + assert layer[0].padding == (1, 1, 1) + assert layer[0].stride == (1, 2, 2) + for layer in (default.layer3, default.layer4): + assert layer[0].kernel_size == (4, 4, 4) + assert layer[0].stride == (2, 2, 2) + x = torch.randn(1, 2, 8, 64, 64) + with torch.no_grad(): + assert torch.equal(default(x), explicit(x)) + + +def test_multi_scale_patchgan3d_preserve_z_at_unit_depth() -> None: + """The multi-scale wrapper forwards preserve_z and pools YX only.""" + model = MultiScalePatchGAN3D(in_channels=2, num_scales=2, preserve_z=True) + out = model(torch.randn(1, 2, 1, 128, 128)) + assert len(out) == 2 + assert all(o.shape[2] == 1 for o in out), [tuple(o.shape) for o in out] + assert out[1].shape[-1] < out[0].shape[-1] + + +def test_patchgan3d_preserve_z_gradient_flow() -> None: + """Every conv weight receives a gradient at Z=1.""" + model = PatchGAN3D(in_channels=2, preserve_z=True) + model(torch.randn(2, 2, 1, 64, 64)).sum().backward() + missing = [ + name + for name, param in model.named_parameters() + if "weight" in name and (param.grad is None or param.grad.abs().sum().item() == 0.0) + ] + assert not missing, f"no gradient reached {missing}" From f5541f509490c1398b340db67f9867adfe1c356b Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:48:52 -0700 Subject: [PATCH 11/39] feat(viscy-models): UNet3D downsample_z for FNet-2D; reject non-5D input Port from dynacell-models: - UNet3D(downsample_z=True): False keeps Z through the encoder, which is how FNet2D runs on single planes (default unchanged); - UNet3DBase.forward raises on non-5D input, and a comment flags the ConvTranspose checkerboard artifact. Source on dynacell-models: a556bc9d (#484), 83a1d69c, 16b1d315. Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_models/unet/unet3d.py | 18 ++++++++---- .../src/viscy_models/unet/unet3d_base.py | 29 +++++++++++++++++++ .../tests/test_unet/test_unet3d.py | 27 +++++++++++++++++ .../tests/test_unet/test_unet3d_base.py | 7 +++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/viscy-models/src/viscy_models/unet/unet3d.py b/packages/viscy-models/src/viscy_models/unet/unet3d.py index ed32e1396..a190afbb1 100644 --- a/packages/viscy-models/src/viscy_models/unet/unet3d.py +++ b/packages/viscy-models/src/viscy_models/unet/unet3d.py @@ -39,9 +39,10 @@ class Unet3d(UNet3DBase): FNet-configured preset of the unified 3D U-Net base with BatchNorm, ReLU activations, non-residual double-conv blocks, and a convolutional - bottleneck. Downsamples all three spatial dimensions. + bottleneck. Downsamples Y and X, and (when ``downsample_z=True``) Z. - All spatial dimensions (Z, Y, X) must be divisible by ``2**depth``. + Each downsampled spatial dimension must be divisible by ``2**depth``: Y and + X always, and Z only when ``downsample_z=True``. Parameters ---------- @@ -56,8 +57,14 @@ class Unet3d(UNet3DBase): in_stack_depth : int or None Z-window size. Stored for engine compatibility (``example_input_array``, ``DivisiblePad``, sliding window prediction). - The model itself handles arbitrary Z as long as it is divisible - by ``2**depth``. + The model itself handles arbitrary Z; when ``downsample_z=True`` the Z + size must be divisible by ``2**depth`` (no constraint when ``False``). + downsample_z : bool + Whether to downsample the Z axis in the encoder (``True`` preserves the + FNet3D behavior). Set ``False`` for a Z-preserving 2D-style network + (stride ``(1, 2, 2)``, ``ConvTranspose3d`` kernel ``(1, 3, 3)``), which + is required to run at ``Z=1`` without tripping the base + ``D % 2**depth`` divisibility check. """ def __init__( @@ -67,6 +74,7 @@ def __init__( depth: int = 4, mult_chan: int = 32, in_stack_depth: int | None = None, + downsample_z: bool = True, ) -> None: dims = [mult_chan * (2**i) for i in range(depth + 1)] bottleneck = ConvBottleneck3D(dims[-1], residual=False, norm="batch", activation="relu") @@ -76,7 +84,7 @@ def __init__( dims=dims, num_res_block=[1] * depth, bottleneck=bottleneck, - downsample_z=True, + downsample_z=downsample_z, residual=False, norm="batch", activation="relu", diff --git a/packages/viscy-models/src/viscy_models/unet/unet3d_base.py b/packages/viscy-models/src/viscy_models/unet/unet3d_base.py index d5aae7ef5..2be04af97 100644 --- a/packages/viscy-models/src/viscy_models/unet/unet3d_base.py +++ b/packages/viscy-models/src/viscy_models/unet/unet3d_base.py @@ -93,6 +93,27 @@ def __init__( self._cond_inconv = nn.Conv3d(cond_channels, dims[0], kernel_size=3, stride=1, padding=1) # ── Stride configuration ──────────────────────────────────────── + # KNOWN ARTIFACT: kernel 3 with stride 2 is the checkerboard condition + # (Odena et al., "Deconvolution and Checkerboard Artifacts", 2016). + # 3 is not divisible by 2, so kernel placements overlap unevenly and + # each ConvTranspose imprints a 2-periodic modulation; three stacked + # upsamples put energy at 2, 4 and 8 px. It is present IN distribution, + # not only out of it: on an iPSC-trained membrane prediction of an iPSC + # FOV the period-2 component measured 216x its ground-truth amplitude + # and period-4 67x. Out of distribution it grows further -- on an + # A549-trained ER prediction of an iPSC FOV the 4 px peak reached 16x + # its spectral background. It is visible in every + # model built on this base (UNetViT3D and FNet3D alike), which is how + # it was traced here -- a shared artifact across two otherwise + # unrelated architectures. + # + # The clean fix is kernel_size 4 with output_padding 0 (kernel + # divisible by stride -> uniform overlap), or nearest-neighbour + # Upsample + Conv3d. Both change weight shapes and so invalidate every + # existing checkpoint, which is why this is documented rather than + # changed: the published benchmark numbers are tied to these weights. + # For inference-time suppression on existing checkpoints see + # ``dynacell.engine._phase_shift_average``. if downsample_z: down_stride = (2, 2, 2) up_kwargs = dict(kernel_size=3, stride=(2, 2, 2), padding=1, output_padding=1) @@ -158,7 +179,15 @@ def forward(self, x: Tensor, cond: Tensor | None = None, t: Tensor | None = None ------- Tensor Output tensor of shape ``(B, out_channels, D, H, W)``. + + Raises + ------ + ValueError + If ``x`` is not a 5D tensor, or a downsampled spatial dimension is + not divisible by ``2**num_blocks``. """ + if x.ndim != 5: + raise ValueError(f"Expected 5D input (B, C, D, H, W), got {x.ndim}D.") for dim_name, size in zip(("D", "H", "W"), x.shape[2:]): if self.downsamples_z or dim_name != "D": if size % self._divisor != 0: diff --git a/packages/viscy-models/tests/test_unet/test_unet3d.py b/packages/viscy-models/tests/test_unet/test_unet3d.py index 1e1ed76e5..58bf4e50c 100644 --- a/packages/viscy-models/tests/test_unet/test_unet3d.py +++ b/packages/viscy-models/tests/test_unet/test_unet3d.py @@ -67,6 +67,33 @@ def test_engine_attributes(): assert model.downsamples_z is True +def test_downsample_z_default_preserves_fnet3d(): + """Default downsample_z keeps the FNet3D Z-downsampling behavior.""" + model = Unet3d(depth=4, mult_chan=32, in_stack_depth=32) + assert model.downsamples_z is True + + +def test_downsample_z_false_z1_forward(): + """downsample_z=False enables the FNet2D use case: a Z=1 forward runs and preserves Z. + + With downsample_z=True (the FNet3D default) a Z=1 input trips the base + ``D % 2**depth`` divisor check; the Z-preserving path must skip that check. + """ + model = Unet3d(in_channels=1, out_channels=1, depth=4, mult_chan=32, downsample_z=False) + assert model.downsamples_z is False + x = torch.randn(2, 1, 1, 64, 64) # Y,X divisible by 2**4=16; Z=1 + y = model(x) + assert y.shape == (2, 1, 1, 64, 64) + + +def test_downsample_z_false_skips_z_divisor_check(): + """With downsample_z=False, Z need not be divisible by 2**depth (only Y/X are checked).""" + model = Unet3d(in_channels=1, out_channels=1, depth=2, mult_chan=16, downsample_z=False) + x = torch.randn(1, 1, 3, 16, 16) # Z=3 not divisible by 2^2=4, but Z is not downsampled + y = model(x) + assert y.shape == (1, 1, 3, 16, 16) + + def test_state_dict_keys(): """State dict uses iterative encoder-decoder key structure.""" model = Unet3d(in_channels=1, out_channels=1, depth=2, mult_chan=16) diff --git a/packages/viscy-models/tests/test_unet/test_unet3d_base.py b/packages/viscy-models/tests/test_unet/test_unet3d_base.py index cd54eb260..aa6c59dd2 100644 --- a/packages/viscy-models/tests/test_unet/test_unet3d_base.py +++ b/packages/viscy-models/tests/test_unet/test_unet3d_base.py @@ -173,3 +173,10 @@ def test_dims_mismatch_raises(): """len(dims) != len(num_res_block) + 1 raises ValueError.""" with pytest.raises(ValueError, match="len\\(dims\\)"): _make_base(dims=(16, 32, 64), num_res_block=(1,)) + + +def test_forward_non_5d_input_raises(): + """A non-5D input raises a clear ValueError, not a confusing Conv3d channel error.""" + model = _make_base() + with pytest.raises(ValueError, match="Expected 5D input"): + model(torch.randn(2, 1, 16, 16)) # 4D: a squeezed Z dim From dc891f5412bccb02b3df179333332f8dc8634155 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:49:02 -0700 Subject: [PATCH 12/39] fix(viscy-transforms): stop BatchedRandAffined blanking singleton axes Port 9640537d from dynacell-models: a size-1 spatial axis (Z=1 for 2D models) gets an identity row in the affine, instead of being resampled off the volume and zeroed. Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_transforms/_affine.py | 39 +++++- .../viscy-transforms/tests/test_affine.py | 121 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/packages/viscy-transforms/src/viscy_transforms/_affine.py b/packages/viscy-transforms/src/viscy_transforms/_affine.py index 34f51100d..8c97524d2 100644 --- a/packages/viscy-transforms/src/viscy_transforms/_affine.py +++ b/packages/viscy-transforms/src/viscy_transforms/_affine.py @@ -20,10 +20,17 @@ class _PaddedRandomAffine3D(RandomAffine3D): - """RandomAffine3D with configurable padding_mode. + """RandomAffine3D with configurable padding_mode and depth-1 safety. Kornia 0.8.x hard-codes ``padding_mode='zeros'`` in apply_transform. - This subclass overrides that call to forward the user-specified mode. + This subclass overrides that call to forward the user-specified mode, + and forces the matrix row of any singleton spatial axis to identity. + + Note the neutralized row is applied to a copy, so + ``self._transform_matrix`` (and therefore + :meth:`RandomAffine3D.inverse`) still records the un-neutralized matrix + on a singleton axis. Nothing in VisCy reads those, but do not rely on + them for such inputs without re-applying the same neutralization. """ def __init__(self, *args: object, padding_mode: str = "zeros", **kwargs: object) -> None: @@ -37,9 +44,29 @@ def apply_transform( flags: dict, transform: Tensor | None = None, ) -> Tensor: + # clone() is load-bearing: transform[:, :3] is a VIEW into the same + # tensor kornia stores as self._transform_matrix right after this + # returns, so mutating it in place would corrupt the recorded + # transform (and hence RandomAffine3D.inverse()). It is not about + # contiguity -- warp_affine3d accepts the non-contiguous view -- and + # not about the source/target pairing, which kornia rebuilds per key. + matrix = transform[:, :3].clone() + # Kornia normalizes pixel coordinates with a 2 / (size - 1) scale and + # guards a singleton axis by dividing by eps=1e-14 instead, so that + # axis' scale blows up to ~2e14. Any non-identity entry in the + # corresponding matrix row then pushes every sampled coordinate out of + # bounds and padding_mode="zeros" blanks the volume. A singleton axis + # has no extent to rotate, shear, scale or translate along, so identity + # is the correct row. Other rows are left alone and still warp + # normally; their terms for this axis are divided by the same 2e14 and + # vanish. Rows are ordered (x, y, z), i.e. (W, H, D). + for row, size in enumerate((input.shape[-1], input.shape[-2], input.shape[-3])): + if size == 1: + matrix[:, row] = 0.0 + matrix[:, row, row] = 1.0 return warp_affine3d( input, - transform[:, :3, :], + matrix, (input.shape[-3], input.shape[-2], input.shape[-1]), flags["resample"].name.lower(), padding_mode=self._padding_mode, @@ -289,6 +316,12 @@ def _scale_z_shear_facets(params: dict[str, Tensor], shape: torch.Size) -> dict[ produces ~64x more displacement than the same angle across Z=8. This method reduces Z-related facets so the pixel displacement in Z is proportional to the Z depth. + + This is augmentation *policy* and is opt-out via ``scale_z_shear``. + Depth-1 safety is a correctness invariant handled separately, in + ``_PaddedRandomAffine3D.apply_transform`` — do not add depth-1 + handling here, where it would cover shear only and could be + switched off. """ z_depth = shape[2] yx_size = max(shape[3], shape[4]) diff --git a/packages/viscy-transforms/tests/test_affine.py b/packages/viscy-transforms/tests/test_affine.py index c9852ca3f..08a5195f4 100644 --- a/packages/viscy-transforms/tests/test_affine.py +++ b/packages/viscy-transforms/tests/test_affine.py @@ -1,5 +1,6 @@ import math +import pytest import torch from viscy_transforms import BatchedRandAffined @@ -302,3 +303,123 @@ def test_safe_crop_size_preserves_key_consistency(): torch.manual_seed(42) out = t(sample) assert torch.equal(out["source"], out["target"]) + + +def test_affine_depth_one_preserves_data(): + """A depth-1 volume must not be blanked by the affine. + + Kornia normalizes pixel coordinates with a ``2 / (D - 1)`` scale, which + at ``D == 1`` becomes ~2e14. Any non-identity entry in the transform's Z + row is then multiplied by that factor, pushing every sampled coordinate + out of bounds so ``padding_mode="zeros"`` returns an all-zero tensor. + + The 2D training overlays (``z_window_size: 1``) use exactly this shear + configuration, so a regression here silently blanks training data. + """ + t = BatchedRandAffined( + keys=["source", "target"], + prob=1.0, + rotate_range=[0, 0, 0], + shear_range=[0.0, 0.05, 0.05], + scale_range=[[1, 1], [0.5, 1.5], [0.5, 1.5]], + ) + base = torch.ones(4, 1, 1, 64, 64) + sample = {"source": base.clone(), "target": base.clone()} + + torch.manual_seed(0) + out = t(sample) + + # The regression signature is exactly 0.0. A fractional threshold would be + # seed-fragile: scale_range lets YX shrink to 0.5, legitimately taking a + # sample to ~0.30 nonzero on some seeds. + nonzero = (out["source"] != 0).float().mean(dim=(1, 2, 3, 4)) + assert (nonzero > 0).all(), f"depth-1 volume was blanked: per-sample nonzero {nonzero.tolist()}" + assert torch.equal(out["source"], out["target"]) + + +def test_affine_depth_one_still_transforms_in_plane(): + """Neutralizing the Z row must not disable the YX transform at D=1.""" + t = BatchedRandAffined( + keys=["source"], + prob=1.0, + rotate_range=[3.14, 0, 0], + scale_range=[[1, 1], [0.5, 1.5], [0.5, 1.5]], + ) + base = torch.zeros(2, 1, 1, 64, 64) + base[:, :, :, 16:48, 16:48] = 1.0 + + torch.manual_seed(0) + out = t({"source": base.clone()})["source"] + + assert (out != 0).any(), "depth-1 volume was blanked" + assert not torch.equal(out, base), "YX transform was not applied at D=1" + + +@pytest.mark.parametrize( + "index,name", + list(enumerate(["sxy", "sxz", "syx", "syz", "szx", "szy"])), +) +def test_affine_depth_one_all_shear_facets(index: int, name: str): + """Every individual shear facet is safe at depth 1.""" + shear = [(0.0, 0.0)] * 6 + shear[index] = (-0.05, 0.05) + t = BatchedRandAffined(keys=["source"], prob=1.0, shear_range=shear) + + torch.manual_seed(0) + out = t({"source": torch.ones(2, 1, 1, 64, 64)})["source"] + + assert float(out.mean()) > 0.5, f"facet {name} blanked the depth-1 volume" + + +@pytest.mark.parametrize( + "shape,label", + [ + ((2, 1, 1, 64, 64), "depth"), + ((2, 1, 8, 1, 64), "height"), + ((2, 1, 8, 64, 1), "width"), + ], +) +def test_affine_singleton_axis_preserves_data(shape: tuple[int, ...], label: str): + """Any singleton spatial axis is safe, not just depth. + + Kornia's ``2 / (size - 1)`` normalization degenerates identically for a + singleton W or H, so the guard covers every spatial axis. + """ + t = BatchedRandAffined(keys=["source"], prob=1.0, shear_range=[0.0, 0.05, 0.05]) + + torch.manual_seed(0) + out = t({"source": torch.ones(*shape)})["source"] + + assert (out != 0).any(), f"singleton {label} axis blanked the volume" + + +def test_affine_depth_one_partial_batch(): + """prob < 1 at depth 1 -- the only configuration production runs. + + The 2D overlays all use ``prob: 0.8``, which takes Kornia's mixed + ``to_apply`` branch: ``apply_transform`` is called with a *sliced* batch + and the result is merged back. The ``prob=1.0`` tests take a different + branch and would not catch a guard that read a batch-level shape. + """ + t = BatchedRandAffined( + keys=["source", "target"], + prob=0.5, + rotate_range=[0, 0, 0], + shear_range=[0.0, 0.05, 0.05], + scale_range=[[1, 1], [0.5, 1.5], [0.5, 1.5]], + ) + base = torch.ones(8, 1, 1, 64, 64) + + torch.manual_seed(0) + out = t({"source": base.clone(), "target": base.clone()}) + + # Pin that the batch really was partial, or this silently degrades into + # another prob=1 (or prob=0) test and stops covering the sliced branch. + transformed = [not torch.equal(out["source"][i], base[i]) for i in range(base.shape[0])] + assert 0 < sum(transformed) < base.shape[0], ( + f"batch was not partial: {sum(transformed)}/{base.shape[0]} transformed" + ) + + nonzero = (out["source"] != 0).float().mean(dim=(1, 2, 3, 4)) + assert (nonzero > 0).all(), f"partial batch had blanked samples: {nonzero.tolist()}" + assert torch.equal(out["source"], out["target"]) From b7011c087184cdee3b14e9b60fd36adc82d8038d Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:49:03 -0700 Subject: [PATCH 13/39] perf(viscy-transforms): mark the sampled normalizers pointwise NormalizeSampled and MinMaxSampled set pointwise = True, which lets the read-once predict path in viscy-data normalize a whole volume once instead of once per window. Source on dynacell-models: 8e74a7eb (#532). Co-Authored-By: Claude Opus 5.5 --- packages/viscy-transforms/src/viscy_transforms/_normalize.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/viscy-transforms/src/viscy_transforms/_normalize.py b/packages/viscy-transforms/src/viscy_transforms/_normalize.py index 50457dd66..1de096fa0 100644 --- a/packages/viscy-transforms/src/viscy_transforms/_normalize.py +++ b/packages/viscy-transforms/src/viscy_transforms/_normalize.py @@ -26,6 +26,9 @@ def _match_image(tensor: Tensor, target: Tensor) -> Tensor: class NormalizeSampled(MapTransform): is_spatial = False + # Each voxel maps through precomputed statistics alone, so a whole volume normalized + # once equals each window normalized on its own (``SlidingWindowDataset.cache_volume``). + pointwise = True """Normalize using precomputed statistics stored in ``sample["norm_meta"]``. @@ -82,6 +85,8 @@ def __call__(self, sample: Sample) -> Sample: class MinMaxSampled(MapTransform): is_spatial = False + # See NormalizeSampled.pointwise. + pointwise = True """Normalize to [-1, 1] by clipping then rescaling with precomputed range statistics. From 860d0f8a789bd76e173b73213071b908eefd2619 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:49:04 -0700 Subject: [PATCH 14/39] test(viscy-transforms): make the Gaussian-noise statistics test deterministic Port 3409560f (#513) from dynacell-models. Co-Authored-By: Claude Opus 5.5 --- packages/viscy-transforms/tests/test_noise.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/viscy-transforms/tests/test_noise.py b/packages/viscy-transforms/tests/test_noise.py index 64c2f1940..0a480d510 100644 --- a/packages/viscy-transforms/tests/test_noise.py +++ b/packages/viscy-transforms/tests/test_noise.py @@ -1,3 +1,5 @@ +import math + import pytest import torch from monai.transforms import Compose @@ -34,11 +36,14 @@ def test_batched_gaussian_noise(device, ndim, prob, compose): @pytest.mark.parametrize("std", [2.0, 4.0]) @pytest.mark.parametrize("sample_std", [True, False]) def test_batched_gaussian_noise_statistics(mean, std, sample_std): + torch.manual_seed(0) img = torch.zeros(64, 8, 8, 8, 8) transform = BatchedRandGaussianNoise(prob=1.0, mean=mean, std=std, sample_std=sample_std) result = transform(img) assert (result.mean() - mean).abs() < 0.5 - expected_std = std / 2.0 if sample_std else std + # sample_std draws each sample's std from U(0, std); the pooled std is then + # sqrt(E[s^2]) = std / sqrt(3), not the mean draw std / 2. + expected_std = std / math.sqrt(3) if sample_std else std assert (result.std() - expected_std).abs() < 0.6 From c239394ba111df5dffb2e4841d81dd912988938b Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:49:48 -0700 Subject: [PATCH 15/39] build: parallel test runs with pytest-xdist Port dfcf9a53 (#512) from dynacell-models: pytest-xdist joins the test group, and a root conftest.py pins OMP/MKL/OpenBLAS to one thread under each xdist worker. Without the pin every worker's torch sizes its pool to all cores, which made `-n 11` 10x slower than it should be and timed out subprocess tests. CLAUDE.md documents the usage. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 ++ conftest.py | 14 ++++++++++++++ pyproject.toml | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 conftest.py diff --git a/CLAUDE.md b/CLAUDE.md index 6801f8eba..d83a7e6f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,8 @@ uv run pytest packages/viscy-models/ # single package (models) Prefer `{file}_test.py` in the same directory as `{file}.py`, unless there are import issues, in which case use `tests/`. +Run the suite in parallel with `pytest -n ` (pytest-xdist, in the `test` group). The root `conftest.py` pins each worker to one OpenMP/BLAS thread. Without that pin, torch in every worker grabs every core and the parallel run is 10x slower than it should be. `-n auto` counts the cores the shell may use, so inside a 1-CPU `srun` step it runs a single worker; give the step more CPUs (`srun --overlap -c N`) first. + --- ## Project Conventions diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..c8b776b03 --- /dev/null +++ b/conftest.py @@ -0,0 +1,14 @@ +"""Repo-wide pytest setup, loaded before every package's own conftest.""" + +import os + +# Under pytest-xdist every worker is a separate process, and torch/OpenMP size +# their thread pools to all visible cores by default, so `-n N` runs ~N x cores +# threads. On 11 cores that made the suite 10x slower (1242 s vs 117 s) and +# timed out subprocess tests. xdist sets PYTEST_XDIST_WORKER before this module +# is imported, which is before any test module imports torch. Assigned, not +# setdefault: an inherited limit (a job script exporting OMP_NUM_THREADS=16) +# would otherwise oversubscribe the cores the same way. +if "PYTEST_XDIST_WORKER" in os.environ: + for var in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS"): + os.environ[var] = "1" diff --git a/pyproject.toml b/pyproject.toml index efde37d5a..bff73e881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ urls.Repository = "https://github.com/mehta-lab/VisCy" [dependency-groups] dev = [ { include-group = "jupyter" }, { include-group = "test" } ] -test = [ "pytest>=9.0.2", "pytest-cov>=7" ] +test = [ "pytest>=9.0.2", "pytest-cov>=7", "pytest-xdist>=3.8" ] jupyter = [ "ipykernel>=7.1", "jupyterlab>=4.5.3" ] doc = [ "mike @ git+https://github.com/squidfunk/mike.git", From 451fea64ac8bb8a7f7bee316541db5271c6b3462 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:49:49 -0700 Subject: [PATCH 16/39] test: register pytest markers at the root and make them strict Port the marker half of c50300f5 (#489) from dynacell-models. strict_markers = true turns an unregistered marker into a collection error, so a typo'd marker can no longer silently decorate nothing. hpc_integration moves from the cytoland and dynaclr conftests to the root: an addinivalue_line registration only holds when that conftest is in the invocation's ancestry. dynaclr drops its own testpaths so the root ini applies. requires_data is registered too: main's applications/dynacell/tools/generate_grouped_eval_configs_test.py uses it, and strict markers would otherwise drop that module at collection. (dynacell-models no longer uses the marker; the dynacell PR that follows removes the registration with it.) Co-Authored-By: Claude Opus 5.5 --- applications/cytoland/tests/conftest.py | 5 ----- applications/dynaclr/pyproject.toml | 3 --- applications/dynaclr/tests/conftest.py | 1 - pyproject.toml | 18 +++++++++++++++++- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/applications/cytoland/tests/conftest.py b/applications/cytoland/tests/conftest.py index 36530291d..28f6ae67b 100644 --- a/applications/cytoland/tests/conftest.py +++ b/applications/cytoland/tests/conftest.py @@ -54,11 +54,6 @@ ) -def pytest_configure(config): - """Register custom markers.""" - config.addinivalue_line("markers", "hpc_integration: requires HPC paths and GPU") - - @pytest.fixture def checkpoint_path(): """Return path to vscyto3d checkpoint.""" diff --git a/applications/dynaclr/pyproject.toml b/applications/dynaclr/pyproject.toml index 4cce8f180..76ed2e3f7 100644 --- a/applications/dynaclr/pyproject.toml +++ b/applications/dynaclr/pyproject.toml @@ -96,9 +96,6 @@ source = "uv-dynamic-versioning" [tool.hatch.build.targets.wheel] packages = [ "src/dynaclr" ] -[tool.pytest.ini_options] -testpaths = [ "tests" ] - [tool.uv-dynamic-versioning] vcs = "git" style = "pep440" diff --git a/applications/dynaclr/tests/conftest.py b/applications/dynaclr/tests/conftest.py index 855efe84f..f4d9fae0c 100644 --- a/applications/dynaclr/tests/conftest.py +++ b/applications/dynaclr/tests/conftest.py @@ -336,7 +336,6 @@ def pytest_configure(config): # ArrowStringArray index. Remove when anndata fixes zarr 3 support. # See: https://github.com/scverse/anndata/issues/1510 pd.options.future.infer_string = False - config.addinivalue_line("markers", "hpc_integration: requires HPC paths and GPU") def pytest_collection_modifyitems(config, items): diff --git a/pyproject.toml b/pyproject.toml index bff73e881..134d60ef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,8 +101,24 @@ minversion = "9.0" testpaths = [ "packages/*/tests", "applications/*/tests" ] addopts = [ "-ra", "-q", "--import-mode=importlib", "-m", "not slow" ] pythonpath = [ "applications/dynacell/tools" ] +# Set as an ini option, NOT as --strict-markers in addopts. pytest >= 9 backs the +# flag with this ini option and only honours the flag from the command line — in +# addopts it parses (config.option.strict_markers reads True) but never enforces, +# so an unregistered marker still merely warns. Verified on pytest 9.0.3. +strict_markers = true markers = [ - "slow: marks tests requiring GPU/HPC resources (deselect with -m 'not slow')", + # strict_markers above turns an unregistered marker into a collection error. + # Without it a typo'd or invented marker silently decorates nothing, so the + # test it was meant to gate runs anyway (and asserts on whatever the shared + # HPC tree happens to hold). + # + # Register every marker HERE, not via addinivalue_line in an application + # conftest: that only registers when the conftest is in the invocation's + # ancestry, so a marked test outside that app collects fine from the repo root + # and errors when run as a single file. + "slow: marks tests requiring GPU/HPC resources or live HPC data (deselect with -m 'not slow')", + "hpc_integration: requires HPC data paths and a CUDA GPU", + "requires_data: reads the live dynacell training tree on disk (deselect with -m 'not requires_data')", ] [tool.uv-dynamic-versioning] From cd3ed0fbacce407ebf059cd16b06049f1e257c17 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:49:51 -0700 Subject: [PATCH 17/39] build: put the dynacell hf_demo on the pytest pythonpath Port the root half of 7b7cb431 (#523) from dynacell-models so the HF demo's tests import its app module. The directory already exists on main. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 134d60ef5..4d6138e55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ lint.pydocstyle.convention = "numpy" minversion = "9.0" testpaths = [ "packages/*/tests", "applications/*/tests" ] addopts = [ "-ra", "-q", "--import-mode=importlib", "-m", "not slow" ] -pythonpath = [ "applications/dynacell/tools" ] +pythonpath = [ "applications/dynacell/tools", "applications/dynacell/examples/hf_demo" ] # Set as an ini option, NOT as --strict-markers in addopts. pytest >= 9 backs the # flag with this ini option and only honours the flag from the command line — in # addopts it parses (config.option.strict_markers reads True) but never enforces, From ce2a03cf93c8cc487fad52f7de83d593b36cc4e9 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:06 -0700 Subject: [PATCH 18/39] test(dynaclr): join annotations on the full fov_name Port d1ea492f from dynacell-models. The orchestrated linear-classifier fixture joined on the first fov_name component, which failed 7 tests on main once pytest collects dynaclr's colocated *_test.py. Co-Authored-By: Claude Opus 5.5 --- .../evaluation/linear_classifiers/orchestrated_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py b/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py index a28db7569..fc90cadf4 100644 --- a/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py +++ b/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py @@ -76,12 +76,12 @@ def _make_annotations( ) -> Path: """Create a synthetic annotation CSV with infection_state and organelle_state labels. - fov_name is stored as the first path component only (e.g. "A/1/FOV0" → "A"), - matching what load_annotation_anndata extracts from obs via .str.split("/").str[0]. + fov_name is stored in full (e.g. "A/1/FOV0"), matching the join key + load_annotation_anndata actually builds: it normalizes both sides with + .str.strip("/") and joins on the whole path, never a split component. """ labels = ["uninfected" if i % 3 != 0 else "infected" for i in range(len(fov_names))] - # Extract first path component to match the join key in load_annotation_anndata - fov_first = [str(f).split("/")[0] for f in fov_names] + fov_first = [str(f) for f in fov_names] data: dict = { "fov_name": fov_first, "t": ts, From 6cfde6868e5331e09bb0e8dc008f2898fb32b7e3 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:13 -0700 Subject: [PATCH 19/39] docs(cytoland): correct the Spotlight recipe comments Port the cytoland recipe comment fixes from 40de7939 (#498): the preprocess flags take explicit values (`--compute_otsu true --compute_fg_masks true`), fg_threshold and nonzero_threshold are inert while fg_mask_key is set, and the Otsu-centred target is what makes 0 the FG/BG boundary. Comments only. Co-Authored-By: Claude Opus 5.5 --- .../examples/configs/recipes/modes/spotlight.yml | 11 ++++++++--- .../examples/configs/vscyto3d/train_spotlight.yml | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/applications/cytoland/examples/configs/recipes/modes/spotlight.yml b/applications/cytoland/examples/configs/recipes/modes/spotlight.yml index be9b5d295..cbe4e51d0 100644 --- a/applications/cytoland/examples/configs/recipes/modes/spotlight.yml +++ b/applications/cytoland/examples/configs/recipes/modes/spotlight.yml @@ -1,9 +1,10 @@ # Mode recipe: Spotlight foreground-aware loss (Kalinin et al. 2025). # Cross-cutting: sets loss, fg_mask_key, nonzero filtering, AND -# Otsu-centered normalization. fg_threshold: 0.0 is valid ONLY -# because normalizations use subtrahend: otsu_threshold. +# Otsu-centered normalization. The Otsu-centred target is what makes 0 the +# FG/BG boundary in the model's output space -- which the Dice term needs +# regardless of fg_threshold, since that field is inert here (see below). # -# Requires: viscy preprocess --compute_otsu --compute_fg_masks +# Requires: viscy preprocess --compute_otsu true --compute_fg_masks true # # Base ordering: this mode must come AFTER the data recipe in the # base: list so that its normalizations replace the data default. @@ -14,6 +15,10 @@ model: init_args: lambda_mse: 0.5 sigmoid_k: -0.95 + # INERT while data.fg_mask_key is set below: forward() short-circuits + # on `if fg_mask is not None`, so the mask from the store defines + # foreground and this threshold is never consulted. It applies only if + # fg_mask_key is removed. Same for data.nonzero_threshold. fg_threshold: 0.0 data: init_args: diff --git a/applications/cytoland/examples/configs/vscyto3d/train_spotlight.yml b/applications/cytoland/examples/configs/vscyto3d/train_spotlight.yml index a5cbdd25c..7a423f970 100644 --- a/applications/cytoland/examples/configs/vscyto3d/train_spotlight.yml +++ b/applications/cytoland/examples/configs/vscyto3d/train_spotlight.yml @@ -1,5 +1,5 @@ # VSCyto3D: supervised training with Spotlight foreground-aware loss. -# Requires: viscy preprocess --compute_otsu --compute_fg_masks +# Requires: viscy preprocess --compute_otsu true --compute_fg_masks true # Usage: python -m cytoland fit --config vscyto3d/train_spotlight.yml base: - ../recipes/trainer/fit.yml From f0880a29d9f1773749c8c1673c817ab308b28d34 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:18 -0700 Subject: [PATCH 20/39] docs(cytoland): install the VCP tutorials' unpublished deps from GitHub Port the tutorial fix from d3f6422f (#479): cytoland and viscy-utils are not on PyPI yet, so the tutorials install them from the GitHub monorepo; their siblings resolve from PyPI. Adds the jupytext-paired notebooks (no outputs). Co-Authored-By: Claude Opus 5.5 --- .../examples/vcp_tutorials/hek293t.ipynb | 368 +++++++++++++ .../examples/vcp_tutorials/hek293t.py | 5 +- .../examples/vcp_tutorials/neuromast.ipynb | 465 +++++++++++++++++ .../examples/vcp_tutorials/neuromast.py | 5 +- .../examples/vcp_tutorials/quick_start.ipynb | 492 ++++++++++++++++++ .../examples/vcp_tutorials/quick_start.py | 6 +- 6 files changed, 1335 insertions(+), 6 deletions(-) create mode 100644 applications/cytoland/examples/vcp_tutorials/hek293t.ipynb create mode 100644 applications/cytoland/examples/vcp_tutorials/neuromast.ipynb create mode 100644 applications/cytoland/examples/vcp_tutorials/quick_start.ipynb diff --git a/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb b/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb new file mode 100644 index 000000000..ce81bebf1 --- /dev/null +++ b/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb @@ -0,0 +1,368 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d0990bbc", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Cytoland Tutorial: Virtual Staining of HEK293T Cells with VSCyto3D\n", + "\n", + "**Estimated time to complete:** 15 minutes" + ] + }, + { + "cell_type": "markdown", + "id": "02b87aa0", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Learning Goals\n", + "\n", + "* Download the VSCyto3D model and an example dataset containing HEK293T cell images.\n", + "* Pre-compute normalization statistics for the images using the `viscy preprocess` command line interface (CLI).\n", + "* Run inference for joint virtual staining of cell nuclei and plasma membrane via the `viscy predict` CLI.\n", + "* Compare virtually and experimentally stained cells and see how virtual staining can rescue missing labels." + ] + }, + { + "cell_type": "markdown", + "id": "dc3ac201", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Prerequisites\n", + "\n", + "Python>=3.12" + ] + }, + { + "cell_type": "markdown", + "id": "ff9bed71", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Introduction\n", + "\n", + "See the [model card](https://virtualcellmodels.cziscience.com/paper/cytoland2025)\n", + "for more details about the Cytoland models.\n", + "\n", + "VSCyto3D is a 3D UNeXt2 model that has been trained on A549, HEK293T, and hiPSC cells using the Cytoland approach.\n", + "This model enables users to jointly stain cell nuclei and plasma membranes from 3D label-free images\n", + "for downstream analysis such as cell segmentation and tracking without the need for human annotation of volumetric data." + ] + }, + { + "cell_type": "markdown", + "id": "e96a3709", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Setup\n", + "\n", + "The commands below will install the required packages and download the example dataset and model checkpoint.\n", + "It may take a **few minutes** to download all the files.\n", + "\n", + "## Setup Google Colab\n", + "\n", + "To run this quick-start guide using Google Colab,\n", + "choose the 'T4' GPU runtime from the \"Connect\" dropdown menu\n", + "in the upper-right corner of this notebook for faster execution.\n", + "Using a GPU significantly speeds up running model inference, but CPU compute can also be used.\n", + "\n", + "## Setup Local Environment\n", + "\n", + "The commands below assume a Unix-like shell with `wget` installed.\n", + "On Windows, the files can be downloaded manually from the URLs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ca08f540", + "metadata": {}, + "outputs": [], + "source": [ + "# Install the modular Cytoland/VisCy packages required for this example\n", + "# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from\n", + "# the GitHub monorepo (their siblings resolve from PyPI automatically).\n", + "# Here stackview and ipycanvas are installed for visualization\n", + "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" stackview ipycanvas==0.11" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5673cf4", + "metadata": {}, + "outputs": [], + "source": [ + "# Restart kernel if running in Google Colab\n", + "# This is required to use the packages installed above\n", + "# The 'kernel crashed' message is expected here\n", + "if \"get_ipython\" in globals():\n", + " session = get_ipython() # noqa: F821\n", + " if \"google.colab\" in str(session):\n", + " print(\"Shutting down colab session.\")\n", + " session.kernel.do_shutdown(restart=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c70d5e2", + "metadata": {}, + "outputs": [], + "source": [ + "# Download the example dataset\n", + "!wget -m -np -nH --cut-dirs=5 -R \"index.html*\" \"https://public.czbiohub.org/comp.micro/viscy/VS_datasets/VSCyto3D/test/HEK293T-Phase3D-H2B-CAAX-example.zarr/\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc9db7a0", + "metadata": {}, + "outputs": [], + "source": [ + "# Rename the downloaded dataset to what the example prediction config expects (`input.ome.zarr`)\n", + "# And validate the OME-Zarr metadata with iohub\n", + "!mv HEK293T-Phase3D-H2B-CAAX-example.zarr input.ome.zarr\n", + "!iohub info -v input.ome.zarr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2a2d1e7", + "metadata": {}, + "outputs": [], + "source": [ + "# Download the VSCyto3D model checkpoint and prediction config\n", + "!wget \"https://public.czbiohub.org/comp.micro/viscy/VS_models/VSCyto3D/epoch=83-step=14532-loss=0.492.ckpt\"\n", + "!wget \"https://public.czbiohub.org/comp.micro/viscy/VS_models/VSCyto3D/predict.yml\"" + ] + }, + { + "cell_type": "markdown", + "id": "46fdb6cb", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Use Case\n", + "\n", + "## Example Dataset\n", + "\n", + "The HEK293T example dataset used in this quick-start guide contains\n", + "quantitative phase and paired fluorescence images of cell nuclei and plasma membrane.\n", + "It is a subset (one cropped region of interest) from a test set used to evaluate the VSCyto3D model.\n", + "The full dataset can be downloaded from the\n", + "[BioImage Archive](https://www.ebi.ac.uk/biostudies/BioImages/studies/S-BIAD1702).\n", + "\n", + "Refer to our [preprint](https://doi.org/10.1101/2024.05.31.596901) for more details\n", + "about how the dataset and model were generated.\n", + "\n", + "## Using Custom Data\n", + "\n", + "The model only requires label-free images for inference.\n", + "To run inference on your own data,\n", + "convert them into the OME-Zarr data format using iohub or other\n", + "[tools](https://ngff.openmicroscopy.org/tools/index.html#file-conversion),\n", + "and edit the `predict.yml` file to specify the input data path.\n", + "Specifically, the `data.init_args.data_path` field should be updated:\n", + "\n", + "```diff\n", + "- data_path: input.ome.zarr\n", + "+ data_path: /path/to/your.ome.zarr\n", + "```\n", + "\n", + "The image may need to be resampled to roughly match the voxel size of the example dataset\n", + "(0.2x0.1x0.1 µm, ZYX)." + ] + }, + { + "cell_type": "markdown", + "id": "2d84aad9", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Run Model Inference\n", + "\n", + "On Google Colab, the preprocessing step takes about **1 minute**,\n", + "and the inference step takes about **2 minutes** (T4 GPU)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "383fb379", + "metadata": {}, + "outputs": [], + "source": [ + "# Run the CLI command to pre-compute normalization statistics\n", + "# This includes the median and interquartile range (IQR)\n", + "# Used to shift and scale the intensity distribution of the input images\n", + "!viscy preprocess --data_path=input.ome.zarr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c324ee5", + "metadata": {}, + "outputs": [], + "source": [ + "# Run the CLI command to run inference\n", + "!viscy predict -c predict.yml" + ] + }, + { + "cell_type": "markdown", + "id": "12de2060", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Analysis of Model Outputs\n", + "\n", + "Visualize the experimental and virtually stained images using the `stackview` package." + ] + }, + { + "cell_type": "markdown", + "id": "a2761744", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "Visualizing large 3D multichannel images in a Jupyter notebook\n", + "**is prone to performance issues and may crash the notebook** if the images are too large\n", + "(the free Colab instances have limited CPU cores and memory).\n", + "The visualization code below is only intended for demonstration.\n", + "We strongly recommend downloading the images (from the 'files' bar in Colab)\n", + "and using a standalone viewer such as [napari](https://napari.org/)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64176a04", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np # noqa: E402\n", + "import stackview # noqa: E402\n", + "from iohub import open_ome_zarr # noqa: E402\n", + "from skimage.exposure import rescale_intensity # noqa: E402\n", + "\n", + "try:\n", + " from google.colab import output\n", + "\n", + " output.enable_custom_widget_manager()\n", + "except ImportError:\n", + " pass" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bbb0b4a2", + "metadata": {}, + "outputs": [], + "source": [ + "# open the images\n", + "def split_and_rescale_channels(timepoint: np.ndarray) -> tuple[np.ndarray, ...]:\n", + " return (rescale_intensity(channel, out_range=(0, 1)) for channel in timepoint)\n", + "\n", + "\n", + "fov_name = \"plate/0/11\"\n", + "input_image = open_ome_zarr(\"input.ome.zarr\")[fov_name][\"0\"]\n", + "prediction_image = open_ome_zarr(\"prediction.ome.zarr\")[fov_name][\"0\"]\n", + "\n", + "phase, fluor_nucleus, fluor_membrane = split_and_rescale_channels(input_image[0])\n", + "vs_nucleus, vs_membrane = split_and_rescale_channels(prediction_image[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c6d3cb4", + "metadata": {}, + "outputs": [], + "source": [ + "# Drag the slider to start rendering\n", + "# Click on the numbered buttons to toggle the channels\n", + "stackview.switch(\n", + " # the 0, 1, 2, 3, 4 buttons will correspond to these 5 channels\n", + " # We apply a gamma adjustment to the phase channel to improve visibility in the overlay\n", + " images=[phase**2.5, fluor_nucleus, fluor_membrane, vs_nucleus, vs_membrane],\n", + " colormap=[\"gray\", \"pure_green\", \"pure_magenta\", \"pure_blue\", \"pure_yellow\"],\n", + " toggleable=True,\n", + " zoom_factor=0.5,\n", + " display_min=0.0,\n", + " display_max=0.9,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2733926b", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "Note how the experimental fluorescence is missing for a subset of cells.\n", + "This is due to loss of genetic labeling.\n", + "The virtually stained images is not affected by this issue and can robustly label all cells." + ] + }, + { + "cell_type": "markdown", + "id": "a8c347af", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Summary\n", + "\n", + "In the above example, we demonstrated how to use the VSCyto3D model\n", + "for virtual staining of cell nuclei and plasma membranes, which can rescue missing labels." + ] + }, + { + "cell_type": "markdown", + "id": "9063adf6", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "## Contact & Feedback\n", + "\n", + "For issues or feedback about this tutorial please contact Ziwen Liu at [ziwen.liu@czbiohub.org](mailto:ziwen.liu@czbiohub.org).\n", + "\n", + "## Responsible Use\n", + "\n", + "We are committed to advancing the responsible development and use of artificial intelligence.\n", + "Please follow our [Acceptable Use Policy](https://virtualcellmodels.cziscience.com/acceptable-use-policy) when engaging with our services.\n", + "\n", + "Should you have any security or privacy issues or questions related to the services,\n", + "please reach out to our team at [security@chanzuckerberg.com](mailto:security@chanzuckerberg.com) or [privacy@chanzuckerberg.com](mailto:privacy@chanzuckerberg.com) respectively." + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "all", + "main_language": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/applications/cytoland/examples/vcp_tutorials/hek293t.py b/applications/cytoland/examples/vcp_tutorials/hek293t.py index d318ff895..2d66762f6 100644 --- a/applications/cytoland/examples/vcp_tutorials/hek293t.py +++ b/applications/cytoland/examples/vcp_tutorials/hek293t.py @@ -56,9 +56,10 @@ # %% # Install the modular Cytoland/VisCy packages required for this example -# See the [repository](https://github.com/mehta-lab/VisCy) for more details +# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from +# the GitHub monorepo (their siblings resolve from PyPI automatically). # Here stackview and ipycanvas are installed for visualization -# !pip install -U cytoland viscy stackview ipycanvas==0.11 +# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" stackview ipycanvas==0.11 # %% # Restart kernel if running in Google Colab diff --git a/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb b/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb new file mode 100644 index 000000000..e66db361d --- /dev/null +++ b/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb @@ -0,0 +1,465 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2dd42bb7", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Cytoland Tutorial: Virtual Staining of Zebrafish Neuromasts with VSNeuromast\n", + "\n", + "**Estimated time to complete:** 15 minutes" + ] + }, + { + "cell_type": "markdown", + "id": "e1c38b9f", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Learning Goals\n", + "\n", + "* Download the VSNeuromast model and an example dataset containing time-lapse images of zebrafish neuromasts.\n", + "* Pre-compute normalization statistics for the images using the `viscy preprocess` command-line interface (CLI).\n", + "* Run inference for joint virtual staining of cell nuclei and plasma membrane via the `viscy predict` CLI.\n", + "* Visualize the effect of photobleaching in fluorescence imaging and how virtual staining can mitigate this issue." + ] + }, + { + "cell_type": "markdown", + "id": "c1aa6808", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Prerequisites\n", + "\n", + "Python>=3.12" + ] + }, + { + "cell_type": "markdown", + "id": "bd3b93d4", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Introduction\n", + "\n", + "The zebrafish neuromasts are sensory organs on the lateral lines.\n", + "Given their relatively simple structure and high accessibility to live imaging,\n", + "they are used as a model system to study organogenesis _in vivo_.\n", + "However, multiplexed long-term fluorescence imaging at high spatial-temporal resolution\n", + "is often limited by photobleaching and phototoxicity.\n", + "Also, engineering fish lines with a combination of landmark fluorescent labels\n", + "(e.g. nuclei and plasma membrane) and functional reporters increases experimental complexity.\n", + "\\\n", + "VSNeuromast is a 3D UNeXt2 model that has been trained on images of\n", + "zebrafish neuromasts using the Cytoland approach.\n", + "(See the [model card](https://virtualcellmodels.cziscience.com/paper/cytoland2025)\n", + "for more details about the Cytoland models.)\n", + "This model enables users to jointly stain cell nuclei and plasma membranes from 3D label-free images\n", + "for downstream analysis such as cell segmentation and tracking." + ] + }, + { + "cell_type": "markdown", + "id": "f2a6edfe", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Setup\n", + "\n", + "The commands below will install the required packages and download the example dataset and model checkpoint.\n", + "It may take a **few minutes** to download all the files.\n", + "\n", + "## Setup Google Colab\n", + "\n", + "To run this quick-start guide using Google Colab,\n", + "choose the 'T4' GPU runtime from the \"Connect\" dropdown menu\n", + "in the upper-right corner of this notebook for faster execution.\n", + "Using a GPU significantly speeds up running model inference, but CPU compute can also be used.\n", + "\n", + "## Setup Local Environment\n", + "\n", + "The commands below assume a Unix-like shell with `wget` installed.\n", + "On Windows, the files can be downloaded manually from the URLs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3f0a34a", + "metadata": {}, + "outputs": [], + "source": [ + "# Install the modular Cytoland/VisCy packages required for this example\n", + "# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from\n", + "# the GitHub monorepo (their siblings resolve from PyPI automatically).\n", + "# Here stackview and ipycanvas are installed for visualization, and iohub provides OME-Zarr access\n", + "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" iohub stackview ipycanvas" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "540ab976", + "metadata": {}, + "outputs": [], + "source": [ + "# Restart kernel if running in Google Colab\n", + "# This is required to use the packages installed above\n", + "# The 'kernel crashed' message is expected here\n", + "if \"get_ipython\" in globals():\n", + " session = get_ipython()\n", + " if \"google.colab\" in str(session):\n", + " print(\"Shutting down colab session.\")\n", + " session.kernel.do_shutdown(restart=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51cf0bab", + "metadata": {}, + "outputs": [], + "source": [ + "# Download the example dataset\n", + "!wget -m -np -nH --cut-dirs=5 -R \"index.html*\" \"https://public.czbiohub.org/comp.micro/viscy/VS_datasets/VSNeuromast/test/isim-bleaching-example.zarr/\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "250def65", + "metadata": {}, + "outputs": [], + "source": [ + "# Rename the downloaded dataset to what the example prediction config expects (`input.ome.zarr`)\n", + "# And validate the OME-Zarr metadata with iohub\n", + "!mv isim-bleaching-example.zarr input.ome.zarr\n", + "!iohub info -v input.ome.zarr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88326661", + "metadata": {}, + "outputs": [], + "source": [ + "# Download the VSNeuromast model checkpoint and prediction config\n", + "!wget \"https://public.czbiohub.org/comp.micro/viscy/VS_models/VSNeuromast/epoch=64-step=24960.ckpt\"\n", + "!wget \"https://public.czbiohub.org/comp.micro/viscy/VS_models/VSNeuromast/predict.yml\"" + ] + }, + { + "cell_type": "markdown", + "id": "1022e0e7", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Use Case\n", + "\n", + "## Example Dataset\n", + "\n", + "The neuromast example dataset used in this tutorial contains\n", + "quantitative phase and paired fluorescence images of the cell nuclei and the plasma membrane.\n", + "\\\n", + "**It is a subsampled time-lapse from a test set used to evaluate the VSNeuromast model.**\n", + "\\\n", + "The full dataset can be downloaded from the\n", + "[BioImage Archive](https://www.ebi.ac.uk/biostudies/BioImages/studies/S-BIAD1702).\n", + "\n", + "Refer to our [preprint](https://doi.org/10.1101/2024.05.31.596901) for more details\n", + "about how the dataset and model were generated.\n", + "\n", + "## Using Custom Data\n", + "\n", + "The model only requires label-free images for inference.\n", + "To run inference on your own data,\n", + "convert them into the [OME-Zarr](https://ngff.openmicroscopy.org/)\n", + "data format using iohub or other\n", + "[tools](https://ngff.openmicroscopy.org/tools/index.html#file-conversion),\n", + "and edit the `predict.yml` file to specify the input data path.\n", + "Specifically, the `data.init_args.data_path` field should be updated:\n", + "\n", + "```diff\n", + "- data_path: input.ome.zarr\n", + "+ data_path: /path/to/your.ome.zarr\n", + "```\n", + "\n", + "The image may need to be resampled to roughly match the voxel size of the example dataset\n", + "(0.25x0.108x0.108 µm, ZYX)." + ] + }, + { + "cell_type": "markdown", + "id": "a7482dd5", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Run Model Inference\n", + "\n", + "On Google Colab, the preprocessing step takes about **1 minute**,\n", + "and the inference step takes about **2 minutes** (T4 GPU)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2df952e4", + "metadata": {}, + "outputs": [], + "source": [ + "# Run the CLI command to pre-compute normalization statistics\n", + "# This includes the median and interquartile range (IQR)\n", + "# Used to shift and scale the intensity distribution of the input images\n", + "!viscy preprocess --data_path=input.ome.zarr" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "05021fa5", + "metadata": {}, + "outputs": [], + "source": [ + "# Run the CLI command to run inference\n", + "!viscy predict -c predict.yml" + ] + }, + { + "cell_type": "markdown", + "id": "11adee29", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Analysis of Model Outputs\n", + "\n", + "1. Visualize predicted images over time and compare with the fluorescence images.\n", + "2. Measure photobleaching in the fluorescence images\n", + "and how virtual staining can mitigate this issue.\n", + "Since most pixels in the images are background,\n", + "we will use the 99th percentile (brightest 1%)\n", + "of the intensity distribution as a proxy for foreground signal." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3dab72b1", + "metadata": {}, + "outputs": [], + "source": [ + "# imports\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "from cmap import Colormap\n", + "from iohub import open_ome_zarr\n", + "from numpy.typing import NDArray\n", + "from skimage.exposure import rescale_intensity\n", + "\n", + "\n", + "def render_rgb(image: np.ndarray, colormap: Colormap) -> tuple[NDArray, plt.cm.ScalarMappable]:\n", + " \"\"\"Render a 2D grayscale image as RGB using a colormap.\n", + "\n", + " Parameters\n", + " ----------\n", + " image : np.ndarray\n", + " intensity image\n", + " colormap : Colormap\n", + " colormap\n", + "\n", + " Returns\n", + " -------\n", + " tuple[NDArray, plt.cm.ScalarMappable]\n", + " rendered RGB image and the color mapping\n", + " \"\"\"\n", + " image = rescale_intensity(image, out_range=(0, 1))\n", + " image = colormap(image)\n", + " mappable = plt.cm.ScalarMappable(norm=plt.Normalize(0, 1), cmap=colormap.to_matplotlib())\n", + " return image, mappable" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "108cfd0c", + "metadata": {}, + "outputs": [], + "source": [ + "# read a single Z-slice for visualization\n", + "z_slice = 30\n", + "\n", + "with open_ome_zarr(\"input.ome.zarr/0/3/0\") as fluor_store:\n", + " fluor_nucleus = fluor_store[0][:, 1, z_slice]\n", + " fluor_membrane = fluor_store[0][:, 0, z_slice]\n", + "\n", + "with open_ome_zarr(\"prediction.ome.zarr/0/3/0\") as vs_store:\n", + " vs_nucleus = vs_store[0][:, 0, z_slice]\n", + " vs_membrane = vs_store[0][:, 1, z_slice]\n", + "\n", + "\n", + "# Render the images as RGB in false colors\n", + "vs_nucleus_rgb, vs_nucleus_mappable = render_rgb(vs_nucleus, Colormap(\"bop_blue\"))\n", + "vs_membrane_rgb, vs_membrane_mappable = render_rgb(vs_membrane, Colormap(\"bop_orange\"))\n", + "merged_vs = (vs_nucleus_rgb + vs_membrane_rgb).clip(0, 1)\n", + "\n", + "fluor_nucleus_rgb, fluor_nucleus_mappable = render_rgb(fluor_nucleus, Colormap(\"green\"))\n", + "fluor_membrane_rgb, fluor_membrane_mappable = render_rgb(fluor_membrane, Colormap(\"magenta\"))\n", + "merged_fluor = (fluor_nucleus_rgb + fluor_membrane_rgb).clip(0, 1)\n", + "\n", + "# Plot\n", + "fig = plt.figure(figsize=(12, 7), layout=\"constrained\")\n", + "\n", + "images = {\"fluorescence\": merged_fluor, \"virtual staining\": merged_vs}\n", + "\n", + "for row, (subfig, (name, img)) in enumerate(zip(fig.subfigures(nrows=2, ncols=1), images.items())):\n", + " subfig.suptitle(name)\n", + " cax_nuc = subfig.add_axes([1, 0.55, 0.02, 0.3])\n", + " cax_mem = subfig.add_axes([1, 0.15, 0.02, 0.3])\n", + " axes = subfig.subplots(ncols=len(merged_vs))\n", + " for t, ax in enumerate(axes):\n", + " if row == 1:\n", + " ax.set_title(f\"{t * 30} min\", y=-0.1)\n", + " ax.imshow(img[t])\n", + " ax.axis(\"off\")\n", + " if row == 0:\n", + " subfig.colorbar(fluor_nucleus_mappable, cax=cax_nuc, label=\"Nuclei (GFP)\")\n", + " subfig.colorbar(fluor_membrane_mappable, cax=cax_mem, label=\"Membrane (mScarlett)\")\n", + " elif row == 1:\n", + " subfig.colorbar(vs_nucleus_mappable, cax=cax_nuc, label=\"Nuclei (VS)\")\n", + " subfig.colorbar(vs_membrane_mappable, cax=cax_mem, label=\"Membrane (VS)\")\n", + "\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5fcd7a29", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "The plasma membrane fluorescence decreases over time,\n", + "while the virtual staining remains stable.\n", + "How significant is this effect? Is it consistent with photobleaching?\n", + "Analysis below will answer these questions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "452ba89e", + "metadata": {}, + "outputs": [], + "source": [ + "def highlight_intensity_normalized(fov_path: str, channel_name: str) -> list[float]:\n", + " \"\"\"\n", + " Compute highlight (99th percentile) intensity of each timepoint,\n", + " normalized to the first timepoint.\n", + "\n", + " Parameters\n", + " ----------\n", + " fov_path : str\n", + " Path to the field of view (FOV).\n", + " channel_name : str\n", + " Name of the channel to compute highlight intensity for.\n", + "\n", + " Returns\n", + " -------\n", + " NDArray\n", + " List of intensity values.\n", + " \"\"\"\n", + " with open_ome_zarr(fov_path) as fov:\n", + " channel_index = fov.get_channel_index(channel_name)\n", + " channel = fov[\"0\"].dask_array()[:, channel_index]\n", + " highlights = []\n", + " for t, volume in enumerate(channel):\n", + " highlights.append(np.percentile(volume.compute(), 99))\n", + " return [h / highlights[0] for h in highlights]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "837be184", + "metadata": {}, + "outputs": [], + "source": [ + "# Plot intensity over time\n", + "mean_fl = highlight_intensity_normalized(\"input.ome.zarr/0/3/0\", \"mScarlett\")\n", + "mean_vs = highlight_intensity_normalized(\"prediction.ome.zarr/0/3/0\", \"membrane_prediction\")\n", + "time = np.arange(0, 100, 30)\n", + "\n", + "plt.plot(time, mean_fl, label=\"membrane fluorescence\")\n", + "plt.plot(time, mean_vs, label=\"membrane virtual staining\")\n", + "plt.xlabel(\"time / min\")\n", + "plt.ylabel(\"normalized highlight intensity\")\n", + "plt.legend()" + ] + }, + { + "cell_type": "markdown", + "id": "00d41be6", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "Here the highlight intensity of the fluorescence images decreases over time,\n", + "following a exponential decay pattern, indicating photobleaching.\n", + "The virtual staining is not affected by this issue.\n", + "(The object drifts slightly over time, so some inherent noise is expected.)" + ] + }, + { + "cell_type": "markdown", + "id": "19bb6b01", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Summary\n", + "\n", + "In the above example, we demonstrated how to use the VSNeuromast model\n", + "for virtual staining of cell nuclei and plasma membranes of the zebrafish neuromast _in vivo_,\n", + "which can avoid photobleaching in long-term live imaging." + ] + }, + { + "cell_type": "markdown", + "id": "8bda5ac2", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "## Contact & Feedback\n", + "\n", + "For issues or feedback about this tutorial please contact Ziwen Liu at [ziwen.liu@czbiohub.org](mailto:ziwen.liu@czbiohub.org).\n", + "\n", + "## Responsible Use\n", + "\n", + "We are committed to advancing the responsible development and use of artificial intelligence.\n", + "Please follow our [Acceptable Use Policy](https://virtualcellmodels.cziscience.com/acceptable-use-policy) when engaging with our services.\n", + "\n", + "Should you have any security or privacy issues or questions related to the services,\n", + "please reach out to our team at [security@chanzuckerberg.com](mailto:security@chanzuckerberg.com) or [privacy@chanzuckerberg.com](mailto:privacy@chanzuckerberg.com) respectively." + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "all", + "main_language": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/applications/cytoland/examples/vcp_tutorials/neuromast.py b/applications/cytoland/examples/vcp_tutorials/neuromast.py index a65d305ea..d88ab659c 100644 --- a/applications/cytoland/examples/vcp_tutorials/neuromast.py +++ b/applications/cytoland/examples/vcp_tutorials/neuromast.py @@ -64,9 +64,10 @@ # %% # Install the modular Cytoland/VisCy packages required for this example -# See the [repository](https://github.com/mehta-lab/VisCy) for more details +# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from +# the GitHub monorepo (their siblings resolve from PyPI automatically). # Here stackview and ipycanvas are installed for visualization, and iohub provides OME-Zarr access -# !pip install -U cytoland viscy iohub stackview ipycanvas +# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" iohub stackview ipycanvas # %% # Restart kernel if running in Google Colab diff --git a/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb b/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb new file mode 100644 index 000000000..5c4726fb2 --- /dev/null +++ b/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb @@ -0,0 +1,492 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9421d8c7", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Quick Start: Cytoland\n", + "\n", + "**Estimated time to complete:** 15 minutes" + ] + }, + { + "cell_type": "markdown", + "id": "7dae3dc1", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Learning Goals\n", + "\n", + "* Download the VSCyto2D model and an example dataset containing A549 cell images.\n", + "* Run VSCyto2D model inference for joint virtual staining of cell nuclei and plasma membrane.\n", + "* Visualize and compare virtually and experimentally stained cells." + ] + }, + { + "cell_type": "markdown", + "id": "b86f7719", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Prerequisites\n", + "Python>=3.12" + ] + }, + { + "cell_type": "markdown", + "id": "23c78bc5", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Introduction\n", + "\n", + "## Model\n", + "\n", + "The Cytoland virtual staining models are a collection of models (VSCyto2D, VSCyto3D, and VSNeuromast)\n", + "used to predict cellular landmarks (e.g., nuclei and plasma membranes)\n", + "from label-free images (e.g. quantitative phase, Zernike phase contrast, and brightfield).\n", + "This quick-start guide focuses on the VSCyto2D model.\n", + "\n", + "VSCyto2D is a 2D UNeXt2 model that has been trained on A549, HEK293T, and BJ-5ta cells.\n", + "This model enables users to jointly stain cell nuclei and plasma membranes from 2D label-free images\n", + "that are commonly generated for image-based screens.\n", + "\n", + "Alternative models are optimized for different sample types and imaging conditions:\n", + "\n", + "* [VSCyto3D](https://public.czbiohub.org/comp.micro/viscy/VS_models/VSCyto3D):\n", + "3D UNeXt2 model for joint virtual staining of cell nuclei and plasma membrane\n", + "from high-resolution volumetric images.\n", + "* [VSNeuromast](https://public.czbiohub.org/comp.micro/viscy/VS_models/VSNeuromast):\n", + "3D UNeXt2 model for joint virtual staining of nuclei and plasma membrane in zebrafish neuromasts.\n", + "\n", + "## Example Dataset\n", + "\n", + "The A549 example dataset used in this quick-start guide contains\n", + "quantitative phase and paired fluorescence images of cell nuclei and plasma membrane.\n", + "It is stored in OME-Zarr format and can be downloaded from\n", + "[here](https://public.czbiohub.org/comp.micro/viscy/VS_datasets/VSCyto2D/test/a549_hoechst_cellmask_test.zarr).\n", + "It has pre-computed statistics for normalization, generated using the `viscy preprocess` CLI.\n", + "\n", + "Refer to our [preprint](https://doi.org/10.1101/2024.05.31.596901) for more details\n", + "about how the dataset and model were generated.\n", + "\n", + "## User Data\n", + "\n", + "The VSCyto2D model only requires label-free images for inference.\n", + "To run inference on your own data,\n", + "convert them into the OME-Zarr data format using iohub or other\n", + "[tools](https://ngff.openmicroscopy.org/tools/index.html#file-conversion),\n", + "and run [pre-processing](https://github.com/mehta-lab/VisCy/blob/main/docs/usage.md#preprocessing)\n", + "with the `viscy preprocess` CLI." + ] + }, + { + "cell_type": "markdown", + "id": "aba74cd8", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Setup\n", + "\n", + "The commands below will install the required packages and download the example dataset and model checkpoint.\n", + "It may take a few minutes to download all the files.\n", + "\n", + "## Setup Google Colab\n", + "\n", + "To run this quick-start guide using Google Colab,\n", + "choose the 'T4' GPU runtime from the \"Connect\" dropdown menu\n", + "in the upper-right corner of this notebook for faster execution.\n", + "Using a GPU significantly speeds up running model inference, but CPU compute can also be used.\n", + "\n", + "## Setup Local Environment\n", + "\n", + "The commands below assume a Unix-like shell with `wget` installed.\n", + "On Windows, the files can be downloaded manually from the URLs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "069fbda0", + "metadata": {}, + "outputs": [], + "source": [ + "# Install the modular packages required for this example\n", + "# `cytoland` and `viscy-utils` are not yet published to PyPI, so they are\n", + "# installed from the GitHub monorepo; their siblings (viscy-data, viscy-models,\n", + "# viscy-transforms) resolve from PyPI automatically.\n", + "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" torchview cmap iohub" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44132e97", + "metadata": {}, + "outputs": [], + "source": [ + "# restart kernel if running in Google Colab\n", + "if \"get_ipython\" in globals():\n", + " session = get_ipython() # noqa: F821\n", + " if \"google.colab\" in str(session):\n", + " print(\"Shutting down colab session.\")\n", + " session.kernel.do_shutdown(restart=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4c898fe", + "metadata": {}, + "outputs": [], + "source": [ + "# Validate installation\n", + "!viscy --help" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4e695f9", + "metadata": {}, + "outputs": [], + "source": [ + "# Download the example dataset\n", + "!wget -m -np -nH --cut-dirs=5 -R \"index.html*\" \"https://public.czbiohub.org/comp.micro/viscy/VS_datasets/VSCyto2D/test/a549_hoechst_cellmask_test.zarr/\"\n", + "# Download the model checkpoint\n", + "!wget https://public.czbiohub.org/comp.micro/viscy/VS_models/VSCyto2D/VSCyto2D/epoch=399-step=23200.ckpt" + ] + }, + { + "cell_type": "markdown", + "id": "dc8ccda1", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Run Model Inference\n", + "\n", + "The following code will run inference on a single field of view (FOV) of the example dataset.\n", + "This can also be achieved by using the VisCy CLI." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8ad5bdd8", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path # noqa: E402\n", + "\n", + "from iohub import open_ome_zarr # noqa: E402\n", + "from torchview import draw_graph # noqa: E402\n", + "\n", + "from cytoland.engine import FcmaeUNet # noqa: E402\n", + "from viscy_data.hcs import HCSDataModule # noqa: E402\n", + "from viscy_transforms import NormalizeSampled # noqa: E402\n", + "from viscy_utils.callbacks import HCSPredictionWriter # noqa: E402\n", + "from viscy_utils.trainer import VisCyTrainer # noqa: E402" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34663450", + "metadata": { + "lines_to_next_cell": 2 + }, + "outputs": [], + "source": [ + "# NOTE: Nothing needs to be changed in this code block for the example to work.\n", + "# If using your own data, please modify the paths below.\n", + "\n", + "# TODO: Set download paths, by default the working directory is used\n", + "root_dir = Path()\n", + "# TODO: modify the path to the input dataset\n", + "input_data_path = root_dir / \"a549_hoechst_cellmask_test.zarr\"\n", + "# TODO: modify the path to the model checkpoint\n", + "model_ckpt_path = root_dir / \"epoch=399-step=23200.ckpt\"\n", + "# TODO: modify the path to save the predictions\n", + "output_path = root_dir / \"a549_prediction.zarr\"\n", + "# TODO: Choose an FOV\n", + "fov = \"0/0/0\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ff5bd62", + "metadata": {}, + "outputs": [], + "source": [ + "# Configure the data module for loading example images in prediction mode.\n", + "# See API documentation for how to use it with a different dataset.\n", + "# For example, View the documentation for the HCSDataModule class by running:\n", + "?HCSDataModule" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4bb5e84f", + "metadata": {}, + "outputs": [], + "source": [ + "# Setup the data module to use the example dataset\n", + "data_module = HCSDataModule(\n", + " # Path to HCS or Single-FOV OME-Zarr dataset\n", + " data_path=input_data_path / fov,\n", + " # Name of the input phase channel\n", + " source_channel=\"Phase3D\",\n", + " # Desired name of the output channels\n", + " target_channel=[\"Nuclei\", \"Membrane\"],\n", + " # Axial input size, 1 for 2D models\n", + " z_window_size=1,\n", + " # Batch size\n", + " # Adjust based on available memory (reduce if seeing OOM errors)\n", + " batch_size=8,\n", + " # Number of workers for data loading\n", + " # Set to 0 for Windows and macOS if running in a notebook,\n", + " # since multiprocessing only works with a `if __name__ == '__main__':` guard.\n", + " # On Linux, set it based on available CPU cores to maximize performance.\n", + " num_workers=4,\n", + " # Normalization strategy\n", + " # This one uses pre-computed statistics from `viscy preprocess`\n", + " # to subtract the median and divide by the interquartile range (IQR).\n", + " # It can also be replaced by other MONAI transforms.\n", + " normalizations=[\n", + " NormalizeSampled(\n", + " [\"Phase3D\"],\n", + " level=\"fov_statistics\",\n", + " subtrahend=\"median\",\n", + " divisor=\"iqr\",\n", + " )\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a5b229a", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the VSCyto2D model from the downloaded checkpoint\n", + "# VSCyto2D is fine-tuned from a FCMAE-pretrained UNeXt2 model.\n", + "# See this module for options to configure the model:\n", + "\n", + "?FullyConvolutionalMAE" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10674ebf", + "metadata": {}, + "outputs": [], + "source": [ + "vs_cyto_2d = FcmaeUNet.load_from_checkpoint(\n", + " # checkpoint path\n", + " model_ckpt_path,\n", + " model_config={\n", + " # number of input channels\n", + " # must match the number of channels in the input data\n", + " \"in_channels\": 1,\n", + " # number of output channels\n", + " # must match the number of target channels in the data module\n", + " \"out_channels\": 2,\n", + " # number of ConvNeXt v2 blocks in each stage of the encoder\n", + " \"encoder_blocks\": [3, 3, 9, 3],\n", + " # feature map channels in each stage of the encoder\n", + " \"dims\": [96, 192, 384, 768],\n", + " # number of ConvNeXt v2 blocks in each stage of the decoder\n", + " \"decoder_conv_blocks\": 2,\n", + " # kernel size in the stem layer\n", + " \"stem_kernel_size\": [1, 2, 2],\n", + " # axial size of the input image\n", + " # must match the Z-window size in the data module\n", + " \"in_stack_depth\": 1,\n", + " # whether to perform masking (for FCMAE pre-training)\n", + " \"pretraining\": False,\n", + " },\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9cbece7", + "metadata": {}, + "outputs": [], + "source": [ + "# Visualize the model graph\n", + "model_graph = draw_graph(\n", + " vs_cyto_2d,\n", + " (vs_cyto_2d.example_input_array),\n", + " graph_name=\"VSCyto2D\",\n", + " roll=True,\n", + " depth=3,\n", + " expand_nested=True,\n", + ")\n", + "\n", + "model_graph.visual_graph" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e284524b", + "metadata": {}, + "outputs": [], + "source": [ + "# Setup the trainer for prediction\n", + "# The trainer can be further configured to better utilize the available hardware,\n", + "# For example using GPUs and half precision.\n", + "# Callbacks can also be used to customize logging and prediction writing.\n", + "# See the API documentation for more details:\n", + "?VisCyTrainer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bffe587d", + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize the trainer\n", + "# The prediction writer callback will save the predictions to an OME-Zarr store\n", + "trainer = VisCyTrainer(callbacks=[HCSPredictionWriter(output_path)])\n", + "\n", + "# Run prediction\n", + "trainer.predict(model=vs_cyto_2d, datamodule=data_module, return_predictions=False)" + ] + }, + { + "cell_type": "markdown", + "id": "92396f37", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "# Model Outputs\n", + "\n", + "The model outputs are also stored in an OME-Zarr store.\n", + "It can be visualized in an image viewer such as [napari](https://napari.org/).\n", + "Below we show a snapshot in the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19e90a39", + "metadata": {}, + "outputs": [], + "source": [ + "# Read images from Zarr stores\n", + "# Choose the ROI for better visualization\n", + "y_slice = slice(0, 512)\n", + "x_slice = slice(0, 512)\n", + "\n", + "# Open the prediction store and get the 2D images from 5D arrays (t,c,z,y,x)\n", + "with open_ome_zarr(output_path / fov) as vs_store:\n", + " vs_nucleus = vs_store[0][0, 0, 0, y_slice, x_slice]\n", + " vs_membrane = vs_store[0][0, 1, 0, y_slice, x_slice]\n", + "\n", + "# Open the experimental fluorescence dataset\n", + "with open_ome_zarr(input_data_path / fov) as fluor_store:\n", + " fluor_nucleus = fluor_store[0][0, 1, 0, y_slice, x_slice]\n", + " fluor_membrane = fluor_store[0][0, 2, 0, y_slice, x_slice]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41ef52ea", + "metadata": {}, + "outputs": [], + "source": [ + "# Plot\n", + "import matplotlib.pyplot as plt # noqa: E402\n", + "import numpy as np # noqa: E402\n", + "from cmap import Colormap # noqa: E402\n", + "from skimage.exposure import rescale_intensity # noqa: E402\n", + "\n", + "\n", + "def render_rgb(image: np.ndarray, colormap: Colormap):\n", + " image = rescale_intensity(image, out_range=(0, 1))\n", + " image = colormap(image)\n", + " return image\n", + "\n", + "\n", + "# Render the images as RGB in false colors\n", + "vs_nucleus_rgb = render_rgb(vs_nucleus, Colormap(\"bop_blue\"))\n", + "vs_membrane_rgb = render_rgb(vs_membrane, Colormap(\"bop_orange\"))\n", + "merged_vs = (vs_nucleus_rgb + vs_membrane_rgb).clip(0, 1)\n", + "\n", + "fluor_nucleus_rgb = render_rgb(fluor_nucleus, Colormap(\"green\"))\n", + "fluor_membrane_rgb = render_rgb(fluor_membrane, Colormap(\"magenta\"))\n", + "merged_fluor = (fluor_nucleus_rgb + fluor_membrane_rgb).clip(0, 1)\n", + "\n", + "# Plot\n", + "# Show the individual channels and then fused in a grid\n", + "fig, ax = plt.subplots(2, 3, figsize=(15, 10))\n", + "\n", + "# Virtual staining plots\n", + "ax[0, 0].imshow(vs_nucleus_rgb)\n", + "ax[0, 0].set_title(\"VS Nuclei\")\n", + "ax[0, 1].imshow(vs_membrane_rgb)\n", + "ax[0, 1].set_title(\"VS Membrane\")\n", + "ax[0, 2].imshow(merged_vs)\n", + "ax[0, 2].set_title(\"VS Nuclei+Membrane\")\n", + "\n", + "# Experimental fluorescence plots\n", + "ax[1, 0].imshow(fluor_nucleus_rgb)\n", + "ax[1, 0].set_title(\"Experimental Fluorescence Nuclei\")\n", + "ax[1, 1].imshow(fluor_membrane_rgb)\n", + "ax[1, 1].set_title(\"Experimental Fluorescence Membrane\")\n", + "ax[1, 2].imshow(merged_fluor)\n", + "ax[1, 2].set_title(\"Experimental Fluorescence Nuclei+Membrane\")\n", + "\n", + "# turnoff axis\n", + "for a in ax.flatten():\n", + " a.axis(\"off\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "bc8778c1", + "metadata": { + "cell_marker": "\"\"\"" + }, + "source": [ + "## Responsible Use\n", + "\n", + "We are committed to advancing the responsible development and use of artificial intelligence.\n", + "Please follow our [Acceptable Use Policy](https://virtualcellmodels.cziscience.com/acceptable-use-policy) when engaging with our services.\n", + "\n", + "Should you have any security or privacy issues or questions related to the services,\n", + "please reach out to our team at [security@chanzuckerberg.com](mailto:security@chanzuckerberg.com) or [privacy@chanzuckerberg.com](mailto:privacy@chanzuckerberg.com) respectively." + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "all", + "main_language": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/applications/cytoland/examples/vcp_tutorials/quick_start.py b/applications/cytoland/examples/vcp_tutorials/quick_start.py index d67a3545f..7f1dc3c74 100644 --- a/applications/cytoland/examples/vcp_tutorials/quick_start.py +++ b/applications/cytoland/examples/vcp_tutorials/quick_start.py @@ -87,8 +87,10 @@ # %% # Install the modular packages required for this example -# See the repositories/package indexes for the latest compatibility details -# !pip install cytoland viscy_data viscy_transforms viscy_utils torchview cmap iohub +# `cytoland` and `viscy-utils` are not yet published to PyPI, so they are +# installed from the GitHub monorepo; their siblings (viscy-data, viscy-models, +# viscy-transforms) resolve from PyPI automatically. +# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" torchview cmap iohub # %% # restart kernel if running in Google Colab From cff98a353f43b15044293255db769c0a07c5a59a Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:27 -0700 Subject: [PATCH 21/39] docs: cut CLAUDE.md content the code or global memory already states Port 93ac2343 from dynacell-models: drop the repo-layout tree, setup and common-command blocks, lint/docstring rules (pyproject.toml is the source) and the generic coding-philosophy sections. The DynaCell job runbook from the same file arrives with the dynacell PR, since it documents tools that only exist there. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 72 +++---------------------------------------------------- 1 file changed, 3 insertions(+), 69 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d83a7e6f4..cb0afc6bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,19 +4,7 @@ VisCy is a **uv workspace monorepo** for virtual staining and computational microscopy. Sub-packages live under `packages/`. -## Repo Layout - -``` -pyproject.toml # Root config (ruff, pytest, uv workspace) -packages/ - viscy-data/ # Data loading and Lightning DataModules - viscy-models/ # Neural network architectures - viscy-transforms/ # Image transforms -src/viscy/ # Umbrella package (re-exports) -applications/ # Self-contained research applications -``` - -### Packages vs Applications +## Packages vs Applications - **Shared code belongs in `packages/`**, not in applications. - **Applications must not import from each other.** If two applications need the same logic, move it to an existing package or create a new one. @@ -30,16 +18,6 @@ applications/ # Self-contained research applications Use `uv` package manager. Run commands with `uv run `. Edit `pyproject.toml` to modify dependencies and sync to update `uv.lock`. -```sh -uv venv -p 3.13 -uv sync --all-packages --all-extras -``` - -If `uv` is not installed: -```sh -curl -LsSf https://astral.sh/uv/install.sh | sh -``` - On HPC, symlink the uv cache out of your home directory first: ```sh mkdir -p /hpc/mydata/firstname.lastname/.cache/uv && ln -s /hpc/mydata/firstname.lastname/.cache/uv ~/.cache/uv @@ -98,23 +76,8 @@ Examples (verified against the `applications/dynacell/configs/benchmarks/virtual When in doubt, read both `train_dataloader` overrides directly — they are short. Don't infer from comments alone. -### Common Commands - -```sh -uvx ruff check packages/ # lint -uvx ruff check --fix packages/ # lint + auto-fix -uvx ruff format packages/ # format -uv run pytest # all tests -``` - ### Testing -```sh -uv run pytest # all tests -uv run pytest packages/viscy-data/ # single package (data) -uv run pytest packages/viscy-models/ # single package (models) -``` - Prefer `{file}_test.py` in the same directory as `{file}.py`, unless there are import issues, in which case use `tests/`. Run the suite in parallel with `pytest -n ` (pytest-xdist, in the `test` group). The root `conftest.py` pins each worker to one OpenMP/BLAS thread. Without that pin, torch in every worker grabs every core and the parallel run is 10x slower than it should be. `-n auto` counts the cores the shell may use, so inside a 1-CPU `srun` step it runs a single worker; give the step more CPUs (`srun --overlap -c N`) first. @@ -139,10 +102,6 @@ Run the suite in parallel with `pytest -n ` (pytest-xdist, in the `test` ### Code Style -- Docstrings use **numpy style** (`convention = "numpy"`). -- Lint rules: `D, E, F, I, NPY, PD, W`. -- `D` rules are ignored in `**/tests/**` and notebooks. -- Format: double quotes, spaces, 120 char line length. - Use a subagent to run tests and complex bash commands, especially those expected to return complex output. - Run independent tasks (multi-file edits across separate concerns, cross-cutting verifications, distinct review angles) in parallel via concurrent subagents in a single message. Subagent startup overhead is negligible relative to sequential blocking. Only sequence subagents when a later task needs an earlier task's output. @@ -189,17 +148,7 @@ Ask yourself if your test is actually covering the true function. ### Coding Philosophy -#### 1. Think Before Coding - -Don't assume. Don't hide confusion. Surface tradeoffs. - -Before implementing: -- State your assumptions explicitly. If uncertain, ask. -- If multiple interpretations exist, present them — don't pick silently. -- If a simpler approach exists, say so. Push back when warranted. -- If something is unclear, stop. Name what's confusing. Ask. - -#### 2. Simplicity First +#### 1. Simplicity First Minimum code that solves the problem. Nothing speculative. @@ -210,7 +159,7 @@ Minimum code that solves the problem. Nothing speculative. - If you write 200 lines and it could be 50, rewrite it. - Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. -#### 3. Surgical Changes +#### 2. Surgical Changes Touch only what you must. Clean up only your own mess. @@ -225,18 +174,3 @@ When your changes create orphans: - Don't remove pre-existing dead code unless asked. The test: every changed line should trace directly to the user's request. - -#### 4. Goal-Driven Execution - -Define success criteria. Loop until verified. - -Transform tasks into verifiable goals: -- "Add validation" → "Write tests for invalid inputs, then make them pass" -- "Fix the bug" → "Write a test that reproduces it, then make it pass" -- "Refactor X" → "Ensure tests pass before and after" - -For multi-step tasks, state a brief plan: -1. [Step] → verify: [check] -2. [Step] → verify: [check] - -Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. From 69391a1a8ac83cf351dac557eebd46a3371aead4 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:41 -0700 Subject: [PATCH 22/39] ci: run the viscy-utils tests in their own job viscy-utils had no CI job on main or on dynacell-models: four of its test files ran inside the dynacell job and the rest (losses, metrics, meta_utils, optimizer health, CLI, lazy imports) ran nowhere. Mirror the viscy-data job on ubuntu-latest for Python 3.12 and 3.13, and make the gate job wait for it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 616044d9a..8e4dbb2ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,6 +91,33 @@ jobs: run: uv run --frozen pytest --cov=viscy_data --cov-report=term-missing working-directory: packages/viscy-data + test-utils: + name: Test Utils (Python ${{ matrix.python-version }}, ubuntu-latest) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up uv with Python ${{ matrix.python-version }} + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-suffix: ubuntu-latest-${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --frozen --all-extras --dev + working-directory: packages/viscy-utils + + - name: Run tests with coverage + run: uv run --frozen pytest --cov=viscy_utils --cov-report=term-missing + working-directory: packages/viscy-utils + test-applications: name: Test (${{ matrix.application }}, Python 3.13, ubuntu-latest) runs-on: ubuntu-latest @@ -166,7 +193,7 @@ jobs: check: name: All tests pass if: always() - needs: [test, test-data, test-data-extras, test-applications, test-dynacell-configs] + needs: [test, test-data, test-data-extras, test-utils, test-applications, test-dynacell-configs] runs-on: ubuntu-latest steps: - name: Verify all test jobs succeeded From cf8c7edbb03905d25e89b62d9f6c47f1f95e7ef0 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:42 -0700 Subject: [PATCH 23/39] ci(dynacell): run the launcher, engine and prediction-writer suites Port the test-dynacell-configs job from dynacell-models, minus the eight test files that only arrive with the dynacell PR: sync viscy-utils' test group with dynacell, and add test_submit_benchmark_batch, test_engine, test_pipeline_cache, feature_metrics_test and the viscy-utils prediction-writer, prediction-metadata, monitor-health and LR-cooldown tests. The dynacell-models PR target is not carried over. Source on dynacell-models: 33d2fee5, 74fa18a4 (#497), 2d1ed32c (#506), 4450efdc (#518), 2798dee8 (#519), 2f9ef319 (#528). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e4dbb2ad..d1d5dcbef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -146,7 +146,7 @@ jobs: working-directory: applications/${{ matrix.application }} test-dynacell-configs: - name: Test dynacell benchmark configs (Python 3.13, ubuntu-latest) + name: Test dynacell configs and regressions (Python 3.13, ubuntu-latest) runs-on: ubuntu-latest steps: @@ -166,10 +166,11 @@ jobs: # (falls back to numpy/scikit-image without CUDA), so the eval extra # installs and runs on a GPU-less runner. eval_gpu (cupy/cucim) is NOT # installed — those are the only CUDA-only deps. - run: uv sync --frozen --extra eval --group test + # Include viscy-utils' test group for the prediction-writer fixtures. + run: uv sync --frozen --package dynacell --package viscy-utils --extra eval --group test working-directory: applications/dynacell - - name: Run benchmark-schema + submit-tool + eval-runtime tests + - name: Run benchmark configs and pipeline regressions # test_benchmark_config_composition + test_submit_benchmark_job cover # config composition + the launcher. test_runtime + the two # test_evaluation_pipeline_parallel* suites cover the runtime module, @@ -179,14 +180,26 @@ jobs: # segmenter + feature-extractor model loads — but pipeline import still # needs the eval stack). test_evaluation_grouped drives the # multi-condition driver against the same cache-only fixture. + # + # test_submit_benchmark_batch covers the OTHER two srun emitters (array + # + chained-serial), which drifted out of sync with the single-job + # template because only the latter was under test. run: | - uv run --frozen pytest \ + uv run --frozen --no-sync pytest \ tests/test_benchmark_config_composition.py \ tests/test_submit_benchmark_job.py \ + tests/test_submit_benchmark_batch.py \ tests/test_runtime.py \ tests/test_evaluation_pipeline_parallel.py \ tests/test_evaluation_pipeline_parallel_cpu.py \ tests/test_evaluation_grouped.py \ + tests/test_engine.py \ + tests/test_pipeline_cache.py \ + src/dynacell/evaluation/feature_metrics_test.py \ + ../../packages/viscy-utils/tests/test_prediction_writer.py \ + ../../packages/viscy-utils/tests/test_prediction_metadata.py \ + ../../packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py \ + ../../packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown_test.py \ -v working-directory: applications/dynacell From fdcbfba66d8dbbf4f6239bb12dd1d7576ebee622 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 15:50:58 -0700 Subject: [PATCH 24/39] build: re-lock against the ported packages and main's dynacell Start from dynacell-models' uv.lock and re-resolve with `uv lock` against this tree, whose applications/dynacell/pyproject.toml is still main's. Against dynacell-models' lock this drops the ten dynacell-only packages (dinov3, etils, ftfy, jsonpath-rw, mlcroissant, pandas-stubs, ply, rdflib, submitit, termcolor) and resolves cubic back to 0.7.0; the dynacell PR restores them. Against main's lock it adds pytest-xdist and moves boto3/botocore 1.43.0 -> 1.43.20, cellpose 4.1.1 -> 4.2.1.1, pyarrow 19.0.1 -> 24.0.0, s3transfer and tracksdata; torch and lightning are unchanged. `uv lock --check` exits 0 (main's own lock does not). Co-Authored-By: Claude Opus 5.5 --- uv.lock | 2509 +++++++++++++++++++++++++------------------------------ 1 file changed, 1144 insertions(+), 1365 deletions(-) diff --git a/uv.lock b/uv.lock index 9b52e7d2d..9356f4a71 100644 --- a/uv.lock +++ b/uv.lock @@ -1,18 +1,24 @@ version = 1 -revision = 3 -requires-python = ">=3.12, <3.14" +revision = 5 +requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and platform_machine == 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and platform_machine != 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine == 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", - "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", "python_full_version < '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", - "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'emscripten'", "python_full_version < '3.13' and platform_machine != 's390x' and sys_platform == 'emscripten'", - "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'emscripten'", "python_full_version < '3.13' and platform_machine == 's390x' and sys_platform == 'emscripten'", - "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.13' and platform_machine != 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.13' and platform_machine == 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -22,7 +28,6 @@ members = [ "cytoland", "dynacell", "dynaclr", - "eet-tracking", "viscy", "viscy-data", "viscy-models", @@ -85,24 +90,6 @@ dependencies = [ { name = "tifffile" }, ] -[[package]] -name = "aiobotocore" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "aioitertools" }, - { name = "botocore" }, - { name = "jmespath" }, - { name = "multidict" }, - { name = "python-dateutil" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -169,15 +156,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" }, { url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" }, { url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" }, -] - -[[package]] -name = "aioitertools" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" }, + { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" }, + { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" }, + { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" }, + { url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" }, ] [[package]] @@ -224,15 +243,6 @@ requires-dist = [ ] provides-extras = ["dev"] -[[package]] -name = "alabaster" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, -] - [[package]] name = "anndata" version = "0.12.6" @@ -290,31 +300,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "app-model" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "in-n-out" }, - { name = "psygnal" }, - { name = "pydantic" }, - { name = "pydantic-compat" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8d/23/a9537fd7021e8b6ea7db96b73e66fe61e809a3b23338de07d1176e69e4ee/app_model-0.4.0.tar.gz", hash = "sha256:ccf667999f6c659e921ca3490b6da176971e67cf2f41abc34e33caa8cfa18573", size = 120529, upload-time = "2025-06-20T17:41:08.943Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/fa/d7f4ae02a3b115abdf561d05ba8d5cdf9e3b2b5724b45d91e6cf08c163c3/app_model-0.4.0-py3-none-any.whl", hash = "sha256:5b1d69ee023d955b0c7a525771fd2fc02ff9d82cd2f12a982949423c3a901210", size = 65710, upload-time = "2025-06-20T17:41:07.696Z" }, -] - -[[package]] -name = "appdirs" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, -] - [[package]] name = "appnope" version = "0.1.4" @@ -345,6 +330,16 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, @@ -433,6 +428,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/aa/e8d7dc17054815fd4a33032a73824d51129c607db3cc720e34e11b684994/awscrt-0.33.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:b99351915b0830eb89e997409970a90d164991ab765f357c9719eba9e1e45406", size = 4195537, upload-time = "2026-05-22T19:42:35.824Z" }, { url = "https://files.pythonhosted.org/packages/5e/b0/58e6a1945223610b5866eecb5978bf86dcb28eef85a47813c9dbd646c1de/awscrt-0.33.0-cp313-cp313t-win32.whl", hash = "sha256:ebf12555563e14f8d6d89a23456cb7b907481905efc3a2c1ae74928863685ab5", size = 4138190, upload-time = "2026-05-22T19:42:37.34Z" }, { url = "https://files.pythonhosted.org/packages/c3/be/fc020b8488f0d0e0b48d3d331ef4a3fca175e08894556b8f8eb7db586ad9/awscrt-0.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7ac4afb54890343335807e9e25277030a429f069112146418ade98c02a2a211f", size = 4296418, upload-time = "2026-05-22T19:42:38.762Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/fcb5d4bb2c7f8bcb28de35046e4b57f5b95f493305c95e7720ab0103df90/awscrt-0.33.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3911132882a6687712b27541b55f19554a0a2e61e184f95ea35eddce38201d37", size = 3430854, upload-time = "2026-05-22T19:42:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/a7/26/2c866f0bb59a8cd72e4149369bd3aaefa1636390def7d2503125ee9ad076/awscrt-0.33.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e5627e05708ed036d092d01480e99eba92c370b4823c0f88a2389f68735cfcd", size = 4037329, upload-time = "2026-05-22T19:42:42.603Z" }, + { url = "https://files.pythonhosted.org/packages/57/e3/f7667f059a0162e8b5fdd3967fc36f5efdea5410edfcb91dc9774ddd0d5e/awscrt-0.33.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:26de77d818810074c84de5e3819c14ecba208da51faa3c16350c8c760384520d", size = 4325550, upload-time = "2026-05-22T19:42:44.393Z" }, + { url = "https://files.pythonhosted.org/packages/59/1c/7361ff0706189c6e08ee86bf311d75a68aefea7cf3e13884f02a0515d743/awscrt-0.33.0-cp314-cp314t-win32.whl", hash = "sha256:f460bf5d16580baa88c44eb73a08d46d0125146743b7849a2d338f39a8951c08", size = 4219307, upload-time = "2026-05-22T19:42:45.972Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/409e529c5ef528b09ddf0ef116d23448b417a301c25f4cfda4d2b0451eae/awscrt-0.33.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8d4d506da517475c935cfa89a7626d8d1610616c593b1c32b2dd5a4286f7e07d", size = 4396689, upload-time = "2026-05-22T19:42:47.878Z" }, ] [[package]] @@ -508,77 +508,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/89/70d22710fe3eb5eaad18fd74277ca970f1622bdd67279643268c9f489cf8/blosc2-4.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5fe4e26efde57e5fafb17518aaa8825d6ce1da6cb1a87785594edf8cc0474154", size = 6353541, upload-time = "2026-06-02T18:03:36.692Z" }, { url = "https://files.pythonhosted.org/packages/71/c6/054e8499bb2f61fcdaaff258408d0deb859fc077e50fa9389983cd98bbaf/blosc2-4.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2edede1fed53a485cb6153a8bc325d05bbc388cec737419fe556a1edf7e3b5e0", size = 6647836, upload-time = "2026-06-02T18:03:38.588Z" }, { url = "https://files.pythonhosted.org/packages/2e/be/89a8805247e8de0fba12a4f6ac254bb156fd0576742afb87714e58fb7a7e/blosc2-4.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:51d7537d97a50f0f8336550f0a5ec66e93599c8951a4144453a7c5ba60aef15b", size = 4203672, upload-time = "2026-06-02T18:03:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/ccc8fcedc661bd8697d7fb601c5d7f1170cf981662e2d8270239dc5c339a/blosc2-4.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f4ec2fd1f82d0f75eaa91c263d999ceda713d779a1d2ddb918719490825e8d76", size = 5954517, upload-time = "2026-06-02T18:03:42.576Z" }, + { url = "https://files.pythonhosted.org/packages/0f/12/e809357c8f8c617bec5ac88d6a982e3e32ea7dd9067bc3a47ebeffdde844/blosc2-4.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:016db2f3926d7d39e262ead711fc0980f0061ab7899aa27a724f335521345a90", size = 5110449, upload-time = "2026-06-02T18:03:44.739Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/37eeb0aa926bbe0d112c6ec1a6d7ce20fa3cf8f431fd774e52c96fcdb1b5/blosc2-4.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30d2da937c886a1efa7b93bb08a23cdbd067f10308a6a329094a8e10f4cc44c3", size = 6365820, upload-time = "2026-06-02T18:03:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/d5ca09221aca7ec497b63635cb6260cb0ff9b997bc6cc195e648407ea40e/blosc2-4.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f803cecd7f69062a64fa287367dbad6989ad201278a94bf2a5ddf56d6edab965", size = 6647422, upload-time = "2026-06-02T18:03:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3b/91e10bfe3cdee2708d17508c4d8fdbc0f8e328ad53eec21b8d024e0403f2/blosc2-4.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:2e9e0febd93bb4d7ecff1c5a0465da9c6e3b81fb262d655234732bccaf3caad3", size = 4286622, upload-time = "2026-06-02T18:03:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3e/cb9719a9730109fe2d6dbabf253277e0a0e56d0805c5d6c6c6b0cf8e5d10/blosc2-4.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cd8dbf99e0f30f6315037720bb2731b964ba84961eab5911d90ee8e80af046f0", size = 5989949, upload-time = "2026-06-02T18:03:54.804Z" }, + { url = "https://files.pythonhosted.org/packages/7f/cb/695999125111bb17ca81d54ea06425982095fffab2fcc3cbeac5e01e5dae/blosc2-4.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eb2ea1089d15b8f91201df028f7e9c428065acd4f7721f8878a6f25caaac228c", size = 5157341, upload-time = "2026-06-02T18:03:56.85Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/935022910ccd525c93cbd47ff257cc38a3f415ef7d170ca61ee47e8a37e0/blosc2-4.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7bfb7ab86b13f8cefe7ea08740e08df66d8fd63cb180c81a3a1321d17073f00d", size = 6317857, upload-time = "2026-06-02T18:03:58.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f3/2fcf50f0331fc80f4dcaa9199dba278609b3e7d0ae8decbcea4ce54de8b2/blosc2-4.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae547c72b9cc4ecbd6278e0f68306a00664ba66c8b3176b45d2c8426c28bd09", size = 6612822, upload-time = "2026-06-02T18:04:01.206Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ff/71e766357b50d11a232e233cfd16caf85cc550e6ccea0b45bb4425b22357/blosc2-4.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5bbdf3bf5746e18e6ec1bc4f33e758e39a01c3df98b127ffc30f173455229362", size = 4346450, upload-time = "2026-06-02T18:04:03.344Z" }, ] [[package]] name = "boto3" -version = "1.43.0" +version = "1.43.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/65/47670987f2f9e181397872c7ee6415b7b95156d711b7eab6c55f66e575bc/boto3-1.43.0.tar.gz", hash = "sha256:80d44a943ef90aba7958ab31d30c155c198acc8a9581b5846b3878b2c8951086", size = 113143, upload-time = "2026-04-29T22:07:49.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/7a/a3605b814fedd052fec798932f8d2b0459e61fd60ab2f652de1ce95815df/boto3-1.43.20.tar.gz", hash = "sha256:536d023d9f72dcec04254f71b216b78c6ee644be56ac61e780a6c73c1ddea445", size = 113210, upload-time = "2026-06-02T19:43:36.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl", hash = "sha256:8ebe03754a4b73a5cb6ec2f14cca03ac33bd4760d0adea53da4724845130258b", size = 140497, upload-time = "2026-04-29T22:07:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/5d/af/05411a9dc841d6e2344f2b8101338991bb32248e1bfa7cf8e2288327c025/boto3-1.43.20-py3-none-any.whl", hash = "sha256:ee878fa5cd739f38e68fbad535ff9bc3bf7c409290943a62f5e8bddad486aad4", size = 140536, upload-time = "2026-06-02T19:43:34.21Z" }, ] [[package]] name = "botocore" -version = "1.43.0" +version = "1.43.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, -] - -[[package]] -name = "build" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "os_name == 'nt'" }, - { name = "packaging" }, - { name = "pyproject-hooks" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, -] - -[[package]] -name = "cachey" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "heapdict" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/9c/e3c959c1601013bf8a72e8bf91ea1ebc6fe8a2305bd2324b039ee0403277/cachey-0.2.1.tar.gz", hash = "sha256:0310ba8afe52729fa7626325c8d8356a8421c434bf887ac851e58dcf7cf056a6", size = 6461, upload-time = "2020-03-11T15:34:08.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/f0/e24f3e5d5d539abeb783087b87c26cfb99c259f1126700569e000243745a/cachey-0.2.1-py3-none-any.whl", hash = "sha256:49cf8528496ce3f99d47f1bd136b7c88237e55347a15d880f47cefc0615a83c3", size = 6415, upload-time = "2020-03-11T15:34:07.347Z" }, -] - -[[package]] -name = "cbcbox" -version = "2.929" -source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/04/ab59a00e6f003a5bee49464a14fa801b1498ecf1fb1ec32d5d847ab0e639/botocore-1.43.20.tar.gz", hash = "sha256:84fec90a8f25d11bb98176a3fa31c8b8e031b5be37d56f9a1a16f825b483ad4b", size = 15453850, upload-time = "2026-06-02T19:43:25.572Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9e/c41844f3a746500b88982817233f3f2f4fa6d3a554cedb9222bac9efcbea/cbcbox-2.929-py3-none-macosx_15_0_arm64.whl", hash = "sha256:610fd250f737b19f599d56cca299682a4519472b3ff6a13044502eb71e03931e", size = 59626733, upload-time = "2026-03-23T15:28:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/3f/08/172af0d618ede8668862ae9c83ffcd929bc966610c1ce357b268edb33645/cbcbox-2.929-py3-none-macosx_15_0_x86_64.whl", hash = "sha256:b369071081176fd55ba9d104c2f2330133c36f0264ebe0215c50a366f290a050", size = 87883726, upload-time = "2026-03-23T15:28:24.874Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c7/a57493dd959a57dcb8dc31aeef468c0e95d066d4bbf8fc59f9dda3a1fd97/cbcbox-2.929-py3-none-manylinux2014_aarch64.whl", hash = "sha256:62841ab4ed4e1c368bcba8d62c66cd7dd03f3dfaf1ecf4a01c6385a122d1946e", size = 145740150, upload-time = "2026-03-23T15:28:29.617Z" }, - { url = "https://files.pythonhosted.org/packages/4c/86/502b81d060603e1016a8cb471064e4e65d8009c2feed60ea878b8513a728/cbcbox-2.929-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a57a403cc44fb2af0aa53cb790a7642390f1836a5ff6a7a0b31b30eb6597e3e0", size = 181208771, upload-time = "2026-03-23T15:28:35.544Z" }, - { url = "https://files.pythonhosted.org/packages/0c/85/2fef9c7c3054ebfb9b0e84fabbeac776a18514b025efa5abd768c889f560/cbcbox-2.929-py3-none-win_amd64.whl", hash = "sha256:0a91420befb965ec1763aab62d7908d3a89a51af845e2a4e05c09d99a67f1151", size = 135305901, upload-time = "2026-03-23T15:28:42.021Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/0bf341f86389a02cd3c4e6e2c597d15c7bf6c033a2abf1352473fc2e8354/botocore-1.43.20-py3-none-any.whl", hash = "sha256:1ee28afba70210f726c24f4bed1bf238a33542937fa2cf1b8cf2107287b85e04", size = 15135023, upload-time = "2026-06-02T19:43:20.939Z" }, ] [[package]] name = "cellpose" -version = "4.1.1" +version = "4.2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastremap" }, @@ -595,9 +567,9 @@ dependencies = [ { name = "torchvision" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/b0/3dde4f82033a4b8e35a48d7419aa9c4f3e7c1100055a135790a41c47f19b/cellpose-4.1.1.tar.gz", hash = "sha256:859c732c8edede78ec7ee6537c5cfb18ab54f3dc01846adc10edca0cec124094", size = 4252891, upload-time = "2026-03-31T16:40:23.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/e4/3d4606883a5489b86fa6ab5f65b9d0a243810f64efb934033fe425089d5a/cellpose-4.2.1.1.tar.gz", hash = "sha256:229eedea3977184202b2c87b3bd41c609facd742c7203bb4ad3cebd7b6bc6839", size = 4441350, upload-time = "2026-06-14T14:25:15.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/33/c2fbe5d074e2bb95bacdada02ebfa05915d211b67ab590419be1449d2115/cellpose-4.1.1-py3-none-any.whl", hash = "sha256:52d33d3c890df5572bec029ec4211388611b313c58f7b7073f47622b9befc32a", size = 213394, upload-time = "2026-03-31T16:40:21.147Z" }, + { url = "https://files.pythonhosted.org/packages/cc/20/d9d1fa1abffc8dab4381116ea0d91e8f8e4315acc038b76679608c097649/cellpose-4.2.1.1-py3-none-any.whl", hash = "sha256:6c5c5159fd459115bee390ffb01d94d5e1006b97c211bf3c1a7b37d5395a1a13", size = 203334, upload-time = "2026-06-14T14:25:13.797Z" }, ] [[package]] @@ -642,6 +614,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -682,6 +676,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -790,6 +816,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] [[package]] @@ -858,25 +906,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] -[[package]] -name = "ct3" -version = "3.4.0.post5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/c2/2a1605af41829cd2a247271b5b6f6b2dbc2e9407df2dd9bcafdceafcd4b3/ct3-3.4.0.post5.tar.gz", hash = "sha256:1c5f2000d52d591703c74f6f5f7ef427ed1b6501be28e3f1634f62c3a5d792e1", size = 303503, upload-time = "2025-11-29T12:38:18.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ae/55e9a8ce30c37c43cd0677310bf4d58ed65b8f08880fa3ffccdd86f62125/ct3-3.4.0.post5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:443bff9699ccb0fac142027bea3321a57fb33fe43ec7cccdbac5dd0be337dcbd", size = 863020, upload-time = "2025-11-29T12:39:51.975Z" }, - { url = "https://files.pythonhosted.org/packages/0c/fe/33cab98b7ba9408b837300899bf1bded951bb0e82772c2f4f786951cb5e8/ct3-3.4.0.post5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61ec5dc8261ff8bb3b0d579ba95f8be9a938251e9a3547cc09ca65de702dbc69", size = 866396, upload-time = "2025-11-29T12:44:12.326Z" }, - { url = "https://files.pythonhosted.org/packages/9f/27/3c3e5398fa49227b2dd67211b4402c84ed9d5b48a4257bf5b1196f9b6a8e/ct3-3.4.0.post5-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3be2cd9d24b6c6741e90c3f854b20dd019b0c47f7887962972a515b92c35204e", size = 866146, upload-time = "2025-11-29T12:38:20.844Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0e/a065a472cadd8821962aa1f3eb988bcaa93a6cf04eae89b4f72251f5df70/ct3-3.4.0.post5-cp312-cp312-win_amd64.whl", hash = "sha256:9f8ed00963e774c41031727876df99694695b62c791e7de2e4352892ac1e1e06", size = 187276, upload-time = "2025-11-29T12:43:22.548Z" }, - { url = "https://files.pythonhosted.org/packages/a3/85/f0536596eda1884f2c982f675eeaea63de4fd7aeab50785c1006bce1ccdd/ct3-3.4.0.post5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b9a98284dad8fd32e49c05f5223226610cadef69a6a816a4e979fa8af6d870c5", size = 873390, upload-time = "2025-11-29T12:44:00.222Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/e8aa47b6e4a39ee57618198ec778e7a2aa093f267c3b868bad6230ecfff9/ct3-3.4.0.post5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:371baa49402fdbf50924fa159000c54503071a8354922513c81bdd91234c17d1", size = 876586, upload-time = "2025-11-29T12:43:54.764Z" }, - { url = "https://files.pythonhosted.org/packages/04/1e/eeac3163475c7620621ab582375403a90c5c9d0b0704eab328ca62724383/ct3-3.4.0.post5-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a85e6d708344ebe93e846d2855d73e0db73b6a8c7aabdc2f4daf27b56abf44b9", size = 876462, upload-time = "2025-11-29T12:38:21.45Z" }, - { url = "https://files.pythonhosted.org/packages/77/a2/67e560a207ef077cff1937c4c983a78935544ea3b02262eed12feba664f7/ct3-3.4.0.post5-cp313-cp313-win_amd64.whl", hash = "sha256:216201ee1978bc653d40ccf7a1cafc5076e81670d64971a5be87e83ade0781fc", size = 187357, upload-time = "2025-11-29T12:42:36.992Z" }, -] - [[package]] name = "cubic" version = "0.7.0" @@ -908,6 +970,8 @@ wheels = [ { url = "https://pypi.nvidia.com/cucim-cu13/cucim_cu13-26.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:655292496907f5c3728ed5343806c940f226c2978ba0e6e327d2687284717cbb" }, { url = "https://pypi.nvidia.com/cucim-cu13/cucim_cu13-26.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdd2c7ce1029d80a960d3471e42a8f75038c85e02d2c66a803d1c9f8e46b05db" }, { url = "https://pypi.nvidia.com/cucim-cu13/cucim_cu13-26.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:692f4c640ee1c5d1d6dd65db6bebf9210f5bd3ac39354be7580a10322ba504a3" }, + { url = "https://pypi.nvidia.com/cucim-cu13/cucim_cu13-26.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49013b329d4887fc00688c8f5edb61c09541e7dfa2f2bff651981fd23a7f0711" }, + { url = "https://pypi.nvidia.com/cucim-cu13/cucim_cu13-26.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8db4b750de7898050e564804e88b363a927c25d9280dbfa4071ca9bde6a5f74" }, ] [[package]] @@ -915,13 +979,17 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, ] [[package]] @@ -942,34 +1010,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -987,6 +1055,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/0f/149a9e6c561f44e6522be6c3fdacdaed17d8fd5a1651004a0782ce045502/cupy_cuda13x-14.1.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:cc671cb766f1c42cb6eed3bf27e9da6092721bcb3d77e09aef28e2f5b089ea38", size = 72811428, upload-time = "2026-06-01T04:54:22.432Z" }, { url = "https://files.pythonhosted.org/packages/80/f6/f4cd043ad8c8e18410e9cea73a8de8778a0a05c705b8505729a794758bf1/cupy_cuda13x-14.1.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:ff4526725c1398ea19d578044737aa24859b08e1f469aba0ca45dfd6f9aa505d", size = 69094726, upload-time = "2026-06-01T04:54:26.424Z" }, { url = "https://files.pythonhosted.org/packages/54/64/f2b60a0f4d73820d774906bf8684df0c8494820d78b3494121c7ec57ee25/cupy_cuda13x-14.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:54949b4e4f90e3588eeef25b55037f156578364e4835eb9ae1b93e236f2e58d5", size = 35171412, upload-time = "2026-06-01T04:54:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/9bc8d6d40003640653161e2dba78740f2055df8bb48e216eed5584cecc3c/cupy_cuda13x-14.1.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:8b068f6958811bc50d81bd0a83267ec4cffc3d7e842120b9646ec547f5a69298", size = 72680680, upload-time = "2026-06-01T04:54:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/8c79c577e9b35c60e5600217d1a849ab76c18a289f99a4145aa85388a531/cupy_cuda13x-14.1.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:39f8aeeb5bad16603956f8ec00094e058a9dd5db92ab514975686750363c3692", size = 68432306, upload-time = "2026-06-01T04:54:38.882Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/8d4d77138eb5f60a2a1fb5484a89dad2bc6bc6283682179994d722cfb4b5/cupy_cuda13x-14.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:dae6f77edfe96ab8e014d3e589dca33906d212fdf8f72e66850d4612bc031783", size = 35323057, upload-time = "2026-06-01T04:54:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c4/eb46fec30fffd7a57a80cc7f027e50a1d530b332e81362a8b874905da2d1/cupy_cuda13x-14.1.1-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:509f378c865c0b6bc9ed1dfe9e94b08b4511e9865363fdac55f9d66fe341e91d", size = 72984246, upload-time = "2026-06-01T04:54:46.464Z" }, + { url = "https://files.pythonhosted.org/packages/24/59/e0098ca2e1ba99a0086ad4b515b2d7dbb8ae0f13b9721c02eed0db4adec5/cupy_cuda13x-14.1.1-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:6c0d19789e9f9515988d51dffaff70af8b3dcdddcdf2fbafa9eff08df7be0a51", size = 68662887, upload-time = "2026-06-01T04:54:50.932Z" }, ] [[package]] @@ -998,35 +1071,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] -[[package]] -name = "cython" -version = "3.1.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/a4/ec55945b52d0b888e2a090450a5524001bfa17c8b68348379a154badb850/cython-3.1.8.tar.gz", hash = "sha256:518475fbe633ba30c1577669f417440f41e1a23ad6b6e2dbb20703cd457f04ef", size = 3193040, upload-time = "2026-01-03T18:30:25.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/6b/0a64844db2ba8a15f4108700afde4b8f73352fdbf73516ac26d5a025ea12/cython-3.1.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:850503d88092af519662cf66aef26f95db677bdbba55a482ae8658c5b3ac6ad4", size = 3059306, upload-time = "2026-01-03T18:31:20.734Z" }, - { url = "https://files.pythonhosted.org/packages/72/fb/7448e6ba0baf50df463ee0e4f8c332750778960f21c7e73b574e90dd85fb/cython-3.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0aa5443cc8bfc937369871fbba59990b36cd9af73ed70db9052ac980d44492e4", size = 2967016, upload-time = "2026-01-03T18:31:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a6/a5ae92b551ea07dcb106c39835d27e8133d3c80f7f752161795e2d5b8deb/cython-3.1.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9029c0eb895a56746ca0d4b3a6a0a57e4c90f2599204e99f7033818ea1c3022", size = 3385245, upload-time = "2026-01-03T18:31:24.544Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e0/97031ea58d8e68d9465039a9cb7ef0f94dae301907fc352d69f973587008/cython-3.1.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9cd8d1194ab8f52898be10d252ff17360e0af0e2a6662a8d9688fdd8e4cdd7e", size = 3163833, upload-time = "2026-01-03T18:31:28.108Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5e/b590aff6c332fb6bf5c12d2fae0383f6e866b47d0b1efd8bdc9443198ce0/cython-3.1.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bcbed7578f1986a61471476ea5deb0806591aa477230954dd9898a25d0f4e2", size = 3314265, upload-time = "2026-01-03T18:31:29.758Z" }, - { url = "https://files.pythonhosted.org/packages/1d/82/54aa9d4cf913a68bc9d2f96c3d24024bb032b1d864bbe2b238571f85743d/cython-3.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d973d25a767abcf977737221dc11befda98daf2573e8bc3c2e6995479505f92a", size = 3176271, upload-time = "2026-01-03T18:31:31.817Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/306dbd4b795d61f1452fb955b6a3872a02de060cbfe84c27e33b7c8ef987/cython-3.1.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a9288fa02135743719942deaeb44976399a5785a59f076221ef7e9b22cd0d571", size = 3397778, upload-time = "2026-01-03T18:31:33.48Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4c/9f4955fd1f14c5b05b72bb060b6568ba858b90084d7ee8f0744465458c0d/cython-3.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7eb8448eb1c3632c4684ee98db935cf7d3b54fb528a7a455841789316667cd88", size = 3324441, upload-time = "2026-01-03T18:31:35.774Z" }, - { url = "https://files.pythonhosted.org/packages/df/0f/036800c421ddf465312fa26872835f2388e8f4247aac1b9ac31527738d83/cython-3.1.8-cp312-cp312-win32.whl", hash = "sha256:c6b74c42bf6b2381d9fdb65f7eda44bcc5c29e4e96c677167495d2cd882d7a3c", size = 2483909, upload-time = "2026-01-03T18:31:37.72Z" }, - { url = "https://files.pythonhosted.org/packages/aa/77/59db4b7e09374a718564a4a9758c88285131d74cecde275991a2b513b05e/cython-3.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:cae61e8e730bfc7de917204c8b7c1ca8e3e0d134dcaa55a2e2ef98de741590fb", size = 2701034, upload-time = "2026-01-03T18:31:39.58Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/363c88e2038c7f8acdf0d8a35a79aee9c738946f613ba070ab4ab331ca2f/cython-3.1.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e10d15d2e7f245e61dec89c27da377c2136c08efb6cc2c50baea25b39aab3f9", size = 3047679, upload-time = "2026-01-03T18:31:41.607Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ea/31560e57d1cbe5e7b18a949e2103b8e08617a6796356f4771200b7b5dff5/cython-3.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e04bb68f932853a38bab7f7c024088a9f6510ccb45ad9a12cca6ebb09ec7a25f", size = 2955139, upload-time = "2026-01-03T18:31:45.551Z" }, - { url = "https://files.pythonhosted.org/packages/b6/77/523f572b65ebe160493a834652fa829d955c97dccef36f8f0d2bb82efbc9/cython-3.1.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85c39a8005f314ee90ab271d2b68b6725f94fc08c4e6728417b64704a7ebce04", size = 3359630, upload-time = "2026-01-03T18:31:48.025Z" }, - { url = "https://files.pythonhosted.org/packages/53/58/a3d7f9a0318c6334cb84b8029858dcbc64de0968fa7dde54ea54cdc8585b/cython-3.1.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1be05353f84e6a32cbae5869da1af72d356cc73a6dfa521423543688f6b59d67", size = 3153394, upload-time = "2026-01-03T18:31:49.661Z" }, - { url = "https://files.pythonhosted.org/packages/97/59/aaa43285de4970857787c4f43ae5a4f6bb40001c161552e1fe1b6bb5274b/cython-3.1.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03959ed8996e16550267469187442f3926ea3ba4bb220a06714e6203e4cb6017", size = 3304486, upload-time = "2026-01-03T18:31:51.763Z" }, - { url = "https://files.pythonhosted.org/packages/fa/56/45029eaa65b91579f557cd2c42b1dcabb0a4c2bff1be3b6b7e19743ff0f8/cython-3.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:354f43c6d0e312f650ed39c683e550f669ed18f49ca0bd5772f207ef31f8980c", size = 3169675, upload-time = "2026-01-03T18:31:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/d6ad2772c38fba22820ee9ba4a0b647232ebdcb16138751e70c02eafe286/cython-3.1.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b690edfa6b333f6139e8ecaa5af8fe87ea591f04d2f2b6a492ed4f2896d818c4", size = 3373228, upload-time = "2026-01-03T18:31:55.571Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/03f06cf647a0f579cfcfd7d336491426c95838cf9f3e4d6cb9e2c3c1e811/cython-3.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65d0cf78a14616cb05ff21f63b47bf369e23f353acd51420526c5eb3029f682a", size = 3316815, upload-time = "2026-01-03T18:31:57.929Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9d/2c5088d4c2c9d39692c5aca52fad35ade256d871ebecde61171acb3925fd/cython-3.1.8-cp313-cp313-win32.whl", hash = "sha256:34eb6dfaa0786a7477aaac86696f26efc052cda586d4f97a9d1d2c6b2ff6d26d", size = 2477612, upload-time = "2026-01-03T18:31:59.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/4cb436aab34f082454cf38cd21ed64021450fe51d1006aecaba62b6ac2c1/cython-3.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:fc3cbaee08d424d2852c7b134ca432501792f67021b73a029cf2f13fb6172423", size = 2698217, upload-time = "2026-01-03T18:32:01.635Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c7/b81516c9bb5da55d327b3cf2f33f39217719e73b524ab49a4028e18b6a66/cython-3.1.8-py3-none-any.whl", hash = "sha256:f36368c1d7ea4003c52e9e93f3556074a631a6482f8a32e781cb073e211e4df9", size = 1223843, upload-time = "2026-01-03T18:30:22.229Z" }, -] - [[package]] name = "cytoland" source = { editable = "applications/cytoland" } @@ -1121,6 +1165,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] @@ -1192,15 +1240,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - [[package]] name = "donfig" version = "0.8.1.post1" @@ -1230,6 +1269,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/7f/06ce3d5ce51a959be0534584ad2556e6c8be966ef1218a866c6c3d62e3c5/dtaidistance-2.4.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:94b841d6575e3ad715b4e213f0f04de25e23c2da3ac21ee9c6775b38f5bdfecf", size = 1738478, upload-time = "2026-02-13T08:14:51.715Z" }, { url = "https://files.pythonhosted.org/packages/db/8e/6c8a5c7710f9f5e3805281974ce8fea4ad0334c00a1e0f977977c045a594/dtaidistance-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0f2a65628aea82175e7f8c5e96faf5372c933ed40e2e39a84957d8fe305158d", size = 4341606, upload-time = "2026-02-12T22:23:46.977Z" }, { url = "https://files.pythonhosted.org/packages/9f/b6/7f77c6773380742660d09f379d43814b448296fc24c3fb1de15a3d813311/dtaidistance-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8c9ef4c7270d1a192e8f1b481c2e10e63c33c6e7edfc507acac7f3fdc19949f", size = 1441578, upload-time = "2026-02-12T22:23:48.897Z" }, + { url = "https://files.pythonhosted.org/packages/05/72/ac72a2e196c66c627c1f51684ffa2fd782e9cb042baa29898206f79b0d86/dtaidistance-2.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bda9849cd22800cd8b3c6abf29574db241458823814cd37302d01df99474175a", size = 2136045, upload-time = "2026-02-12T22:23:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/60f941b3fed3d8b94e7315a71b8f87294f2e053c1f3c19e53f7b6cc33689/dtaidistance-2.4.0-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:b5aa878c57f779cb9e141c0b4b1bc6b5be9c1721b349d153758112854a1b24cf", size = 1676822, upload-time = "2026-02-13T08:14:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/b9/82/b805e66d3b05e2cfc4e209b7d7f62ac31fb7e72615c3532c7edb0bf1943a/dtaidistance-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2d7d26b9023788f62e5f245db5b78b68aacde67e5aeaa75906ce7ddb251da7f", size = 4328390, upload-time = "2026-02-12T22:23:52.115Z" }, + { url = "https://files.pythonhosted.org/packages/2c/89/da956340797c0ea022a35b6b0df9a118cc4427401d9a8119dc104d4b48de/dtaidistance-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a35f2957bbf50b068b0b90f5cc9b442bf9b2a90b855e713811b8badf89a668d", size = 1462169, upload-time = "2026-02-12T22:23:53.598Z" }, + { url = "https://files.pythonhosted.org/packages/a7/02/16088a7bd17340a4e600f49bf4da16a9741ddbb737202a91363407e993b2/dtaidistance-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e083a5163c780a5b711d970c190d3eca83ebc0ec86e453e6f56d63b1d6d78139", size = 4332943, upload-time = "2026-02-12T22:23:55.009Z" }, ] [[package]] @@ -1252,6 +1296,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, ] [[package]] @@ -1483,156 +1534,12 @@ test = [ ] [[package]] -name = "edt" -version = "3.1.1" +name = "execnet" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/432c11df2a261fcae71dea11eeabfc5affd8ada22aaafa378212c1e9c9e2/edt-3.1.1.tar.gz", hash = "sha256:4b0ab5d898077eda76d1ebdec6e3a4085fd33d4c85195d71abf464ff225915c5", size = 3234159, upload-time = "2026-03-01T05:20:30.695Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/e2/f54b48f0385898b1e5ee406cf99394e7764c9d77fff1741f0fb979f784d0/edt-3.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7234587b35ce80dcf15ad7c81b45d79d75822c8b6b723e100573a5101bde90d9", size = 271864, upload-time = "2026-03-01T05:19:37.877Z" }, - { url = "https://files.pythonhosted.org/packages/2f/fe/3d0bb0dcb15382f290fc747d0709fe3d72adf5dad3f9f86d7743845d3a42/edt-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9850d32ed0e39fae0677a3ec33202a2f4a1be8bf19d0a9371b555adbca649f56", size = 253569, upload-time = "2026-03-01T05:19:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/05/b1/5d88ed781e1b72d24c9ff0a08fae46b7d704d1d057f1888fb922345c79f2/edt-3.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec432bdf8441bdf7d98bf3295280662dbba65c9dcecc54d8713b5238ffeb16e7", size = 2876532, upload-time = "2026-03-01T05:19:40.457Z" }, - { url = "https://files.pythonhosted.org/packages/f1/79/dad79e48da52bed034fcf3589d4f2d97a77d010b7776ffd0d29f6d63516b/edt-3.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f5e28666d723e577327d4fc2ce5c4fd572e20299a2b14e5bb829a675a95340e", size = 2969706, upload-time = "2026-03-01T05:19:42.217Z" }, - { url = "https://files.pythonhosted.org/packages/21/4d/1509a586dddd92288410d5de605c38f83fb1f1e7572193ca941bd2a88c86/edt-3.1.1-cp312-cp312-win32.whl", hash = "sha256:d713a4b0a8e097cb5bf2c7fdebd9dc76f6668de7c4e62e0ca846b68ddbd6f64d", size = 170753, upload-time = "2026-03-01T05:19:44.335Z" }, - { url = "https://files.pythonhosted.org/packages/24/88/623a76e7451d9b147bf8f025921c42063e85eac3729e88807af3336bab46/edt-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:da204a3b1e2b98a414a0d45a5e3806b91e0e16daf43581d98e7adef74f960769", size = 206157, upload-time = "2026-03-01T05:19:43.502Z" }, - { url = "https://files.pythonhosted.org/packages/2a/3a/46f02072334c42146ffd241251ac5ca795e84f6d6ffa87b95df37d047e64/edt-3.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05959ab8c0f9ce1a1b2736d27c83780f1a2ab77232428f5a606d5e447b7f9a3a", size = 270847, upload-time = "2026-03-01T05:19:46.505Z" }, - { url = "https://files.pythonhosted.org/packages/fa/15/92f5dceb6b953f1c31b4b627466977fc5a3d18514eeed27f7be1d287b105/edt-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b6922e81f1b47832a7a71302eb79372f3aea2a0bbca6bb6b6f6275df18bdaab", size = 252991, upload-time = "2026-03-01T05:19:47.489Z" }, - { url = "https://files.pythonhosted.org/packages/0a/eb/37e1a719432e747aa6b8d2046eae910f76640bd9be4d71f667cc3dda2356/edt-3.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467ce55f466ce21c5fb90462cabf82c73796920a8a9d5c0f655b01c6f427c7c3", size = 2867610, upload-time = "2026-03-01T05:19:49.352Z" }, - { url = "https://files.pythonhosted.org/packages/c0/16/35778deac2353ed385e81bda4dfd2eefcc1c0c1e1c083f4cb39ef0025d18/edt-3.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b464bd8bb6e14edfc07f8c1382700446bc20a2b521ec494f3de1fd5d20471cf3", size = 2954115, upload-time = "2026-03-01T05:19:52.558Z" }, - { url = "https://files.pythonhosted.org/packages/22/87/33fa8d7787489dd21957de56fbc6ad655dd156382b78e9ce2aeef125a044/edt-3.1.1-cp313-cp313-win32.whl", hash = "sha256:06c0ff97bc705803880a153c34b12b8245da51951198f4689942020a9bfcf6ff", size = 170606, upload-time = "2026-03-01T05:19:54.967Z" }, - { url = "https://files.pythonhosted.org/packages/fb/cf/ce2e98dc0b060c705c321090d9d9fcd9ab39b041309ccc0ca0ae9009cc94/edt-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d6804fe5fcbd7d03813eb48fc33cdfd25cb166d0241768969ee24f209fca07de", size = 206561, upload-time = "2026-03-01T05:19:53.741Z" }, -] - -[[package]] -name = "eet-features" -version = "0.1.0" -source = { editable = "../../../../../home/eduardo.hirata/repos/eet_features" } -dependencies = [ - { name = "dask", extra = ["array"] }, - { name = "h5py" }, - { name = "numpy" }, - { name = "polars" }, - { name = "scikit-image" }, - { name = "scipy" }, - { name = "toolz" }, - { name = "tracksdata" }, -] - -[package.metadata] -requires-dist = [ - { name = "dask", extras = ["array"], specifier = ">=2022.3.0" }, - { name = "h5py", specifier = ">=3.9.0" }, - { name = "numpy", specifier = ">=1.24.0" }, - { name = "polars", specifier = ">=0.19.0" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "scikit-image", specifier = ">=0.21.0" }, - { name = "scipy", specifier = ">=1.11.0" }, - { name = "toolz", specifier = ">=0.11.0" }, - { name = "tracksdata", git = "https://github.com/royerlab/tracksdata?rev=b605d2d6aa4bd12a31c07b499f5acf8411bac518" }, -] -provides-extras = ["dev"] - -[[package]] -name = "eet-inference" -version = "0.1.0" -source = { editable = "../../../../../home/eduardo.hirata/repos/eet_inference" } -dependencies = [ - { name = "dask" }, - { name = "eet-features" }, - { name = "gurobipy" }, - { name = "numpy" }, - { name = "polars" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "spatial-graph" }, - { name = "torch" }, - { name = "tracksdata" }, - { name = "typer" }, -] - -[package.metadata] -requires-dist = [ - { name = "dask" }, - { name = "eet-features", specifier = ">=0.1.0" }, - { name = "gurobipy", specifier = ">12.0.1,<13.0.0" }, - { name = "numpy", specifier = ">=1.24.0" }, - { name = "polars", specifier = ">=1.0.0" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, - { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, - { name = "pyyaml", specifier = ">=6.0.3" }, - { name = "rich", specifier = ">=14.3.1" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "spatial-graph", specifier = ">=0.0.5" }, - { name = "torch", specifier = ">=2.0.0" }, - { name = "tracksdata" }, - { name = "typer", specifier = ">=0.21.1" }, -] -provides-extras = ["dev"] - -[[package]] -name = "eet-tracking" -source = { editable = "applications/eet" } -dependencies = [ - { name = "dask", extra = ["array"] }, - { name = "eet-features" }, - { name = "eet-inference" }, - { name = "iohub" }, - { name = "napari", extra = ["pyqt5"] }, - { name = "napari-geff" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "torch" }, - { name = "ultrack" }, - { name = "ultrack-td" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ipykernel" }, - { name = "pytest" }, - { name = "pytest-cov" }, -] -jupyter = [ - { name = "ipykernel" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "dask", extras = ["array"] }, - { name = "eet-features", editable = "../../../../../home/eduardo.hirata/repos/eet_features" }, - { name = "eet-inference", editable = "../../../../../home/eduardo.hirata/repos/eet_inference" }, - { name = "iohub", specifier = ">=0.3.3" }, - { name = "napari", extras = ["pyqt5"] }, - { name = "napari-geff", editable = "../../../../../home/eduardo.hirata/repos/napari-geff" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "torch", specifier = ">=2.0.0" }, - { name = "ultrack", editable = "../../../../../home/eduardo.hirata/repos/ultrack" }, - { name = "ultrack-td", editable = "../../../../../home/eduardo.hirata/repos/ultrack-td" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipykernel" }, - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7" }, -] -jupyter = [{ name = "ipykernel" }] -test = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=7" }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] @@ -1644,31 +1551,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] -[[package]] -name = "fastapi" -version = "0.138.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" }, -] - -[[package]] -name = "fasteners" -version = "0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/18/7881a99ba5244bfc82f06017316ffe93217dbbbcfa52b887caa1d4f2a6d3/fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8", size = 25087, upload-time = "2025-08-11T10:19:37.785Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ac/e5d886f892666d2d1e5cb8c1a41146e1d79ae8896477b1153a21711d3b44/fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7", size = 18702, upload-time = "2025-08-11T10:19:35.716Z" }, -] - [[package]] name = "fastjsonschema" version = "2.21.2" @@ -1699,6 +1581,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/41/e70db40a9d1a2cb8d4bbd5416f1c3607c52beb482fdc28ca7c4ef05630c9/fastremap-1.19.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be07b2f2f240947563e3959e561eadd3c6574254d41adbf7146cb15b4bce8f92", size = 6976692, upload-time = "2026-04-29T00:39:19.867Z" }, { url = "https://files.pythonhosted.org/packages/c2/66/9bd9e8a3485876a9cb520fdb0616999db98500206180bed88ad8d3f5be71/fastremap-1.19.0-cp313-cp313-win32.whl", hash = "sha256:6619a877a16e732a4cc909268b10ec4dccffed70b3eb05eb6d259f5282094883", size = 432989, upload-time = "2026-04-29T00:39:24.756Z" }, { url = "https://files.pythonhosted.org/packages/bd/17/5f82236f7b742753204d1388ecac5b9b2cc8d3043fbfb7176f0df071bdad/fastremap-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:bba7d43fe3770960086cc69fcaca63768dd149159df42e02ef05fa1b8b2d79c0", size = 602795, upload-time = "2026-04-29T00:39:23.397Z" }, + { url = "https://files.pythonhosted.org/packages/77/ef/65cad42fe60d4b4611a1e95ba2d406d202e825de663d86961be82ee54b3b/fastremap-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00197b4b2609ec8a14953d3af4780ed4d3b2dedb6fa612c8854217d4673e07a8", size = 700693, upload-time = "2026-04-29T00:39:26.335Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ab/b9d5a26e3b3ab4bd624e610e0a71e47f73917edfbd4fbb70a26ef5fd4a98/fastremap-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8b8ba5e9df854c7fef5a77a48be5f892520d8222c6f4602c2de41f6966c9cefb", size = 591630, upload-time = "2026-04-29T00:39:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/63/7d/dd7b1938b142978ee2dc1e656d195aedb7527affb108f31124a884d66164/fastremap-1.19.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da8b90e6e8c6e32b9e18e490509ce098097bcd9cd0a30280b381460b507e98f9", size = 6796504, upload-time = "2026-04-29T00:39:32.508Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ea/101b1aea304127b532642f7985a816a65af29953de36e83877a2cc2a7a3e/fastremap-1.19.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03627be1bac4c66578de3793a7a3b9c6fda7ec2c16af0a1c52ee5ac025b132b1", size = 6882715, upload-time = "2026-04-29T00:39:36.398Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d1/1ae713ca32044abaa326df048f9196f68ac897fe1f1d4349ec14ad4f8f96/fastremap-1.19.0-cp314-cp314-win32.whl", hash = "sha256:228f2fbf1df40da2a3268283fcaf3da700fe477f4c8a99e042e9235eab4013d2", size = 438590, upload-time = "2026-04-29T00:39:41.741Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4c/45aaed0599d2e2eaea839c1604eb8a1d7850d122697890169360509e2618/fastremap-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:d63c5980fbe91301d4ae37caf4e054b908b37878b68c7c8cc8cd523689576be6", size = 616956, upload-time = "2026-04-29T00:39:39.789Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d7/a9251e6e279410fe3702e7bfec87b80e1559b686ff1c5a31c575483441cd/fastremap-1.19.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2cab361d27df0c3256729ef1a2047e2a0ee3d644f8bf6acf5f6d7aa01332f59a", size = 756620, upload-time = "2026-04-29T00:39:43.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/8c/26dd49757f78498619be417b1a9b062acb073f62873581f766debf9e9408/fastremap-1.19.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0305002a45e57eef021c9003638134434a1fc7fabe68e1c909132b66eef6a920", size = 648660, upload-time = "2026-04-29T00:39:44.689Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0a/40fb911b25b9c7bbc5c6a251d4109a686167791b4b6b794e2b4be62e144a/fastremap-1.19.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5704e0e632567a32adbee5f6ec85428983e1cac02531ae1ba283555ab7ab92ab", size = 6937623, upload-time = "2026-04-29T00:39:48.651Z" }, + { url = "https://files.pythonhosted.org/packages/39/3a/e0e07c196fab28a023888c27cdcfe7d421d44b12f24f1d782f3a40889bff/fastremap-1.19.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c495f7e5687a65766803b1ca8fb5468a0bcfd9b98b1585817ca91dfeb90672fc", size = 6764452, upload-time = "2026-04-29T00:39:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/311c7629d93e05b180742cb5319540fdbc506f4c8221d80cb09ff0ee9436/fastremap-1.19.0-cp314-cp314t-win32.whl", hash = "sha256:f9eddcee20abecbd96bed4c3547aaeb2a7d0692f9507e287c7001012a22ef568", size = 551561, upload-time = "2026-04-29T00:39:55.936Z" }, + { url = "https://files.pythonhosted.org/packages/09/09/8d81617b388d1be461e6e1dd318c287c3aba2c5bf3978c7075570443b2c3/fastremap-1.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c48b6400090721f92d3ea5ff2f5c289b9d034e12722d97c5f3239c189b00f5f4", size = 770527, upload-time = "2026-04-29T00:39:54.467Z" }, ] [[package]] @@ -1734,6 +1628,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/a0/aa0383524502292aa926e2cf88ebfda47f2ea5ca71e0e599c8536274dfab/fill_voids-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d9d007f9b473115ae3df49feb5ed3f632b28ed3e39be82a34937b8c786bda1", size = 2301405, upload-time = "2026-04-30T03:16:00.191Z" }, { url = "https://files.pythonhosted.org/packages/e0/6b/3d23e65c4a967e62ca097decaa7caf182a7814102ed34f42e67c2edcee1d/fill_voids-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4d9def2de9cafcec367341ce81078a30465c4de27ff55c973ee29edad07701c", size = 2360277, upload-time = "2026-04-30T03:16:01.546Z" }, { url = "https://files.pythonhosted.org/packages/ac/36/5d5d6a86d850a829dd3ea2e8036576909cc48d5ac7b26b1771625856a481/fill_voids-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:dc52abad93670d910d221330d11132ba9f14e9cea6df3d80772a1bbd007a9445", size = 173660, upload-time = "2026-04-30T03:16:02.957Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/1d2341811c284f396d4b2a8abf09a772a40cf456345f02bcfbaefac0701f/fill_voids-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:821f3e586df0d8202d4decc1b4751e49a980546d3ba600de9570557d1e6f3528", size = 208118, upload-time = "2026-04-30T03:16:03.91Z" }, + { url = "https://files.pythonhosted.org/packages/41/8a/564dba5148d7fea6c9241ee46cb306ef8c7fc3b73e802962d8f9456b76dd/fill_voids-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:72608da4556ddc09829702ab755ebfcd23ae03702303d7b757cfab102e680dc8", size = 190032, upload-time = "2026-04-30T03:16:05.392Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e4/5bd175448978d2b0742b3c2ab95e8e133088429933b25eda8a0bc5074fa4/fill_voids-2.1.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d505758017fe24deb32aca0a246ef95da3838aa623f858aeeae49aff4496d38", size = 1441672, upload-time = "2026-04-30T03:16:06.842Z" }, + { url = "https://files.pythonhosted.org/packages/41/4e/152d8a8dee6858ef378bdc6b0d7fdf380dd4d71e5ebc0d0522bc40a2efad/fill_voids-2.1.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c5666a561eab6cd24e6bcf1fe8d9e2f31b4e0d8b7f84e26eb882d53aff033f7", size = 1450956, upload-time = "2026-04-30T03:16:08.426Z" }, + { url = "https://files.pythonhosted.org/packages/42/e0/68b56983099a5ff02f3f7994397bfe4efff849cf58f4e8886759e7dfe66d/fill_voids-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:78a2f22b9b221cdc0f19030a9355a73192a1e4087d2fb5acae49c8f84653d6fa", size = 2276253, upload-time = "2026-04-30T03:16:09.942Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ed/818f29b56087ceda015cbd1c5b93c323b98b9f8afe1a918e46a776930887/fill_voids-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14023b0803da5fc77aebf16f404596965d1699ea58a3d658da3d1564f374f4c4", size = 2344877, upload-time = "2026-04-30T03:16:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/35/76/500e0803afa9c9224b5e3f2cf534fe731f36eb68090c762ef9cd004e0d16/fill_voids-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:56dbd5aaff3b5e07824d974525e14a116526b3c07ae4dafc5a805eab99ae016f", size = 175811, upload-time = "2026-04-30T03:16:13.16Z" }, + { url = "https://files.pythonhosted.org/packages/de/49/b6420ee8e85608ce57c56d3ee52f8ae4a9d9dc8d96fdc5b2019f0de4e8cc/fill_voids-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a813b53253144a82548902066a251f2ae52598fb3ef44722293aba82a8367c09", size = 218370, upload-time = "2026-04-30T03:16:14.119Z" }, + { url = "https://files.pythonhosted.org/packages/c7/91/b1e107315073fd9fd72a0be4f2ae0d750f3f6789a6ae92ced8b7d44ef4b2/fill_voids-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afc0f086ee0d72cf124ea2e05cd91de43e6a2dcfea2214ebe2eae25532118141", size = 202100, upload-time = "2026-04-30T03:16:15.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/15/a89d04352035896d8bee737cf3af6768a8aca5b354310b25339da336248e/fill_voids-2.1.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a4f86e807afce22fa0668c97ad9959d1b1ce7b255411832741e1a9410432f63", size = 1441286, upload-time = "2026-04-30T03:16:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/fdcf6a3fa3e9ee070828d397b2caf80a02183d45b205f6f12febd3dc5716/fill_voids-2.1.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b1095cbb7def09ad9d32d52adfebc16eb6b410ec087d341ccfd4013aa5a6ef8", size = 1413039, upload-time = "2026-04-30T03:16:18.01Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/47747ee139e99d239b2df25dd2e1ae2bf979592d3a03670244c3f174474a/fill_voids-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:620455dca04628b71f02ff86dfb7056f07f93fb6022e5b90006ad80a8b1633df", size = 2257624, upload-time = "2026-04-30T03:16:19.51Z" }, + { url = "https://files.pythonhosted.org/packages/51/b8/5f62ef7ecc2531ca0e8c99b1b8a6df70e4d1960ed86520223feb9f0dc238/fill_voids-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02a044ec78f630155fb4eab8bbaa6f935c915472e5d4b6ed0cbe643dd026dc73", size = 2307237, upload-time = "2026-04-30T03:16:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/74/f6/1797633bcdeb9121de82d73c35ac4e1fa8ae24e3778e2324d7ed923e27ba/fill_voids-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:883695c30048660ef043ee8a48e5a15b0e70e5db792aa2207c9b3812472dfaf5", size = 203359, upload-time = "2026-04-30T03:16:22.489Z" }, ] [[package]] @@ -1744,30 +1652,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] -[[package]] -name = "flexcache" -version = "0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" }, -] - -[[package]] -name = "flexparser" -version = "0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" }, -] - [[package]] name = "fonttools" version = "4.63.0" @@ -1790,6 +1674,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] @@ -1802,20 +1702,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] -[[package]] -name = "freetype-py" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/9c/61ba17f846b922c2d6d101cc886b0e8fb597c109cedfcb39b8c5d2304b54/freetype-py-2.5.1.zip", hash = "sha256:cfe2686a174d0dd3d71a9d8ee9bf6a2c23f5872385cf8ce9f24af83d076e2fbd", size = 851738, upload-time = "2024-08-29T18:32:26.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/a8/258dd138ebe60c79cd8cfaa6d021599208a33f0175a5e29b01f60c9ab2c7/freetype_py-2.5.1-py3-none-macosx_10_9_universal2.whl", hash = "sha256:d01ded2557694f06aa0413f3400c0c0b2b5ebcaabeef7aaf3d756be44f51e90b", size = 1747885, upload-time = "2024-08-29T18:32:17.604Z" }, - { url = "https://files.pythonhosted.org/packages/a2/93/280ad06dc944e40789b0a641492321a2792db82edda485369cbc59d14366/freetype_py-2.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2f6b3d68496797da23204b3b9c4e77e67559c80390fc0dc8b3f454ae1cd819", size = 1051055, upload-time = "2024-08-29T18:32:19.153Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/853cad240ec63e21a37a512ee19c896b655ce1772d803a3dd80fccfe63fe/freetype_py-2.5.1-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:289b443547e03a4f85302e3ac91376838e0d11636050166662a4f75e3087ed0b", size = 1043856, upload-time = "2024-08-29T18:32:20.565Z" }, - { url = "https://files.pythonhosted.org/packages/93/6f/fcc1789e42b8c6617c3112196d68e87bfe7d957d80812d3c24d639782dcb/freetype_py-2.5.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cd3bfdbb7e1a84818cfbc8025fca3096f4f2afcd5d4641184bf0a3a2e6f97bbf", size = 1108180, upload-time = "2024-08-29T18:32:21.871Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1b/161d3a6244b8a820aef188e4397a750d4a8196316809576d015f26594296/freetype_py-2.5.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3c1aefc4f0d5b7425f014daccc5fdc7c6f914fb7d6a695cc684f1c09cd8c1660", size = 1106792, upload-time = "2024-08-29T18:32:23.134Z" }, - { url = "https://files.pythonhosted.org/packages/93/6e/bd7fbfacca077bc6f34f1a1109800a2c41ab50f4704d3a0507ba41009915/freetype_py-2.5.1-py3-none-win_amd64.whl", hash = "sha256:0b7f8e0342779f65ca13ef8bc103938366fecade23e6bb37cb671c2b8ad7f124", size = 814608, upload-time = "2024-08-29T18:32:24.648Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -1870,6 +1756,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -1886,9 +1804,6 @@ wheels = [ http = [ { name = "aiohttp" }, ] -s3 = [ - { name = "s3fs" }, -] [[package]] name = "future" @@ -1984,6 +1899,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, ] [[package]] @@ -2013,9 +1933,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -2023,13 +1941,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, ] [[package]] @@ -2070,6 +2017,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" }, { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" }, { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/28b231333857deb840bc3d182ae087510170ea6d68f21393aeb0fe499530/grpcio-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390", size = 6055712, upload-time = "2026-06-01T05:55:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/999c14f9dff0fc47549d2e827cba1343ddc18e1d1bf0d06d2cf628eecbd9/grpcio-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2", size = 12057189, upload-time = "2026-06-01T05:55:55.952Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3d/1fbde079572562af65351151d840525a13879eb7b481d35b55cd64c6127a/grpcio-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5", size = 6608136, upload-time = "2026-06-01T05:55:59.069Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/1f17cb6882abfd8e5a303a25d5d1665abef5a8c499a96198c65a651d1b85/grpcio-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b", size = 7307045, upload-time = "2026-06-01T05:56:02.376Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/f98e91b2e755652e637ea2144318b0229b290062199f761b445fe1fa6015/grpcio-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf", size = 6812794, upload-time = "2026-06-01T05:56:05.777Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/77892d715ac41e7ec0ace2a50080ffb64e189188056f607a66fe0014d1ee/grpcio-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5", size = 7422767, upload-time = "2026-06-01T05:56:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b8/aa04590c6564714d94954515f15a236e59d4b9b3ad01e615f1b706d7792d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757", size = 8408551, upload-time = "2026-06-01T05:56:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/43/3d/4f4a3450a1973568910c6909cb74abbf2126f68aefae5976962f9f7ad50d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe", size = 7846468, upload-time = "2026-06-01T05:56:14.536Z" }, + { url = "https://files.pythonhosted.org/packages/88/f4/5827fd248221ad3b44161c23ce9b5f4ee405b04fc6da5fd402a9aa87a84a/grpcio-1.81.0-cp314-cp314-win32.whl", hash = "sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664", size = 4264427, upload-time = "2026-06-01T05:56:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/127dc2b246096ad50ef7c8d9b7b31d757787aeb796368bcdd4454e4204c4/grpcio-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b", size = 5070848, upload-time = "2026-06-01T05:56:19.735Z" }, ] [[package]] @@ -2121,15 +2078,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, -] - -[[package]] -name = "heapdict" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/9b/d8963ae7e388270b695f3b556b6dc9adb70ae9618fba09aa1e7b1886652d/HeapDict-1.0.1.tar.gz", hash = "sha256:8495f57b3e03d8e46d5f1b2cc62ca881aca392fd5cc048dc0aa2e1a6d23ecdb6", size = 4274, upload-time = "2019-09-09T18:57:02.154Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/9d/cd4777dbcf3bef9d9627e0fe4bc43d2e294b1baeb01d0422399d5e9de319/HeapDict-1.0.1-py3-none-any.whl", hash = "sha256:6065f90933ab1bb7e50db403b90cab653c853690c5992e69294c2de2b253fc92", size = 3917, upload-time = "2019-09-09T18:57:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" }, + { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, ] [[package]] @@ -2146,6 +2110,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" }, { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" }, { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" }, + { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" }, { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" }, { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" }, { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" }, @@ -2156,33 +2128,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" }, ] -[[package]] -name = "higra" -version = "0.6.13" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/11/ac41629d93a10a8d79faf0cec961a785536f75393396f809347d9b36bc46/higra-0.6.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aa1b6318672ebb87415ba0389059243fde0453a2bfb7d38dfe5489f429a76a9c", size = 7701486, upload-time = "2026-01-16T13:02:18.194Z" }, - { url = "https://files.pythonhosted.org/packages/3e/90/fb4e440d37c4a03d3bce3522d715f87c436c9ffd6c3c861b357163cd46e7/higra-0.6.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fecd196c551ac736bed53cf69bb19b9052d22122a36e297872fafca86b45a26f", size = 6134378, upload-time = "2026-01-16T12:55:22.208Z" }, - { url = "https://files.pythonhosted.org/packages/82/6a/27e323fe9186c74e3e9949629d04bceba76c3b99dd67ea9b3aaef66b5f7e/higra-0.6.13-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc560952fe9a6cec4482da3163e94592f95c93045a124176541d145c98f28a2d", size = 11591985, upload-time = "2026-01-16T12:58:54.959Z" }, - { url = "https://files.pythonhosted.org/packages/61/0e/82904a866a5e430622990cb9d344e6944fd65ab79cb310292a09e0ef7d23/higra-0.6.13-cp312-cp312-win_amd64.whl", hash = "sha256:71ecbf39621acc07b42c9468da4105946cee4016425b5f1edc82fc34ac0a2957", size = 5795626, upload-time = "2026-01-16T13:59:17.29Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/09515a1b6fdef5235b0ab830efa0d7dac78a4b4fccd2088cd7d4eea96f24/higra-0.6.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:df0b605729012fb2edebb2fd7eed3810c9657d607bac2bd3cf1d6b5723962942", size = 7701704, upload-time = "2026-01-16T13:04:13.576Z" }, - { url = "https://files.pythonhosted.org/packages/ea/24/d7e691ad846106c1730924f49597c824360a58ed510440a283d0552e3a2d/higra-0.6.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ced8e0c40ef5d89e2a8abea60409a2e7cf67477302795a769121e67f599c9b75", size = 6134369, upload-time = "2026-01-16T13:06:04.963Z" }, - { url = "https://files.pythonhosted.org/packages/42/39/4af36b2460a080b4301fd9ca6c7fa8b97c96030a14bf1c928a2c07cd603f/higra-0.6.13-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b38257cd3f922aaaced79563543df64e732239942161231867ea9398f2e9161a", size = 11593680, upload-time = "2026-01-16T12:59:02.201Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b2/d1f2e3ed0870c5bc6b127a2ab917af0dd1233700d2503c67f4173f778725/higra-0.6.13-cp313-cp313-win_amd64.whl", hash = "sha256:493235a159c82c917abc17281cf4275dcb57fb1b213b3dbfdc1f81873c64e1e2", size = 5795756, upload-time = "2026-01-16T14:24:35.841Z" }, -] - -[[package]] -name = "hsluv" -version = "5.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/81/af16607fa045724e515579d312577261b436f36f419e7c677e7e88fcc943/hsluv-5.0.4.tar.gz", hash = "sha256:2281f946427a882010042844a38c7bbe9e0d0aaf9d46babe46366ed6f169b72e", size = 543090, upload-time = "2023-09-11T21:46:52.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/36/5bddefea3d7adf22a64f9aa9701492f8a9fe6948223f5cf2602c22ec9be7/hsluv-5.0.4-py2.py3-none-any.whl", hash = "sha256:0138bd10038e2ee1b13eecae9a7d49d4ec8c320b1d7eb4f860832c792e3e4567", size = 5252, upload-time = "2023-09-11T21:46:50.407Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -2312,15 +2257,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, ] -[[package]] -name = "imagesize" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, -] - [[package]] name = "importlib-metadata" version = "9.0.0" @@ -2342,15 +2278,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] -[[package]] -name = "in-n-out" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/08/07edfac98a38ab0208557524cbdd94a296f565b0558417ccb2c03d14a6ea/in_n_out-0.2.1.tar.gz", hash = "sha256:43cde2b7de981d41a6d70618a2b7bd989481095922a53ead4dc75f2bbd5dffea", size = 26026, upload-time = "2024-04-22T18:56:50.418Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/06/711a4d105ad3d01d3ef351a1039bb5cc517a57dbf377d7da9a0808e34c77/in_n_out-0.2.1-py3-none-any.whl", hash = "sha256:343e81edb27cf41ec946134a92964f408465abdf6a065c6c55fe96f53bc3c8b7", size = 19951, upload-time = "2024-04-22T18:56:48.572Z" }, -] - [[package]] name = "inflection" version = "0.5.1" @@ -2933,6 +2860,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, @@ -2974,6 +2931,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/fb/62ab6d4b582eaf12ac991c1bdb94f2211bd7bbeb59c04108af9e9456904e/kornia_rs-0.1.14-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:603f56ffa0ffe2de50e5c3c4c606e5a37c98c0277a2ad752feac0e25920880f4", size = 3377444, upload-time = "2026-05-19T07:45:14.587Z" }, { url = "https://files.pythonhosted.org/packages/d7/a8/9f6dec9c5b78ff76dca0fd63c6f3c96b8d00e2a475fcfadd7eeee0c65729/kornia_rs-0.1.14-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496301c800afea6867220d0f02344f44a90b50c1da22d5511c25df0c0c2b4d75", size = 3686869, upload-time = "2026-05-19T07:45:33.734Z" }, { url = "https://files.pythonhosted.org/packages/15/e7/ff8d97f922679c0f8f3aec6b27bab98c03c02feb2200543aed2e98b4b09d/kornia_rs-0.1.14-cp313-cp313t-win_amd64.whl", hash = "sha256:29cfb7b179ba0b98772bd459f6e74da67f93b290491a5c03deb9197955dfa684", size = 3359930, upload-time = "2026-05-19T07:46:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/d5/49/093a3d42d52c6d5b1850f0564b82f9801575b818515a4dfbc9f82d2a7459/kornia_rs-0.1.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:816dd1d1713b13f3b39831d20097cb2aa69c2863c9a98555b1b32df0e5b9e309", size = 3555445, upload-time = "2026-05-19T07:46:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/04/df/344d1c7e36c2cca38227b29693554c8dd1112e5b03155c5fae5fd9122df6/kornia_rs-0.1.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:27b23edda1f847ee4532ae2f008b16da535b947e2cb261be1865f7faff6c9fe7", size = 3349363, upload-time = "2026-05-19T07:45:52.348Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/a90c7ebe73715ec5d326cf5f215f430f32fb5f208a0f7db7bc04f523d7e6/kornia_rs-0.1.14-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1f5798b209c5e0cd6ec2629aac5b70c2b7c6c628a432a1b6a7414aca5805f9d", size = 3381572, upload-time = "2026-05-19T07:45:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ed/05/ffd6ae5b5cdddcbf9f7b7940d408c38911b8ab3911148b5b114522410ff1/kornia_rs-0.1.14-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a70df2ce65269de1f1e9c1fbe14e1fb2cdda6c3a39a31621b68a09cdba01d", size = 3695025, upload-time = "2026-05-19T07:45:35.916Z" }, + { url = "https://files.pythonhosted.org/packages/b0/96/56c15bd73c5ea7f242e6e03001b46db31c99fb7b287455dc36d1273f1b39/kornia_rs-0.1.14-cp314-cp314-win_amd64.whl", hash = "sha256:ff5ab2ede8eee7c05c6b55318ca96118785c40e9320e30c3fbb7f2b68b6fbe2b", size = 3366738, upload-time = "2026-05-19T07:46:21.693Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/db8b3b72289a72c71a29a8d0ae683bd2f307c0b6f5d534744bdad0fd0183/kornia_rs-0.1.14-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7726f27690cf471e8df967d71ee6c937adce764a0de0fea02aeac216b71770fc", size = 3543838, upload-time = "2026-05-19T07:46:07.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/adc8d6642c4cda15525b50d40fae79bf80db91a12237be824cbd67059726/kornia_rs-0.1.14-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7835328d5bf1565c42ce405db125811653a207c6e5dc16e937cf4527a04d8710", size = 3341040, upload-time = "2026-05-19T07:45:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/f3855e36213a8815ae933238b68f67c7435d7f76d36a1f08da3b8a6517a3/kornia_rs-0.1.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cb1a72ea7ce13a2971af16f28409c080560aa332431b3552c633197316e0869", size = 3377938, upload-time = "2026-05-19T07:45:19.273Z" }, + { url = "https://files.pythonhosted.org/packages/63/56/408f1fa7308c5ea8b3d7f5338d121580ef692f7e32858ee35f6969c158b6/kornia_rs-0.1.14-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dc1942acd2e6cbf28f1e056518db751264550f9aaa61760ce01ede266e42b61", size = 3687771, upload-time = "2026-05-19T07:45:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/bb6d1904f92a4f1d05edb4ac39513fd7a3babc48c889bd137f09d4fd5668/kornia_rs-0.1.14-cp314-cp314t-win_amd64.whl", hash = "sha256:26b13fbf0a22c133a1957defca8460faceeb22c7ce1ab37a6f4a658944682c58", size = 3360061, upload-time = "2026-05-19T07:46:23.27Z" }, ] [[package]] @@ -3053,6 +3020,14 @@ wheels = [ { 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]] @@ -3064,22 +3039,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, ] -[[package]] -name = "magicgui" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "psygnal" }, - { name = "qtpy" }, - { name = "superqt", extra = ["iconify"] }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/9c/0d918ee0a9b31f16e9b76c1b86b81b5d4b7bc491354c76030b87790f0cab/magicgui-0.10.2.tar.gz", hash = "sha256:ae7a4cbb7ef2028b827b1877cf0b06743d756074fe6ef849391d62448ab7b65d", size = 20946219, upload-time = "2026-04-10T14:45:28.927Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/bb/5ba264d3ccc13294e74c48c3ef0c2e96b8b13765562628515654012cc47e/magicgui-0.10.2-py3-none-any.whl", hash = "sha256:0f304d4da1a4309ad15d38cb809ef73269cea73a3195ac944f38d7c56d8e0fd5", size = 128254, upload-time = "2026-04-10T14:45:27.173Z" }, -] - [[package]] name = "markdown" version = "3.10.2" @@ -3140,6 +3099,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -3180,6 +3161,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, ] [[package]] @@ -3223,19 +3218,6 @@ dependencies = [ { name = "zensical" }, ] -[[package]] -name = "mip" -version = "1.17.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cbcbox" }, - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/15/7496ce00eafc5b99e98bb696588527754d7629f11ac6208fdc9355f6eec6/mip-1.17.6.tar.gz", hash = "sha256:9da8074b80bd3ef788513d5a214ef832916d82aa66487da11a49c7da9f89d270", size = 9443716, upload-time = "2026-03-23T16:20:09.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/1d/0eb2531e779be687a3249e7f2ba5e465dd34611072390252e5fcf89e7fef/mip-1.17.6-py3-none-any.whl", hash = "sha256:4fb7ff5d7beacbe7d007de172306a76aff8fb36760fa63a5d88a8cb3aa6f4a57", size = 88214, upload-time = "2026-03-23T16:20:07.289Z" }, -] - [[package]] name = "mistune" version = "3.2.1" @@ -3352,6 +3334,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, ] [[package]] @@ -3400,6 +3392,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] @@ -3462,144 +3472,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] -name = "nanobind" -version = "2.13.0" +name = "narwhals" +version = "2.22.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/e5/a6fc2f024eaef17c68b94b5f8f56098d31859a7840a11a3dd45da2d3908a/nanobind-2.13.0.tar.gz", hash = "sha256:c7b04d6a6a4cd57985571e605539399b51331ae455d7fce576a5e2fcb89b1dcf", size = 1052101, upload-time = "2026-06-18T06:03:49.491Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/c80cb7719721a44846c6301ef118434bae30a423924bfad3a47f16bdc064/narwhals-2.22.0.tar.gz", hash = "sha256:6486282bb7e4b4ab55963efbd8be1451b764cc4874b74d1fd625eba9dc60b86f", size = 417565, upload-time = "2026-06-01T13:34:36.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/8d/cec69a0a804711fd89f47fad92cd2a438a1ceaafd9dabaf3b8b403b5cbba/nanobind-2.13.0-py3-none-any.whl", hash = "sha256:9268d7201eaf5519ae61b7a83175f2af77088cb4b99dd3ce5b00550f697da7b7", size = 269387, upload-time = "2026-06-18T06:03:47.508Z" }, -] - -[[package]] -name = "napari" -version = "0.6.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "app-model" }, - { name = "appdirs" }, - { name = "cachey" }, - { name = "certifi" }, - { name = "dask", extra = ["array"] }, - { name = "imageio" }, - { name = "jsonschema" }, - { name = "lazy-loader" }, - { name = "magicgui" }, - { name = "napari-console" }, - { name = "napari-plugin-engine" }, - { name = "napari-svg" }, - { name = "npe2" }, - { name = "numpy" }, - { name = "numpydoc" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "pint" }, - { name = "psutil" }, - { name = "psygnal" }, - { name = "pydantic" }, - { name = "pygments" }, - { name = "pyopengl" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "pyyaml" }, - { name = "qtpy" }, - { name = "scikit-image", extra = ["data"] }, - { name = "scipy" }, - { name = "superqt" }, - { name = "tifffile" }, - { name = "toolz" }, - { name = "tqdm" }, - { name = "typing-extensions" }, - { name = "vispy" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/2c/cff26b706e1982317b19d1bd57bc7809fbbc586da794c968d1b3997e1b85/napari-0.6.5.tar.gz", hash = "sha256:00663221583467f41fa956a672b0b4cd7a761e2642db41fc3c336d1c82339533", size = 3249897, upload-time = "2025-10-02T02:18:48.616Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/69/1e8c5c7d487ad109206ce7f028ad02d41171641e1e95b4e45a913ee4d6b1/napari-0.6.5-py3-none-any.whl", hash = "sha256:51bc0b0fb6b6271555ad4167ce510b9453735aaad0ef947cfbc38bdd03d6a7be", size = 3536276, upload-time = "2025-10-02T02:18:46.062Z" }, -] - -[package.optional-dependencies] -pyqt5 = [ - { name = "pyqt5" }, - { name = "pyqt5-qt5", version = "5.15.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'Windows'" }, -] - -[[package]] -name = "napari-console" -version = "0.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "ipython" }, - { name = "qtconsole" }, - { name = "qtpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/4e/9b65e4c4e9520888fe69c6307818f4f75b0113b0fab1db67424b29bed9fe/napari_console-0.1.3.tar.gz", hash = "sha256:ba4f7e1cdca65a7924631372a5e58884e2e35a2b9092c79b98acb9c2dfe1254f", size = 20010, upload-time = "2024-12-20T13:48:58.451Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ff/1d1aad9292aeccf01f94ca150ce621737cbe7b9fabbb0bfef32b8da70e58/napari_console-0.1.3-py3-none-any.whl", hash = "sha256:29d1700b85561b8c91331821360c9cd0e989350f4a06b86239ec65bc06c7aecb", size = 9990, upload-time = "2024-12-20T13:48:54.093Z" }, -] - -[[package]] -name = "napari-geff" -source = { editable = "../../../../../home/eduardo.hirata/repos/napari-geff" } -dependencies = [ - { name = "geff" }, - { name = "magicgui" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "qtpy" }, - { name = "scikit-image" }, -] - -[package.metadata] -requires-dist = [ - { name = "geff", specifier = ">=1.1.1" }, - { name = "magicgui" }, - { name = "napari", extras = ["all"], marker = "extra == 'all'" }, - { name = "napari", extras = ["qt"], marker = "extra == 'testing'" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "pytest", marker = "extra == 'testing'" }, - { name = "pytest-cov", marker = "extra == 'testing'" }, - { name = "pytest-qt", marker = "extra == 'testing'" }, - { name = "qtpy" }, - { name = "scikit-image" }, - { name = "tox", marker = "extra == 'testing'" }, -] -provides-extras = ["all", "testing"] - -[[package]] -name = "napari-plugin-engine" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/72/c653308edaf7f7c84d82e388a1c46bc8f26c385027af58b5bf728f600b47/napari_plugin_engine-0.2.1.tar.gz", hash = "sha256:46829cf02f368c8f2f1aa8b998ec73bcf14a2c1f5c15abd94b82154d7aef510d", size = 55809, upload-time = "2026-02-10T08:31:20.385Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/bc/2509813cddd0e02736121e21bef54deeec7f0f89af2c6096d753ee1feb09/napari_plugin_engine-0.2.1-py3-none-any.whl", hash = "sha256:de30babe6fd9477816f037207938a2da7faeddc0e9e8663cb29f3d74235b6dc5", size = 33849, upload-time = "2026-02-10T08:31:19.037Z" }, -] - -[[package]] -name = "napari-svg" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "imageio" }, - { name = "numpy" }, - { name = "vispy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/90/802f8288d16c1513b908d644779e733461a53b6c1a2c7561f1464c9f1516/napari_svg-0.2.1.tar.gz", hash = "sha256:031f13b34b0948afbdcb11eb00728fe32ef7e4e3aa3905f923001d6871a08ad9", size = 17533, upload-time = "2025-01-14T07:26:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/ae/0eeb22806c4157a9199f65a93374e5ff5c4d2cc1411b5d25053bcd9e6b91/napari_svg-0.2.1-py3-none-any.whl", hash = "sha256:9eaa54fbbf9bfd5078b67b7d1edc9eccfd872dab89fd586374909fef4ed89a49", size = 16458, upload-time = "2025-01-14T07:26:29.328Z" }, -] - -[[package]] -name = "narwhals" -version = "2.22.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/c80cb7719721a44846c6301ef118434bae30a423924bfad3a47f16bdc064/narwhals-2.22.0.tar.gz", hash = "sha256:6486282bb7e4b4ab55963efbd8be1451b764cc4874b74d1fd625eba9dc60b86f", size = 417565, upload-time = "2026-06-01T13:34:36.249Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b6/e7cdde7b8e90d5dff25b622f95833ef26567ad184c977278b93a1cbd5717/narwhals-2.22.0-py3-none-any.whl", hash = "sha256:1421797ede01789cc1537619dbc3f36f840737240f748fdb24a60a0225fc80be", size = 453815, upload-time = "2026-06-01T13:34:34.127Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/e7cdde7b8e90d5dff25b622f95833ef26567ad184c977278b93a1cbd5717/narwhals-2.22.0-py3-none-any.whl", hash = "sha256:1421797ede01789cc1537619dbc3f36f840737240f748fdb24a60a0225fc80be", size = 453815, upload-time = "2026-06-01T13:34:34.127Z" }, ] [[package]] @@ -3696,6 +3614,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/19/002fc1e6a4abeef8d92e9aa2e43aea4d462f6b170090f7752ea8887f4897/ndindex-1.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a91e1a0398120233d5c3b23ccb2d4b78e970d66136f1a7221fa9a53873c3d5c5", size = 1636436, upload-time = "2025-11-19T20:39:18.266Z" }, { url = "https://files.pythonhosted.org/packages/5f/8f/28b1ad78c787ac8fafd6e26419a80366617784b1779e3857fa687492f6bc/ndindex-1.10.1-cp313-cp313t-win32.whl", hash = "sha256:78bfe25941d2dac406391ddd9baf0b0fce163807b98ecc2c47a3030ee8466319", size = 158780, upload-time = "2025-11-19T20:39:20.454Z" }, { url = "https://files.pythonhosted.org/packages/d0/56/b81060607a19865bb8be8d705b1b3e8aefb8747c0fbd383e38b4cae4bd71/ndindex-1.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:08bfdc1f7a0b408d15b3ce61d141ebbebdb47a25341967e425e104c5bd512a5c", size = 167485, upload-time = "2025-11-19T20:39:21.733Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/aac1131e9f3a5635ba7b0312c3bfa610511ab4108f85c0d914a32887aa00/ndindex-1.10.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b5297f207ebc068c7cdf9e3cd7b95aa5c9ec04295d0a7e56b529f66787d4685", size = 176478, upload-time = "2025-11-19T20:39:23.747Z" }, + { url = "https://files.pythonhosted.org/packages/1a/05/a0d8ca0432c84550bc17af6d6479a803936895b8b8403a1216c5a55475fb/ndindex-1.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c5e9762452b163e33cfb6e821f86e45ba0b53bdfcd23ab5d57b48a8f566898cb", size = 175480, upload-time = "2025-11-19T20:39:25.365Z" }, + { url = "https://files.pythonhosted.org/packages/09/4a/028ab78a9f29fd2a7e86a90337cde4658eaa77b425c63045d83a1d2e4f26/ndindex-1.10.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf80241b40adffdc3276b2c9fb63a96c6c98b4a9d941892738de8add65083962", size = 528125, upload-time = "2025-11-19T20:39:26.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/bd823b345fb06c83ade6ef1c1933521d4357cd04490e684d4fa30126926c/ndindex-1.10.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf5855881884b8467dfcf45764ccf2e4279075be14b155b89c96994bb08d2e6f", size = 527328, upload-time = "2025-11-19T20:39:28.292Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/40b9c15588cbf9dde43c4fb88a31dd1f636a913fa29649f18f8e3ebca36a/ndindex-1.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e81a9bd36fe054b6c9fcc53d26bc9a28cf15d1ab52a0f5b854f894116f3a54e1", size = 1497508, upload-time = "2025-11-19T20:39:30.735Z" }, + { url = "https://files.pythonhosted.org/packages/24/8f/b8048f7837d2e9dff0af507b398307fa84a2aa9ea3db71b4aa800b21da4a/ndindex-1.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:588e8875d836a93b3cd9af482c8074bb02288ae1aff92cf277e1f02d9ae0f992", size = 1552625, upload-time = "2025-11-19T20:39:32.404Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/0ecb53c7e690a44769f2f92a843723ccb1d0ce080d93ba1ea811304cca12/ndindex-1.10.1-cp314-cp314-win32.whl", hash = "sha256:28741daca5926adff402247cd406f453ed5bb6042e82d6855938f805190e5ce9", size = 151237, upload-time = "2025-11-19T20:39:34.847Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4e/197982fa8b4e6e6b9d15c38505c41076d1c552921f09f4d35acbbbbc0b70/ndindex-1.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:59a3222befc0f7cdc85fb9b90a567ae890f70a864bdeb660517e9ebcb36bf1bc", size = 158925, upload-time = "2025-11-19T20:39:37.149Z" }, + { url = "https://files.pythonhosted.org/packages/24/ad/116b6154046a69fc04e2d4490905801d3839a3f21290c0b4d49b1044e251/ndindex-1.10.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967b87b88dadb62555ec1039695c347254eccb8ca3d124c0e5dbe084c525fa93", size = 181724, upload-time = "2025-11-19T20:39:38.635Z" }, + { url = "https://files.pythonhosted.org/packages/c4/00/3ce4351366c890bcc87a5e9f1f90102547962eef356ac7c799bfdd0dddce/ndindex-1.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c67dde588c0fb89d872931a4ed5f9b4d21c1c70a3d92fdf0812a1de154239816", size = 181653, upload-time = "2025-11-19T20:39:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/a6fda696a2f02a3f8dd2ee9d816cb2edff6423bf0110a4876cc3b1259732/ndindex-1.10.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65ca639a7abf72d79f22424f4abd18dece1f289a2b7b028a0ca455edd2168d4", size = 630898, upload-time = "2025-11-19T20:39:41.495Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/eb2e5d067d4c054451e33eaece74cbdcb58236dc60516e73d783dae34c7e/ndindex-1.10.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c3634a8df43e7928122225a3d64d850c8957bd1edf2e403907deacb478af27b", size = 614419, upload-time = "2025-11-19T20:39:43.254Z" }, + { url = "https://files.pythonhosted.org/packages/78/51/261bfb49eb7920c2a7314cacba5821930a529911dce48c7c6cd786096a5a/ndindex-1.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9d581f931e61f182478f18bdf5edd3955899df5da4892ed0d5de547a4cfd5b6f", size = 1587517, upload-time = "2025-11-19T20:39:44.809Z" }, + { url = "https://files.pythonhosted.org/packages/ec/37/084a332ecdf8b0049151bd78001a7baf2daf7f500d043beb8a1f95d0f4e3/ndindex-1.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:78ce45106ebf67aeba99714818c721d8fd5fb9534daebd2565665a2d64b50fc9", size = 1635372, upload-time = "2025-11-19T20:39:47.231Z" }, + { url = "https://files.pythonhosted.org/packages/28/f4/716580fbb03018ab1daa86ed12c1925c67e79689db5fee82393e840758a2/ndindex-1.10.1-cp314-cp314t-win32.whl", hash = "sha256:fe5341e24dc992b09c258456ac90a09a6d25efdc2cb86dcc91d32c8891e1df9a", size = 162186, upload-time = "2025-11-19T20:39:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/4d/20/28f669c09a470e7f523b0cc10b94336664d9648594015e3f2a1ec29047b1/ndindex-1.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:37f87f0e7690ae0324334740e0661d6297f2e62c9bf925127d249fb7eddd0ad8", size = 171077, upload-time = "2025-11-19T20:39:50.108Z" }, ] [[package]] @@ -3742,26 +3676,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, ] -[[package]] -name = "npe2" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "build" }, - { name = "platformdirs" }, - { name = "psygnal" }, - { name = "pydantic" }, - { name = "pydantic-extra-types" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "tomli-w" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/29/0a/1a6ed44b038484a064a0ac8e5f96aae6ddb8768789145cf7e65e699b324b/npe2-0.8.2.tar.gz", hash = "sha256:6e41cc1f2c873257d864980dd281b5bb649a84cef02feeb0cdda1a9d23fd8f8b", size = 122768, upload-time = "2026-04-04T20:55:11.9Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a6/f318fbc7bbb62361d36cb694f399f765f57fb282fdadc3aa2fef4dafd62d/npe2-0.8.2-py3-none-any.whl", hash = "sha256:80eff5fef50352cf0f3407c4c1122a7ae186aede40a21fd595cf91988cd55a9d", size = 93921, upload-time = "2026-04-04T20:55:10.552Z" }, -] - [[package]] name = "numba" version = "0.65.1" @@ -3780,6 +3694,14 @@ wheels = [ { 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]] @@ -3835,6 +3757,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/ce/0d4fcd31ab49319740d934fba1734d7dad13aa485532ca754e555ca16c8b/numexpr-2.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:67ea4771029ce818573b1998f5ca416bd255156feea017841b86176a938f7d19", size = 1474214, upload-time = "2025-10-13T16:15:38.893Z" }, { url = "https://files.pythonhosted.org/packages/b7/47/b2a93cbdb3ba4e009728ad1b9ef1550e2655ea2c86958ebaf03b9615f275/numexpr-2.14.1-cp313-cp313t-win32.whl", hash = "sha256:15015d47d3d1487072d58c0e7682ef2eb608321e14099c39d52e2dd689483611", size = 167676, upload-time = "2025-10-13T16:17:17.351Z" }, { url = "https://files.pythonhosted.org/packages/86/99/ee3accc589ed032eea68e12172515ed96a5568534c213ad109e1f4411df1/numexpr-2.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:94c711f6d8f17dfb4606842b403699603aa591ab9f6bf23038b488ea9cfb0f09", size = 161096, upload-time = "2025-10-13T16:17:19.174Z" }, + { url = "https://files.pythonhosted.org/packages/ac/36/9db78dfbfdfa1f8bf0872993f1a334cdd8fca5a5b6567e47dcb128bcb7c2/numexpr-2.14.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ede79f7ff06629f599081de644546ce7324f1581c09b0ac174da88a470d39c21", size = 162848, upload-time = "2025-10-13T16:16:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/a5c78ae637402c5550e2e0ba175275d2515d432ec28af0cdc23c9b476e65/numexpr-2.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2eac7a5a2f70b3768c67056445d1ceb4ecd9b853c8eda9563823b551aeaa5082", size = 152270, upload-time = "2025-10-13T16:16:47.92Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ed/aabd8678077848dd9a751c5558c2057839f5a09e2a176d8dfcd0850ee00e/numexpr-2.14.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aedf38d4c0c19d3cecfe0334c3f4099fb496f54c146223d30fa930084bc8574", size = 455918, upload-time = "2025-10-13T16:13:50.338Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/3db65117f02cdefb0e5e4c440daf1c30beb45051b7f47aded25b7f4f2f34/numexpr-2.14.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439ec4d57b853792ebe5456e3160312281c3a7071ecac5532ded3278ede614de", size = 446512, upload-time = "2025-10-13T16:15:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fb/7ceb9ee55b5f67e4a3e4d73d5af4c7e37e3c9f37f54bee90361b64b17e3f/numexpr-2.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e23b87f744e04e302d82ac5e2189ae20a533566aec76a46885376e20b0645bf8", size = 1417845, upload-time = "2025-10-13T16:13:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9b5764d0eafbbb2889288f80de773791358acf6fad1a55767538d8b79599/numexpr-2.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:44f84e0e5af219dbb62a081606156420815890e041b87252fbcea5df55214c4c", size = 1466211, upload-time = "2025-10-13T16:15:48.985Z" }, + { url = "https://files.pythonhosted.org/packages/5d/21/204db708eccd71aa8bc55bcad55bc0fc6c5a4e01ad78e14ee5714a749386/numexpr-2.14.1-cp314-cp314-win32.whl", hash = "sha256:1f1a5e817c534539351aa75d26088e9e1e0ef1b3a6ab484047618a652ccc4fc3", size = 168835, upload-time = "2025-10-13T16:17:20.82Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3e/d83e9401a1c3449a124f7d4b3fb44084798e0d30f7c11e60712d9b94cf11/numexpr-2.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:587c41509bc373dfb1fe6086ba55a73147297247bedb6d588cda69169fc412f2", size = 162608, upload-time = "2025-10-13T16:17:22.228Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d6/ec947806bb57836d6379a8c8a253c2aeaa602b12fef2336bfd2462bb4ed5/numexpr-2.14.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec368819502b64f190c3f71be14a304780b5935c42aae5bf22c27cc2cbba70b5", size = 163525, upload-time = "2025-10-13T16:16:50.133Z" }, + { url = "https://files.pythonhosted.org/packages/0d/77/048f30dcf661a3d52963a88c29b52b6d5ce996d38e9313a56a922451c1e0/numexpr-2.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e87f6d203ac57239de32261c941e9748f9309cbc0da6295eabd0c438b920d3a", size = 152917, upload-time = "2025-10-13T16:16:52.055Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/956a13e628d722d649fbf2fded615134a308c082e122a48bad0e90a99ce9/numexpr-2.14.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd72d8c2a165fe45ea7650b16eb8cc1792a94a722022006bb97c86fe51fd2091", size = 466242, upload-time = "2025-10-13T16:13:55.795Z" }, + { url = "https://files.pythonhosted.org/packages/d6/dd/abe848678d82486940892f2cacf39e82eec790e8930d4d713d3f9191063b/numexpr-2.14.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70d80fcb418a54ca208e9a38e58ddc425c07f66485176b261d9a67c7f2864f73", size = 457149, upload-time = "2025-10-13T16:15:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/fd/bb/797b583b5fb9da5700a5708ca6eb4f889c94d81abb28de4d642c0f4b3258/numexpr-2.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:edea2f20c2040df8b54ee8ca8ebda63de9545b2112872466118e9df4d0ae99f3", size = 1426493, upload-time = "2025-10-13T16:13:59.244Z" }, + { url = "https://files.pythonhosted.org/packages/77/c4/0519ab028fdc35e3e7ee700def7f2b4631b175cd9e1202bd7966c1695c33/numexpr-2.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790447be6879a6c51b9545f79612d24c9ea0a41d537a84e15e6a8ddef0b6268e", size = 1474413, upload-time = "2025-10-13T16:15:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/33044878c8f4a75213cfe9c11d4c02058bb710a7a063fe14f362e8de1077/numexpr-2.14.1-cp314-cp314t-win32.whl", hash = "sha256:538961096c2300ea44240209181e31fae82759d26b51713b589332b9f2a4117e", size = 169502, upload-time = "2025-10-13T16:17:23.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/a2/5a1a2c72528b429337f49911b18c302ecd36eeab00f409147e1aa4ae4519/numexpr-2.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a40b350cd45b4446076fa11843fa32bbe07024747aeddf6d467290bf9011b392", size = 163589, upload-time = "2025-10-13T16:17:25.696Z" }, ] [[package]] @@ -3875,18 +3813,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, -] - -[[package]] -name = "numpydoc" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/3c/dfccc9e7dee357fb2aa13c3890d952a370dd0ed071e0f7ed62ed0df567c1/numpydoc-1.10.0.tar.gz", hash = "sha256:3f7970f6eee30912260a6b31ac72bba2432830cd6722569ec17ee8d3ef5ffa01", size = 94027, upload-time = "2025-12-02T16:39:12.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/5e/3a6a3e90f35cea3853c45e5d5fb9b7192ce4384616f932cf7591298ab6e1/numpydoc-1.10.0-py3-none-any.whl", hash = "sha256:3149da9874af890bcc2a82ef7aae5484e5aa81cb2778f08e3c307ba6d963721b", size = 69255, upload-time = "2025-12-02T16:39:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] [[package]] @@ -3894,7 +3841,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3933,7 +3880,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3945,7 +3892,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3975,9 +3922,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3989,7 +3936,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4051,42 +3998,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, ] -[[package]] -name = "ome-zarr" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "dask" }, - { name = "deprecated" }, - { name = "fsspec", extra = ["s3"] }, - { name = "numpy" }, - { name = "ome-zarr-models" }, - { name = "rangehttpserver" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "toolz" }, - { name = "zarr" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/98/f39cf0ec00ad64996ed2d2d6059d15a30c7b8f559cd2a216589885001a6a/ome_zarr-0.18.0.tar.gz", hash = "sha256:5cc0fc682b0c177fa32e54f4479b2436f79850a171e83ad4c6b08fec32e66cd0", size = 106039, upload-time = "2026-06-17T16:22:07.76Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/be/2cf9389c9a7b80ceefae88bd713afa6a4b488cb47e524e7dadae44260efd/ome_zarr-0.18.0-py3-none-any.whl", hash = "sha256:aaf6ea6ac5790623290210efc566ad49f92da170b50b07e9113b6b06a0870cc5", size = 57947, upload-time = "2026-06-17T16:22:06.732Z" }, -] - -[[package]] -name = "ome-zarr-models" -version = "1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "pydantic-zarr" }, - { name = "zarr" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/e7/c37b8896a24eabc76d6a5e7855f722792de5bdf3f5ccc562001738be4f7e/ome_zarr_models-1.6.tar.gz", hash = "sha256:00735ad939d2b5f6a4f583cc19af1e8a5247fa1e93f1f08191143cd913b9633a", size = 106913, upload-time = "2026-03-10T10:14:28.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/fc/7650df09dce0edfbaa1f2898c5eb0e0523ae5964dadb83855beb52166505/ome_zarr_models-1.6-py3-none-any.whl", hash = "sha256:d855a6fe885aa2613f64cd124ff8453d75854531bb3282eeaa916e976c7bcf66", size = 64251, upload-time = "2026-03-10T10:14:27.177Z" }, -] - [[package]] name = "omegaconf" version = "2.3.0" @@ -4123,6 +4034,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/2e/27affcac63eaf2ef183a44fd1a1354b11da64a6c72fe6f3fdcf5571bcee5/onnx-1.21.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b58a4cfec8d9311b73dc083e4c1fa362069267881144c05139b3eba5dc3a840", size = 17617687, upload-time = "2026-03-27T21:33:12.619Z" }, { url = "https://files.pythonhosted.org/packages/1c/5c/ac8ed15e941593a3672ce424280b764979026317811f2e8508432bfc3429/onnx-1.21.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1a9baf882562c4cebf79589bebb7cd71a20e30b51158cac3e3bbaf27da6163bd", size = 16449402, upload-time = "2026-03-27T21:33:15.555Z" }, { url = "https://files.pythonhosted.org/packages/0e/aa/d2231e0dcaad838217afc64c306c8152a080134d2034e247cc973d577674/onnx-1.21.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bba12181566acf49b35875838eba49536a327b2944664b17125577d230c637ad", size = 16408273, upload-time = "2026-03-27T21:33:18.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0a/8905b14694def6ad23edf1011fdd581500384062f8c4c567e114be7aa272/onnx-1.21.0-cp314-cp314t-macosx_12_0_universal2.whl", hash = "sha256:7ee9d8fd6a4874a5fa8b44bbcabea104ce752b20469b88bc50c7dcf9030779ad", size = 17975331, upload-time = "2026-03-27T21:33:21.69Z" }, + { url = "https://files.pythonhosted.org/packages/61/28/f4e401e5199d1b9c8b76c7e7ae1169e050515258e877b58fa8bb49d3bdcc/onnx-1.21.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5489f25fe461e7f32128218251a466cabbeeaf1eaa791c79daebf1a80d5a2cc9", size = 17537430, upload-time = "2026-03-27T21:33:24.547Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/5d13320eb3660d5af360ea3b43aa9c63a70c92a9b4d1ea0d34501a32fcb8/onnx-1.21.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db17fc0fec46180b6acbd1d5d8650a04e5527c02b09381da0b5b888d02a204c8", size = 17617662, upload-time = "2026-03-27T21:33:27.418Z" }, + { url = "https://files.pythonhosted.org/packages/4d/50/3eaa1878338247be021e6423696813d61e77e534dccbd15a703a144e703d/onnx-1.21.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19d9971a3e52a12968ae6c70fd0f86c349536de0b0c33922ecdbe52d1972fe60", size = 16463688, upload-time = "2026-03-27T21:33:30.229Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/38d46b43bbb525e0b6a4c2c4204cc6795d67e45687a2f7403e06d8e7053d/onnx-1.21.0-cp314-cp314t-win_arm64.whl", hash = "sha256:efba467efb316baf2a9452d892c2f982b9b758c778d23e38c7f44fa211b30bb9", size = 16423387, upload-time = "2026-03-27T21:33:33.446Z" }, ] [[package]] @@ -4157,6 +4073,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/97/fe8979f44b9275654b42f7bb556e30789b71a1b22998c83b540df2b1b774/onnxruntime_gpu-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfda2fad535595bfc3e570eb588092717711dcb2957656d814695e0c9ceb1508", size = 276974871, upload-time = "2026-05-08T19:15:58.052Z" }, { url = "https://files.pythonhosted.org/packages/67/3f/59f1777a394625ecc9a85636de57dc47c25dbb5f888da050f1463955a0ce/onnxruntime_gpu-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:6ab9f9c741d2e239b2e321ab0d389c04329d4ab7f11e3b92dd3aa7db1c59dee4", size = 226548083, upload-time = "2026-05-08T19:09:44.408Z" }, { url = "https://files.pythonhosted.org/packages/89/96/360328e3c463f7ea08e853c4239c397e83363dd0204de71a710dc1a544bd/onnxruntime_gpu-1.26.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcf6f347cad9f88a9a625c2b352cf9de927528aedb627ebbc089a201f1990b94", size = 276992052, upload-time = "2026-05-08T19:16:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/aa2dc0e79bba577f37d5448bcb32fea79977e07506684d8138c19a0f1077/onnxruntime_gpu-1.26.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e6e4fb1ec9ae1cf456534d9115f106ab2a1ae96fa513b4ed0f4795302b4a2c6", size = 276978254, upload-time = "2026-05-08T19:16:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/41/e7/923298431e669567d7ccc2a4c898b6534a47641a051569fd97165fe6d9b8/onnxruntime_gpu-1.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e592439b0183d303c2374517b5b392599a3d50b2dc9de949b9b15731ac921c9", size = 229142768, upload-time = "2026-05-08T19:09:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/91/93ffe5431d154989f5e04864a25a97eea480997d771232bcbbc538188241/onnxruntime_gpu-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56dc7b73954ff4bdc71f5b8ab306b6f61be5d007881b6ef423a609e2b9cd088b", size = 276991545, upload-time = "2026-05-08T19:16:33.347Z" }, ] [[package]] @@ -4230,6 +4149,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] @@ -4275,6 +4209,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] @@ -4334,7 +4284,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4402,21 +4352,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, -] - -[[package]] -name = "pint" -version = "0.25.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flexcache" }, - { name = "flexparser" }, - { name = "platformdirs" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/9d/b1379cdbd33a49d17d627bc24e2b63cca06a1c5343b38072d2889499e82e/pint-0.25.3.tar.gz", hash = "sha256:f8f5df6cf65314d74da1ade1bf96f8e3e4d0c41b51577ac53c49e7d44ca5acee", size = 255106, upload-time = "2026-03-19T21:57:08.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl", hash = "sha256:27eb25143bd5de9fcc4d5a4b484f16faf6b4615aa93ece6b3373a8c1a3c1b97d", size = 307488, upload-time = "2026-03-19T21:57:07.022Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] @@ -4557,6 +4517,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] @@ -4587,6 +4581,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, @@ -4597,36 +4597,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] -[[package]] -name = "psycopg2-binary" -version = "2.9.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, - { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, - { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, - { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, - { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, - { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, - { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, - { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, -] - [[package]] name = "psygnal" version = "0.15.1" @@ -4643,6 +4613,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" }, { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" }, { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" }, { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, ] @@ -4699,30 +4674,45 @@ wheels = [ [[package]] name = "pyarrow" -version = "19.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/09/a9046344212690f0632b9c709f9bf18506522feb333c894d0de81d62341a/pyarrow-19.0.1.tar.gz", hash = "sha256:3bf266b485df66a400f282ac0b6d1b500b9d2ae73314a153dbe97d6d5cc8a99e", size = 1129437, upload-time = "2025-02-18T18:55:57.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b4/94e828704b050e723f67d67c3535cf7076c7432cd4cf046e4bb3b96a9c9d/pyarrow-19.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:80b2ad2b193e7d19e81008a96e313fbd53157945c7be9ac65f44f8937a55427b", size = 30670749, upload-time = "2025-02-18T18:53:00.062Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3b/4692965e04bb1df55e2c314c4296f1eb12b4f3052d4cf43d29e076aedf66/pyarrow-19.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee8dec072569f43835932a3b10c55973593abc00936c202707a4ad06af7cb294", size = 32128007, upload-time = "2025-02-18T18:53:06.581Z" }, - { url = "https://files.pythonhosted.org/packages/22/f7/2239af706252c6582a5635c35caa17cb4d401cd74a87821ef702e3888957/pyarrow-19.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d5d1ec7ec5324b98887bdc006f4d2ce534e10e60f7ad995e7875ffa0ff9cb14", size = 41144566, upload-time = "2025-02-18T18:53:11.958Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/c9661b2b2849cfefddd9fd65b64e093594b231b472de08ff658f76c732b2/pyarrow-19.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ad4c0eb4e2a9aeb990af6c09e6fa0b195c8c0e7b272ecc8d4d2b6574809d34", size = 42202991, upload-time = "2025-02-18T18:53:17.678Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4f/a2c0ed309167ef436674782dfee4a124570ba64299c551e38d3fdaf0a17b/pyarrow-19.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d383591f3dcbe545f6cc62daaef9c7cdfe0dff0fb9e1c8121101cabe9098cfa6", size = 40507986, upload-time = "2025-02-18T18:53:26.263Z" }, - { url = "https://files.pythonhosted.org/packages/27/2e/29bb28a7102a6f71026a9d70d1d61df926887e36ec797f2e6acfd2dd3867/pyarrow-19.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b4c4156a625f1e35d6c0b2132635a237708944eb41df5fbe7d50f20d20c17832", size = 42087026, upload-time = "2025-02-18T18:53:33.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/33/2a67c0f783251106aeeee516f4806161e7b481f7d744d0d643d2f30230a5/pyarrow-19.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5bd1618ae5e5476b7654c7b55a6364ae87686d4724538c24185bbb2952679960", size = 25250108, upload-time = "2025-02-18T18:53:38.462Z" }, - { url = "https://files.pythonhosted.org/packages/2b/8d/275c58d4b00781bd36579501a259eacc5c6dfb369be4ddeb672ceb551d2d/pyarrow-19.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e45274b20e524ae5c39d7fc1ca2aa923aab494776d2d4b316b49ec7572ca324c", size = 30653552, upload-time = "2025-02-18T18:53:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9e/e6aca5cc4ef0c7aec5f8db93feb0bde08dbad8c56b9014216205d271101b/pyarrow-19.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d9dedeaf19097a143ed6da37f04f4051aba353c95ef507764d344229b2b740ae", size = 32103413, upload-time = "2025-02-18T18:53:52.971Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fa/a7033f66e5d4f1308c7eb0dfcd2ccd70f881724eb6fd1776657fdf65458f/pyarrow-19.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebfb5171bb5f4a52319344ebbbecc731af3f021e49318c74f33d520d31ae0c4", size = 41134869, upload-time = "2025-02-18T18:53:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/2d/92/34d2569be8e7abdc9d145c98dc410db0071ac579b92ebc30da35f500d630/pyarrow-19.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a21d39fbdb948857f67eacb5bbaaf36802de044ec36fbef7a1c8f0dd3a4ab2", size = 42192626, upload-time = "2025-02-18T18:54:06.062Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1f/80c617b1084fc833804dc3309aa9d8daacd46f9ec8d736df733f15aebe2c/pyarrow-19.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:99bc1bec6d234359743b01e70d4310d0ab240c3d6b0da7e2a93663b0158616f6", size = 40496708, upload-time = "2025-02-18T18:54:12.347Z" }, - { url = "https://files.pythonhosted.org/packages/e6/90/83698fcecf939a611c8d9a78e38e7fed7792dcc4317e29e72cf8135526fb/pyarrow-19.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1b93ef2c93e77c442c979b0d596af45e4665d8b96da598db145b0fec014b9136", size = 42075728, upload-time = "2025-02-18T18:54:19.364Z" }, - { url = "https://files.pythonhosted.org/packages/40/49/2325f5c9e7a1c125c01ba0c509d400b152c972a47958768e4e35e04d13d8/pyarrow-19.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9d46e06846a41ba906ab25302cf0fd522f81aa2a85a71021826f34639ad31ef", size = 25242568, upload-time = "2025-02-18T18:54:25.846Z" }, - { url = "https://files.pythonhosted.org/packages/3f/72/135088d995a759d4d916ec4824cb19e066585b4909ebad4ab196177aa825/pyarrow-19.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c0fe3dbbf054a00d1f162fda94ce236a899ca01123a798c561ba307ca38af5f0", size = 30702371, upload-time = "2025-02-18T18:54:30.665Z" }, - { url = "https://files.pythonhosted.org/packages/2e/01/00beeebd33d6bac701f20816a29d2018eba463616bbc07397fdf99ac4ce3/pyarrow-19.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:96606c3ba57944d128e8a8399da4812f56c7f61de8c647e3470b417f795d0ef9", size = 32116046, upload-time = "2025-02-18T18:54:35.995Z" }, - { url = "https://files.pythonhosted.org/packages/1f/c9/23b1ea718dfe967cbd986d16cf2a31fe59d015874258baae16d7ea0ccabc/pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f04d49a6b64cf24719c080b3c2029a3a5b16417fd5fd7c4041f94233af732f3", size = 41091183, upload-time = "2025-02-18T18:54:42.662Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d4/b4a3aa781a2c715520aa8ab4fe2e7fa49d33a1d4e71c8fc6ab7b5de7a3f8/pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a9137cf7e1640dce4c190551ee69d478f7121b5c6f323553b319cac936395f6", size = 42171896, upload-time = "2025-02-18T18:54:49.808Z" }, - { url = "https://files.pythonhosted.org/packages/23/1b/716d4cd5a3cbc387c6e6745d2704c4b46654ba2668260d25c402626c5ddb/pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7c1bca1897c28013db5e4c83944a2ab53231f541b9e0c3f4791206d0c0de389a", size = 40464851, upload-time = "2025-02-18T18:54:57.073Z" }, - { url = "https://files.pythonhosted.org/packages/ed/bd/54907846383dcc7ee28772d7e646f6c34276a17da740002a5cefe90f04f7/pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:58d9397b2e273ef76264b45531e9d552d8ec8a6688b7390b5be44c02a37aade8", size = 42085744, upload-time = "2025-02-18T18:55:08.562Z" }, +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] [[package]] @@ -4748,18 +4738,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/ff/30cfe8142470da3e45abe43a9842449ca0180d993320559890e2be19e4a5/pycocotools-2.0.11-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:368244f30eb8d6cae7003aa2c0831fbdf0153664a32859ec7fbceea52bfb6878", size = 474658, upload-time = "2025-12-15T22:31:24.883Z" }, { url = "https://files.pythonhosted.org/packages/bc/62/254ca92604106c7a5af3258e589e465e681fe0166f9b10f97d8ca70934d6/pycocotools-2.0.11-cp313-cp313t-win_amd64.whl", hash = "sha256:ac8aa17263e6489aa521f9fa91e959dfe0ea3a5519fde2cbf547312cdce7559e", size = 89681, upload-time = "2025-12-15T22:31:26.025Z" }, { url = "https://files.pythonhosted.org/packages/6e/f0/c019314dc122ad5e6281de420adc105abe9b59d00008f72ef3ad32b1e328/pycocotools-2.0.11-cp313-cp313t-win_arm64.whl", hash = "sha256:04480330df5013f6edd94891a0ee8294274185f1b5093d1b0f23d51778f0c0e9", size = 70520, upload-time = "2025-12-15T22:31:26.999Z" }, -] - -[[package]] -name = "pyconify" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/7f/94d424dc756a6287271cf40cf1b2a44c10e3f137bf3246a2b4a7416ca3d3/pyconify-0.2.1.tar.gz", hash = "sha256:8dd53757d9fbed41711434460932b2b5dbc25da720cd9f9a44af0187b2dfc07d", size = 22478, upload-time = "2025-02-06T13:20:53.592Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/40/50dd2e8bfec81676e4619903bd452c10dc0d8efac1533e79e67cc76759b5/pyconify-0.2.1-py3-none-any.whl", hash = "sha256:d3b53eee1f8a2d60c1d135610f42e789774dbe71c6d8af68af0a21d3b3ec9eb7", size = 19459, upload-time = "2025-02-06T13:20:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/66/2b/58b35c88f2086c043ff1c87bd8e7bf36f94e84f7b01a5e00b6f5fabb92a7/pycocotools-2.0.11-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a6b13baf6bfcf881b6d6ac6e23c776f87a68304cd86e53d1d6b9afa31e363c4e", size = 169883, upload-time = "2025-12-15T22:31:28.233Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/b970eefb78746c8b4f8b3fa1b49d9f3ec4c5429ef3c5d4bbcc55abebe478/pycocotools-2.0.11-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78bae4a9de9d34c4759754a848dfb3306f9ef1c2fcb12164ffbd3d013d008321", size = 486894, upload-time = "2025-12-15T22:31:29.283Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f7/db7436820a1948d96fa9764b6026103e808840979be01246049f2c1e7f94/pycocotools-2.0.11-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d896f4310379849dfcfa7893afb0ff21f4f3cdb04ab3f61b05dd98953dd0ad", size = 483249, upload-time = "2025-12-15T22:31:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a6/a14a12c9f50c41998fdc0d31fd3755bcbce124bac9abb1d6b99d1853cafd/pycocotools-2.0.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:eebd723503a2eb2c8b285f56ea3be1d9f3875cd7c40d945358a428db94f14015", size = 469070, upload-time = "2025-12-15T22:31:32.821Z" }, + { url = "https://files.pythonhosted.org/packages/46/de/aa4f65ece3da8e89310a1be00cad0700170fd13f41a3aaae2712291269d5/pycocotools-2.0.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bd7a1e19ef56a828a94bace673372071d334a9232cd32ae3cd48845a04d45c4f", size = 475589, upload-time = "2025-12-15T22:31:34.188Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/04a30df03ae6236b369b361df0c50531d173d03678978806aa2182e02d1e/pycocotools-2.0.11-cp314-cp314t-win_amd64.whl", hash = "sha256:63026e11a56211058d0e84e8263f74cbccd5e786fac18d83fd221ecb9819fcc7", size = 93863, upload-time = "2025-12-15T22:31:35.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/05/8942b640d6307a21c3ede188e8c56f07bedf246fac0e501437dbda72a350/pycocotools-2.0.11-cp314-cp314t-win_arm64.whl", hash = "sha256:8cedb8ccb97ffe9ed2c8c259234fa69f4f1e8665afe3a02caf93f6ef2952c07f", size = 72038, upload-time = "2025-12-15T22:31:36.768Z" }, ] [[package]] @@ -4786,18 +4771,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] -[[package]] -name = "pydantic-compat" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/7e/43400b6e0800065a982efcfa3e87c8f8d247d60ea75ca1a9d01702e050f8/pydantic_compat-0.1.2.tar.gz", hash = "sha256:c5c5bca39ca2d22cad00c02898e400e1920e5127649a8e860637f15566739373", size = 12838, upload-time = "2023-10-24T23:25:09.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/65/2edf586ff7b3dfc520977c6529c9b718c86ef8459ece088f1ef1f74bf1d4/pydantic_compat-0.1.2-py3-none-any.whl", hash = "sha256:37a4df48565a35aedc947f0fde5edbdff270a30836d995923287292bb59d5677", size = 13092, upload-time = "2023-10-24T23:25:08.155Z" }, -] - [[package]] name = "pydantic-core" version = "2.46.4" @@ -4837,6 +4810,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, @@ -4856,46 +4859,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - -[[package]] -name = "pydantic-zarr" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/57/aac9e500abe15719b7406d55871fc8787e7d0ebf83de5f98d44471139f70/pydantic_zarr-0.10.0.tar.gz", hash = "sha256:996b5b4225fb05757e7fff831ee7a75c61f54514a4c2493c7b79635bade7b415", size = 80374, upload-time = "2026-04-23T14:23:12.575Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl", hash = "sha256:d15769dab43346b070051fdf6e83509d7f55259dd00c6d12be2400935117e230", size = 52809, upload-time = "2026-04-23T14:23:11.011Z" }, -] - -[[package]] -name = "pydot" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -4947,15 +4910,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, ] -[[package]] -name = "pyopengl" -version = "3.1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, -] - [[package]] name = "pyparsing" version = "3.3.2" @@ -4965,81 +4919,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, -] - -[[package]] -name = "pyqt5" -version = "5.15.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyqt5-qt5", version = "5.15.2", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "pyqt5-qt5", version = "5.15.19", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, - { name = "pyqt5-sip" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/07/c9ed0bd428df6f87183fca565a79fee19fa7c88c7f00a7f011ab4379e77a/PyQt5-5.15.11.tar.gz", hash = "sha256:fda45743ebb4a27b4b1a51c6d8ef455c4c1b5d610c90d2934c7802b5c1557c52", size = 3216775, upload-time = "2024-07-19T08:39:57.756Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/64/42ec1b0bd72d87f87bde6ceb6869f444d91a2d601f2e67cd05febc0346a1/PyQt5-5.15.11-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b03dd9380bb13c804f0bdb0f4956067f281785b5e12303d529f0462f9afdc2", size = 6579776, upload-time = "2024-07-19T08:39:19.775Z" }, - { url = "https://files.pythonhosted.org/packages/49/f5/3fb696f4683ea45d68b7e77302eff173493ac81e43d63adb60fa760b9f91/PyQt5-5.15.11-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:6cd75628f6e732b1ffcfe709ab833a0716c0445d7aec8046a48d5843352becb6", size = 7016415, upload-time = "2024-07-19T08:39:32.977Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8c/4065950f9d013c4b2e588fe33cf04e564c2322842d84dbcbce5ba1dc28b0/PyQt5-5.15.11-cp38-abi3-manylinux_2_17_x86_64.whl", hash = "sha256:cd672a6738d1ae33ef7d9efa8e6cb0a1525ecf53ec86da80a9e1b6ec38c8d0f1", size = 8188103, upload-time = "2024-07-19T08:39:40.561Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/ae5a5b4f9b826b29ea4be841b2f2d951bcf5ae1d802f3732b145b57c5355/PyQt5-5.15.11-cp38-abi3-win32.whl", hash = "sha256:76be0322ceda5deecd1708a8d628e698089a1cea80d1a49d242a6d579a40babd", size = 5433308, upload-time = "2024-07-19T08:39:46.932Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/68eb9f3d19ce65df01b6c7b7a577ad3bbc9ab3a5dd3491a4756e71838ec9/PyQt5-5.15.11-cp38-abi3-win_amd64.whl", hash = "sha256:bdde598a3bb95022131a5c9ea62e0a96bd6fb28932cc1619fd7ba211531b7517", size = 6865864, upload-time = "2024-07-19T08:39:53.572Z" }, -] - -[[package]] -name = "pyqt5-qt5" -version = "5.15.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and platform_machine != 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and platform_machine == 's390x' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/09/99a222b0360616250fb2e6003a54e43a2a06b0774f0f8d5daafb86a2c375/PyQt5_Qt5-5.15.2-py3-none-macosx_10_13_intel.whl", hash = "sha256:76980cd3d7ae87e3c7a33bfebfaee84448fd650bad6840471d6cae199b56e154", size = 40546019, upload-time = "2021-03-10T13:52:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/83/d4/241a6a518d0bcf0a9fcdcbad5edfed18d43e884317eab8d5230a2b27e206/PyQt5_Qt5-5.15.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:1988f364ec8caf87a6ee5d5a3a5210d57539988bf8e84714c7d60972692e2f4a", size = 59921716, upload-time = "2021-03-10T13:57:39.485Z" }, -] - -[[package]] -name = "pyqt5-qt5" -version = "5.15.19" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", - "python_full_version < '3.13' and platform_machine != 's390x' and sys_platform == 'win32'", - "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", - "python_full_version < '3.13' and platform_machine == 's390x' and sys_platform == 'win32'", - "python_full_version >= '3.13' and platform_machine != 's390x' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and platform_machine != 's390x' and sys_platform == 'emscripten'", - "python_full_version >= '3.13' and platform_machine == 's390x' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and platform_machine == 's390x' and sys_platform == 'emscripten'", -] - -[[package]] -name = "pyqt5-sip" -version = "12.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/31/5ef342de9faee0f3801088946ae103db9b9eaeba3d6a64fefd5ce74df244/pyqt5_sip-12.18.0.tar.gz", hash = "sha256:71c37db75a0664325de149f43e2a712ec5fa1f90429a21dafbca005cb6767f94", size = 104143, upload-time = "2026-01-13T15:53:19.576Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/61/6d78d702016ac23d2b97634a3b6a831c3f7735f0552a1c8b058db96005d1/pyqt5_sip-12.18.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b29e4cda24748e59e5bd1bdad4812091a86b4b5b08c38b7f781eb55a5166f2b7", size = 124614, upload-time = "2026-01-13T15:52:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/19/bf/8f3efa10ddd3e76c1253865340ab7c2960ef96681d732b1f666c77430612/pyqt5_sip-12.18.0-cp312-cp312-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:163c2bba5e637c2222ec17d82a2c5aa158184a191923eb7d137cf4cfa0399529", size = 339412, upload-time = "2026-01-13T15:53:00.563Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/f1bcf6729d01bae6729cd790b22fd579dbe34014e8be031e6f10c5b9b2aa/pyqt5_sip-12.18.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ead5e0a64ad852ac60797989d8444a6a5bd834768536b04a07b40b2937d922f6", size = 282376, upload-time = "2026-01-13T15:52:59.172Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b7/d84c764ac9f1366be561255ec9bd88ee224fefdbdb349aee250f3003f0ca/pyqt5_sip-12.18.0-cp312-cp312-win32.whl", hash = "sha256:993fe3ed9a62a92e770f32d5344e3df56c2cacf1471f01b7feaf04818a2df1c4", size = 49523, upload-time = "2026-01-13T15:53:03.068Z" }, - { url = "https://files.pythonhosted.org/packages/ab/e7/ef87178d5afa5f63be38556dc0df8af89f9bf74f2555f4dab6824c0fd150/pyqt5_sip-12.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:9b689e02e400abd1ce0a30cd6eae8eceabcf1bbba0395cb5c86e64ba74351d68", size = 58001, upload-time = "2026-01-13T15:53:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/79/67/8d43d0fea10ff48ddecc8534aead8b855dc80df80653b8b1bf9e1f993063/pyqt5_sip-12.18.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9254e5dd7676b76503ba20edcc919e7ac4a97b6c70a6fb2f9dba9e13b4c60509", size = 124605, upload-time = "2026-01-13T15:53:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/48/2a/b08bc8efeb49c50c6cdac11417dc2c8eaefcac2f0a6382eae7b26dc0f232/pyqt5_sip-12.18.0-cp313-cp313-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c969631ada7293a81e1012b2264a62d69a91995b517586489dfe24421b87b9af", size = 339918, upload-time = "2026-01-13T15:53:08.502Z" }, - { url = "https://files.pythonhosted.org/packages/b6/99/24f82437b2f073cf39296b7c731b6a8bc0f5207911fdd93841a0ea9abe42/pyqt5_sip-12.18.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d84ac384a63285132e67762c87681191c25e28a1df7560287ec3889d9eb223b5", size = 282088, upload-time = "2026-01-13T15:53:06.632Z" }, - { url = "https://files.pythonhosted.org/packages/3e/27/20d3924943df34361fae9c6a0489ae89d0b07571693245c61678d185e4a4/pyqt5_sip-12.18.0-cp313-cp313-win32.whl", hash = "sha256:95bba4670ecf5cba73958b85aa2087c17838a402ed251c38e68060c7665c998b", size = 49501, upload-time = "2026-01-13T15:53:11.159Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/e251623c12968730730512a9e5150430e36246afbe64894610190b896f61/pyqt5_sip-12.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:aac4adc37df2f2ac1dc259409be1900f07332d140a12c9db7c84112cef64ff59", size = 58076, upload-time = "2026-01-13T15:53:09.928Z" }, -] - [[package]] name = "pyqtgraph" version = "0.14.0" @@ -5071,6 +4950,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/6f/5173067773b5e650566f90c3356bd3a71c02349e6644222a8971c0196687/pyscipopt-6.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8860afb9d43c93b653c7295ef674ad1ba156c731591fb7e33b5991c1136b0dc6", size = 16207762, upload-time = "2026-05-16T15:05:26.883Z" }, { url = "https://files.pythonhosted.org/packages/57/76/59288c92b7ee914c351c6003b63249e0702d990d1fdd9ff97e65ffc1c57b/pyscipopt-6.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:725503ea90fd0962f1111a5b46ee3e271b7375e1b0dfda708ce223dbebaeff5e", size = 17560629, upload-time = "2026-05-16T15:05:30.298Z" }, { url = "https://files.pythonhosted.org/packages/0d/42/2b1d48d11311c2fda215c8da83f8f8cabec3b5a82936a7229919ad9141e5/pyscipopt-6.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a5621ddcb59f7816e87c3deeb1bf52f8693e016a1a29148201b712adb84956e3", size = 48215647, upload-time = "2026-05-16T15:05:36.5Z" }, + { url = "https://files.pythonhosted.org/packages/fc/af/4ddfd3802ff69ad58f44e6d96878265c4ea55cac2313aae1994611b0d6aa/pyscipopt-6.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:299dbff65d60853d545788457d02720e3693df19ade4aaba1bbe8b575239ff4b", size = 8418209, upload-time = "2026-05-16T15:05:40.166Z" }, + { url = "https://files.pythonhosted.org/packages/9a/53/61ab5575a6e29547b6d13c37d7005b3f348897b48dd6cbcd4261196f2e25/pyscipopt-6.2.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:8bd6fb0f2a6c8bf4ef49e524d4c022b7b539098193ce312e94d29c6254658ff6", size = 12267358, upload-time = "2026-05-16T15:05:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fd/851dedcabdb6d6168eae0e2c31008d02f20cf018b05d091a5bda6f5da6a1/pyscipopt-6.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb7858ceececa09658c3bfa58476862255353fea52d4f512ff97160f138f92cd", size = 16171696, upload-time = "2026-05-16T15:05:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/ac663246709e6ab94b225c40e9dac863a91b1b86e12c2ea65c951f944bea/pyscipopt-6.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cdf0e460b834f06ea68a13e59f581f7ecbec5a466a76bbf1267ac5b4573bb52", size = 17430536, upload-time = "2026-05-16T15:05:50.267Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/05d80040767d1d2443ea6cd6cc42985cdd532feeaecfcc19f103c8b45a1b/pyscipopt-6.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6aed03b621decb09b38f399773bf8cf2c707e965990b778a24d28c8cc90a0756", size = 49375214, upload-time = "2026-05-16T15:05:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/05/d4/caa18165a2faf876d8d482b0b92bd75abf284af2b51b64ebee62c1b47065/pyscipopt-6.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:18c6283b8fc47929787a043204e7bb26f41e414d1e8597d394057bcc60fc3ee0", size = 8528074, upload-time = "2026-05-16T15:05:59.776Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3c/f6593bf4459e788f2302878369c5eb65ec5b04fd6e18524296fe05365c6b/pyscipopt-6.2.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:622e9640cdfc22632079ed4f73d88653d6170ee89ca6bdb6459d0db334fa7a87", size = 12341833, upload-time = "2026-05-16T15:06:02.402Z" }, + { url = "https://files.pythonhosted.org/packages/49/db/bc80dfa03ef13701b818aca26428fe05c389a71a8ec5654e1e4ea60ef3a4/pyscipopt-6.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a85fb7f73fd06fad9a0358b2eb47d94dbe94ee52baf85e045beb5e216022b6c7", size = 16735540, upload-time = "2026-05-16T15:06:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/58/ee/97cd080513efdedea2fcd1f9b71d02e775f5e445ab39f8f012c14e4b4c5e/pyscipopt-6.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d323fa83b855308587794cd0ab04ae6dd341c227202d0ddaec2761917034e972", size = 17628571, upload-time = "2026-05-16T15:06:09.608Z" }, + { url = "https://files.pythonhosted.org/packages/7b/75/e280c162ae55a5db7f9031c4b92774964a8eebef94d2d463a389c9f6294b/pyscipopt-6.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:138e312cd692b7c853e7eab572986bc6d2dd28acd6e16347d73c96c225613056", size = 49551043, upload-time = "2026-05-16T15:06:15.368Z" }, ] [[package]] @@ -5104,24 +4993,28 @@ wheels = [ ] [[package]] -name = "python-dateutil" -version = "2.9.0.post0" +name = "pytest-xdist" +version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "execnet" }, + { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] -name = "python-dotenv" -version = "1.2.2" +name = "python-dateutil" +version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -5209,19 +5102,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/fe/2b70276ede7878c5fe8356ca07574db5da63e222ce39a463e84bfad135e8/pywavelets-1.9.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3830e6657236b53a3aae20c735cccead942bb97c54bbca9e7d07bae01645fe9c", size = 4477528, upload-time = "2025-08-04T16:19:35.932Z" }, { url = "https://files.pythonhosted.org/packages/e7/ed/d58b540c15e36508cfeded7b0d39493e811b0dce18d9d4e6787fb2e89685/pywavelets-1.9.0-cp313-cp313t-win32.whl", hash = "sha256:81bb65facfbd7b50dec50450516e72cdc51376ecfdd46f2e945bb89d39bfb783", size = 4186493, upload-time = "2025-08-04T16:19:37.198Z" }, { url = "https://files.pythonhosted.org/packages/84/b2/12a849650d618a86bbe4d8876c7e20a7afe59a8cad6f49c57eca9af26dfa/pywavelets-1.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:47d52cf35e2afded8cfe1133663f6f67106a3220b77645476ae660ad34922cb4", size = 4274821, upload-time = "2025-08-04T16:19:38.436Z" }, -] - -[[package]] -name = "pywin32" -version = "312" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1f/18c82122547c9eec2232d800b02ada1fbd30ce2136137b5738acca9d653e/pywavelets-1.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:53043d2f3f4e55a576f51ac594fe33181e1d096d958e01524db5070eb3825306", size = 4314440, upload-time = "2025-08-04T16:19:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/1c92ac6b538ef5388caf1a74af61cf6af16ea6d14115bb53357469cb38d6/pywavelets-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bc36b42b1b125fd9cb56e7956b22f8d0f83c1093f49c77fc042135e588c799", size = 4290162, upload-time = "2025-08-04T16:19:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/d856a2cac8069c20144598fa30a43ca40b5df2e633230848a9a942faf04a/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08076eb9a182ddc6054ac86868fb71df6267c341635036dc63d20bdbacd9ad7e", size = 4437162, upload-time = "2025-08-04T16:19:42.556Z" }, + { url = "https://files.pythonhosted.org/packages/c9/54/777e0495acd4fb008791e84889be33d6e7fc8af095b441d939390b7d2491/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ee1ee7d80f88c64b8ec3b5021dd1e94545cc97f0cd479fb51aa7b10f6def08e", size = 4498169, upload-time = "2025-08-04T16:19:43.791Z" }, + { url = "https://files.pythonhosted.org/packages/76/68/81b97f4d18491a18fbe17e06e2eee80a591ce445942f7b6f522de07813c5/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3226b6f62838a6ccd7782cb7449ee5d8b9d61999506c1d9b03b2baf41b01b6fd", size = 4443318, upload-time = "2025-08-04T16:19:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/92/74/5147f2f0436f7aa131cb1bc13dba32ef5f3862748ae1c7366b4cde380362/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb7f4b11d18e2db6dd8deee7b3ce8343d45f195f3f278c2af6e3724b1b93a24", size = 4503294, upload-time = "2025-08-04T16:19:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d4/af998cc71e869919e0ab45471bd43e91d055ac7bc3ce6f56cc792c9b6bc8/pywavelets-1.9.0-cp314-cp314-win32.whl", hash = "sha256:9902d9fc9812588ab2dce359a1307d8e7f002b53a835640e2c9388fe62a82fd4", size = 4144478, upload-time = "2025-08-04T16:19:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/66/1d071eae5cc3e3ad0e45334462f8ce526a79767ccb759eb851aa5b78a73a/pywavelets-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:7e57792bde40e331d6cc65458e5970fd814dba18cfc4e9add9d051e901a7b7c7", size = 4227186, upload-time = "2025-08-04T16:19:49.57Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1f/da0c03ac99bd9d20409c0acf6417806d4cf333d70621da9f535dd0cf27fa/pywavelets-1.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b47c72fb4b76d665c4c598a5b621b505944e5b761bf03df9d169029aafcb652f", size = 4354391, upload-time = "2025-08-04T16:19:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/de9e225d8cc307fbb4fda88aefa79442775d5e27c58ee4d3c8a8580ceba6/pywavelets-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:969e369899e7eab546ea5d77074e4125082e6f9dad71966499bf5dee3758be55", size = 4335810, upload-time = "2025-08-04T16:19:52.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/3b/336761359d07cd44a4233ca854704ff2a9e78d285879ccc82d254b9daa57/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8aeffd4f35036c1fade972a61454de5709a7a8fc9a7d177eefe3ac34d76962e5", size = 4422220, upload-time = "2025-08-04T16:19:54.068Z" }, + { url = "https://files.pythonhosted.org/packages/98/61/76ccc7ada127f14f65eda40e37407b344fd3713acfca7a94d7f0f67fe57d/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f63f400fcd4e7007529bd06a5886009760da35cd7e76bb6adb5a5fbee4ffeb8c", size = 4470156, upload-time = "2025-08-04T16:19:55.379Z" }, + { url = "https://files.pythonhosted.org/packages/e0/de/142ca27ee729cf64113c2560748fcf2bd45b899ff282d6f6f3c0e7f177bb/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a63bcb6b5759a7eb187aeb5e8cd316b7adab7de1f4b5a0446c9a6bcebdfc22fb", size = 4430167, upload-time = "2025-08-04T16:19:56.566Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5e/90b39adff710d698c00ba9c3125e2bec99dad7c5f1a3ba37c73a78a6689f/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9950eb7c8b942e9bfa53d87c7e45a420dcddbd835c4c5f1aca045a3f775c6113", size = 4477378, upload-time = "2025-08-04T16:19:58.162Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/89f5f4ebcb9d34d9b7b2ac0a868c8b6d8c78d699a36f54407a060cea0566/pywavelets-1.9.0-cp314-cp314t-win32.whl", hash = "sha256:097f157e07858a1eb370e0d9c1bd11185acdece5cca10756d6c3c7b35b52771a", size = 4209132, upload-time = "2025-08-04T16:20:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, ] [[package]] @@ -5236,6 +5132,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/50/724ed5c38c504d4e58a88a072776a1e880d970789deaeb2b9f7bd9a5141a/pywinpty-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:fe1f7911805127c94cf51f89ab14096c6f91ffdcacf993d2da6082b2142a2523", size = 234591, upload-time = "2026-02-04T21:52:29.821Z" }, { url = "https://files.pythonhosted.org/packages/f7/ad/90a110538696b12b39fd8758a06d70ded899308198ad2305ac68e361126e/pywinpty-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:3f07a6cf1c1d470d284e614733c3d0f726d2c85e78508ea10a403140c3c0c18a", size = 2112360, upload-time = "2026-02-04T21:55:33.397Z" }, { url = "https://files.pythonhosted.org/packages/44/0f/7ffa221757a220402bc79fda44044c3f2cc57338d878ab7d622add6f4581/pywinpty-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:15c7c0b6f8e9d87aabbaff76468dabf6e6121332c40fc1d83548d02a9d6a3759", size = 233107, upload-time = "2026-02-04T21:51:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/88/2ff917caff61e55f38bcdb27de06ee30597881b2cae44fbba7627be015c4/pywinpty-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:d4b6b7b0fe0cdcd02e956bd57cfe9f4e5a06514eecf3b5ae174da4f951b58be9", size = 2113282, upload-time = "2026-02-04T21:52:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/63/32/40a775343ace542cc43ece3f1d1fce454021521ecac41c4c4573081c2336/pywinpty-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:34789d685fc0d547ce0c8a65e5a70e56f77d732fa6e03c8f74fefb8cbb252019", size = 234207, upload-time = "2026-02-04T21:51:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/8d/54/5d5e52f4cb75028104ca6faf36c10f9692389b1986d34471663b4ebebd6d/pywinpty-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c37e224a47a971d1a6e08649a1714dac4f63c11920780977829ed5c8cadead1", size = 2112910, upload-time = "2026-02-04T21:52:30.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/dcd184824e21d4620b06c7db9fbb15c3ad0a0f1fa2e6de79969fb82647ec/pywinpty-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c4e9c3dff7d86ba81937438d5819f19f385a39d8f592d4e8af67148ceb4f6ab5", size = 233425, upload-time = "2026-02-04T21:51:56.754Z" }, ] [[package]] @@ -5264,6 +5164,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -5309,37 +5227,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, -] - -[[package]] -name = "qtawesome" -version = "1.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "qtpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/bc/475c8df94de4f358bbf7703747c13b16ae3c0c4cda50d5243b99cb45e2a8/qtawesome-1.4.2.tar.gz", hash = "sha256:b2bf9351beb335095006892796f072ffd9755a2d7e5113dc71918dcd9ba4ef4a", size = 2614207, upload-time = "2026-04-10T18:49:46.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/b1/da1d826ccc9258674b26dd0abcbbb1c55cab06e7fbf2697518b67edc79fc/qtawesome-1.4.2-py3-none-any.whl", hash = "sha256:dbf08524428fa2df73918ce362153254cd44f089380576d84bfaad8f40eece45", size = 2593557, upload-time = "2026-04-10T18:49:44.219Z" }, -] - -[[package]] -name = "qtconsole" -version = "5.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "ipython-pygments-lexers" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "qtpy" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/28/4070eb0bacb99bc00bf60173fda25fb6a559d6600040035ba96c196d3647/qtconsole-5.7.2.tar.gz", hash = "sha256:27b485b9161925924c1d8e78e66bb342e6e3bc49bf675d0a67b49bad9c291521", size = 436661, upload-time = "2026-03-25T02:24:38.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/60/aba9f3c3f1f48c7fbdf03bed45b576a916cd09c08242939b5294b85e37b4/qtconsole-5.7.2-py3-none-any.whl", hash = "sha256:e1d1f6a792123363626e643a7a4ee561217773571043992693fba7eccfa89f95", size = 125883, upload-time = "2026-03-25T02:24:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, ] [[package]] @@ -5376,15 +5273,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/25/4511e114e3f8420a0edd9febd12de1a4a56b3259a779d7ce5d92c5dfd6ac/quilt3-7.3.0-py3-none-any.whl", hash = "sha256:585245b73ad40586af6fc5be689e1113d3e44bcb75e198b3ee9a03a036b79d07", size = 136740, upload-time = "2026-04-07T21:15:31.782Z" }, ] -[[package]] -name = "rangehttpserver" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/3b/1ec139d6028c6e5eb10301f040d6eee5c5427a4b1b4d614a2f78d3bba1bd/rangehttpserver-1.4.0.tar.gz", hash = "sha256:d5ddccee219b359598e41da0c5fbf30a2579297094f5a682755e2586388a5306", size = 6993, upload-time = "2024-08-27T18:08:43.418Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/43/d7e2b9ad768c07b5473bea3ac7db9ca4d995c09399cbea3d4df1c0bd4955/rangehttpserver-1.4.0-py2.py3-none-any.whl", hash = "sha256:2a0c6926e4341de4cc19ec861292b005e4194ff497b1eefdeccb2992a5045452", size = 7773, upload-time = "2024-08-27T18:08:41.861Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -5453,6 +5341,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] @@ -5540,15 +5460,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/78/3cc84d58e13234861adf91d2897254cd84d3d4a90534d91c960b672e67d1/roifile-2026.2.10-py3-none-any.whl", hash = "sha256:5928a616b58a8ff1c9f89a0590db3616ccbf069c6692f8ecec2a7349f8bbb6ec", size = 21201, upload-time = "2026-02-11T19:54:34.137Z" }, ] -[[package]] -name = "roman-numerals" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, -] - [[package]] name = "rpds-py" version = "2026.5.1" @@ -5599,6 +5510,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, ] [[package]] @@ -5623,30 +5592,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ec/cee878c1879b91ab8dc7d564535d011307839a2fea79d2a650413edf53be/rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea", size = 2055049, upload-time = "2025-08-13T01:43:44.926Z" }, ] -[[package]] -name = "s3fs" -version = "2026.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiobotocore" }, - { name = "aiohttp" }, - { name = "fsspec" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/d8/76f3dc1558bdf4494b117a9f7a9cc0a5d9d34edadc9e5d7ceabc5a6a7c37/s3fs-2026.4.0.tar.gz", hash = "sha256:5bdce0abb00b0435ee150807a45fea727451dbc22de4cbc116464f8504ab9d37", size = 85986, upload-time = "2026-04-29T20:52:51.748Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a4/9d1ea10ebc9e028a289a72fec84da170689549a8102c8aacfcad26bc5035/s3fs-2026.4.0-py3-none-any.whl", hash = "sha256:de0d2a1f33cdf03831fd2382d278c6e4e31fe57c3bf2f703c61f8aec6b703e2a", size = 32392, upload-time = "2026-04-29T20:52:50.295Z" }, -] - [[package]] name = "s3transfer" -version = "0.17.1" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/b3/bcdc2f58fa92592db511beda154c2c08d28f21f6c4637f06a42a24b10c21/s3transfer-0.17.1.tar.gz", hash = "sha256:042dd5e3b1b512355e35a23f0223e426b7042e80b97830ea2680ddce327fc45e", size = 159439, upload-time = "2026-05-26T19:45:01.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl", hash = "sha256:5b9827d1044159bbb01b86ef8902760ea39281927f5de31de75e1d657177bf4c", size = 88264, upload-time = "2026-05-26T19:45:00.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" }, ] [[package]] @@ -5711,11 +5666,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, -] - -[package.optional-dependencies] -data = [ - { name = "pooch" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, ] [[package]] @@ -5743,6 +5709,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, ] [[package]] @@ -5784,6 +5762,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] @@ -5883,15 +5881,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] -[[package]] -name = "snowballstemmer" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, -] - [[package]] name = "sortedcontainers" version = "2.4.0" @@ -5910,104 +5899,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] -[[package]] -name = "spatial-graph" -version = "0.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ct3" }, - { name = "numpy" }, - { name = "setuptools" }, - { name = "typing-extensions" }, - { name = "witty" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/d9/0b1014a2d8c280801e86a1b61716f5987edd78c5bee1c914e35ae51dd0dc/spatial_graph-0.0.6.tar.gz", hash = "sha256:5e605ebc6940040db6405f259c729d2bf76f9bec9c3d0a081ef111e9c33cc812", size = 51898, upload-time = "2026-06-09T16:58:47.514Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/f5/f765c87da4a63ac6fc61364cf32cf07bbf4b5b27501ee4e50b5e54d0fcd9/spatial_graph-0.0.6-py3-none-any.whl", hash = "sha256:dda770061facb7c900fd6fc77c9eb00c8ffa93aed70ca32c98f925029b688ffa", size = 48034, upload-time = "2026-06-09T16:58:46.471Z" }, -] - -[[package]] -name = "sphinx" -version = "9.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, -] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, -] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, -] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, -] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, -] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, -] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.50" @@ -6032,6 +5923,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, ] @@ -6049,19 +5954,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, -] - [[package]] name = "statsmodels" version = "0.14.6" @@ -6087,25 +5979,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, -] - -[[package]] -name = "superqt" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, - { name = "qtpy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/84/fe/919245507b15b4633cd40292d69c3b6afa8a650bf3ec33f3329d26314a3c/superqt-0.8.2.tar.gz", hash = "sha256:faa3bd00ef1c9b209b1f31b154dd41c596fec3824f61a70c251fb5569acd7ba6", size = 110190, upload-time = "2026-05-18T14:33:47.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/b9/748c3cbcdba20ef01c055a35905cad5c771cc1df29be11b9a82da6fd8e0d/superqt-0.8.2-py3-none-any.whl", hash = "sha256:ce8400ae7b577d090368aae5bdbabb06cd6bc893f6ca73485bb686835bf20943", size = 101595, upload-time = "2026-05-18T14:33:45.462Z" }, -] - -[package.optional-dependencies] -iconify = [ - { name = "pyconify" }, + { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, + { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, ] [[package]] @@ -6206,6 +6085,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/b6/d574e2b758631563861d51cba4cc595d27a3965db3473a05ab268eead05b/tensordict-0.12.4-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:6e60888bc24990ead02d52f16fa607af8c01c92089ad767540eca88ade5fb49f", size = 535213, upload-time = "2026-05-22T00:09:39.561Z" }, { url = "https://files.pythonhosted.org/packages/13/a4/25c29e653878e58ed3cb111146e4dd8cdb4cfd4b6f66dd2080f94f8e78f4/tensordict-0.12.4-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031c70d2101376e0fb8036b017c8271a27892c1b9ba6aea021c039c7535aac53", size = 539088, upload-time = "2026-05-22T00:09:41.377Z" }, { url = "https://files.pythonhosted.org/packages/35/6f/c8107ea679a60e7584bc6d36b854879a33f5a990819e174a7ed653edb781/tensordict-0.12.4-cp313-cp313t-win_amd64.whl", hash = "sha256:a1320ea2ed9e0289209b0efc51b8bf2bca02cf5273fade3aec4f60a4ddfed61b", size = 597644, upload-time = "2026-05-22T00:09:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f7/ceeacc76ed53e3bc7ba428ee35fb42dad6fdcda4974d5685b89c89906ff5/tensordict-0.12.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d52e2425ef8d509966819a8da240ed2681f685aa0ba61c155641d994587b398c", size = 890261, upload-time = "2026-05-22T00:09:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/eec984a88be0a19c538556186dd6fa937d8d39d581348e40894246272fc8/tensordict-0.12.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:4b3bb6b1120c2c3759abc814d1cd5f38353a1488790d8e4fc1c11e7f0e36b4a9", size = 534472, upload-time = "2026-05-22T00:09:46.336Z" }, + { url = "https://files.pythonhosted.org/packages/a6/94/610a0fc9f62a573b24e21b2a2cf230cf896e2a7e54ddffbaddfd95e2d2d1/tensordict-0.12.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ca594b8d67ffa899716c91922dcfcc9d7fa56ca332d43804076a6a7b54cce078", size = 537785, upload-time = "2026-05-22T00:09:47.932Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4a/b49f6d1ef76d5b51ba45d7855b3845939006a823761fddf8ca6d9436e9a9/tensordict-0.12.4-cp314-cp314-win_amd64.whl", hash = "sha256:413a7d8f05e9c38fef564d73a754e878d3a771de9b46d5b62a12af7a9cde15e9", size = 587017, upload-time = "2026-05-22T00:09:49.452Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/e2bcdedf16e7693109d74ebdd642c45698e7aa79612f49e6d60e67c013c6/tensordict-0.12.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:9f0fa20d70b54afbdf8fba6da573e27c12637aad40c44262f9b864eee3c49188", size = 894689, upload-time = "2026-05-22T00:09:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/b8/62/f04b168b747fd0b7feae2553b91c10cf85a833643a3d44973e775b098878/tensordict-0.12.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e8a61b6396d57cb60cc6dca5e3e3879a3afa993adbcce438a0af75e22783bb84", size = 534805, upload-time = "2026-05-22T00:09:53.571Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/f60905b7847fe915d667cde140afae0a853be4a928510ac7d702daaa4cb1/tensordict-0.12.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:fbe824052ba1d3fafe1ea53e27a3ac097a398bb1842aacdd27bf7e4715cc8df1", size = 538514, upload-time = "2026-05-22T00:09:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/af/47/b814dc48e74a2c2335985652800809ef84579f7c827fc73583c308fdca56/tensordict-0.12.4-cp314-cp314t-win_amd64.whl", hash = "sha256:113c22d41b8d791cb1e3ec875c2a7e516cc271917de90d00b77d3102b029fc89", size = 597638, upload-time = "2026-05-22T00:09:56.843Z" }, ] [[package]] @@ -6228,6 +6115,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/28/03e46405ba7c616e7a1ec5425a8f4a1b3f4d6ec2be359cb2f248199849e4/tensorstore-0.1.84-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50afb06c57a509091015af6a85da6f483a7f5ad0372284dd95d5513d877336e4", size = 19344890, upload-time = "2026-05-16T06:17:28.956Z" }, { url = "https://files.pythonhosted.org/packages/5e/c6/82669e70cef67c803852285ba6f59d7e3d102983c0ab4be8269c14756677/tensorstore-0.1.84-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea53a851ea86aad3d99c14a790c85468d6324be14c7ac211f1f0265e8fab707", size = 20968230, upload-time = "2026-05-16T06:17:31.374Z" }, { url = "https://files.pythonhosted.org/packages/a5/12/97d8ad183e3130e168f2feb860edd68f1b72e57f29268d980f3b70e34cd0/tensorstore-0.1.84-cp313-cp313-win_amd64.whl", hash = "sha256:fe9bf1c7fef69884a91222179550f9b5ba6c1454f9534429221824d9b15c00ec", size = 13398858, upload-time = "2026-05-16T06:17:33.658Z" }, + { url = "https://files.pythonhosted.org/packages/39/bd/fda828e7915ddb61704a6f4568b5b7ce9fe607c33b7535cf51b4fd900b38/tensorstore-0.1.84-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97e767a64415297f019dd1e4b9af6a23d72e126e2a2d05cf41083253abe81428", size = 16576451, upload-time = "2026-05-16T06:17:35.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/99/2e72fcb19404de43f9412880c542a8ef8651bd30183c85454d6ca14ebe56/tensorstore-0.1.84-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7d01775985fcaa2b0f100349766a953c5086e92e746bf395e936151d4d8f9ac", size = 14907896, upload-time = "2026-05-16T06:17:38.458Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/9a3964cdfdc5a15df6d0485694de9684c13990a5b0f5d88bfa365e0a2936/tensorstore-0.1.84-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2ff5d5536a8a9b1596c51b9075cc9d40b4c4ea4e6cc03c0480111dbe5d956d", size = 19347021, upload-time = "2026-05-16T06:17:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/43/19/70532cb2bf2f6fc3bf252f850bfb528b26eeb9c30c3cafffb075cbb7c77a/tensorstore-0.1.84-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c9108ae6c29adc90b72ca267ba2b577386c5e410ea2f8e87eabce5ebdad327e", size = 20970345, upload-time = "2026-05-16T06:17:43.874Z" }, + { url = "https://files.pythonhosted.org/packages/4e/22/a523e7576c83a6c35bd1415e5f4530b0f1e448c099d7e22684f55792755c/tensorstore-0.1.84-cp314-cp314-win_amd64.whl", hash = "sha256:4096220f4b9a2411c3751597dd8ced2f671d7a217575613c915191e19d5ea150", size = 13787429, upload-time = "2026-05-16T06:17:46.135Z" }, + { url = "https://files.pythonhosted.org/packages/2b/23/40b0a0a91973431770ac09a8b69039bd7a051169f6707b63a1c51ad36782/tensorstore-0.1.84-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:70284a7225d94adad5989de7f9238638ec9fec2bdd8c0fdb86d567f16d59e615", size = 16652804, upload-time = "2026-05-16T06:17:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/2ea3b37864adb2e9f6e724c6959ab2b7f56aa4dad01964d4b32adf211e68/tensorstore-0.1.84-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98e10cae5b3fc0828967b8ddf36242b3b26ac8bc79880bc3e36346063259212a", size = 14990904, upload-time = "2026-05-16T06:17:51.464Z" }, + { url = "https://files.pythonhosted.org/packages/ff/26/fe72887d7b91e832bef3033d244c4c548993d1b6fb19177dff3895659c12/tensorstore-0.1.84-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98edbc57d453ba8ebcf0d19bc28e4de6de95922bee2eca0805955b0833b8ce26", size = 19365698, upload-time = "2026-05-16T06:17:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/37/74/35a1d41343f86f6e2ef135e81f6b8107b9f16c777a3e8be9e3fbce541d18/tensorstore-0.1.84-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bd20de85c1b83dd3ca94db24e7bd449bdb055590a1b162b05691f6b81fff00f", size = 20986107, upload-time = "2026-05-16T06:17:56.571Z" }, ] [[package]] @@ -6319,15 +6215,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "tomli" version = "2.4.1" @@ -6352,18 +6239,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, -] - [[package]] name = "toolz" version = "1.1.0" @@ -6407,6 +6303,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/07/055d06d985b445d67422d25b033c11cf55bbb81785d4c4e68e28bca5820e/torch-2.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af68dbf403439cae9ceaeaaf92f8352b460787dcd27b92aa05c40dd4a19c0f1e", size = 426397656, upload-time = "2026-05-13T14:52:38.84Z" }, { url = "https://files.pythonhosted.org/packages/43/94/b0b4fdc3014122e0a7302fb90086d352aa48f2576f0b252561ebb38c01a8/torch-2.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a6a2eebb237d3b1d9ad3b378e86d9b9e0782afdea8b1e0eba6a13646b9b49c07", size = 532183124, upload-time = "2026-05-13T14:53:16.178Z" }, { url = "https://files.pythonhosted.org/packages/d8/c8/052405e6ad05d3237bfe5a4df78f917773956f8e17813a2d44c059068b74/torch-2.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2140e373e9a51a3e22ef62e8d14366d0b470d18f0adf19fdc757368077133a34", size = 123232462, upload-time = "2026-05-13T14:52:27.26Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/ac069f8d6e8be701535921141055293b0d4819d3d7f224a4612cf157c7f9/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", size = 88027282, upload-time = "2026-05-13T14:53:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/1c1eb00e34555b536dddf792676026a988d710ed36981aa00499b36b0620/torch-2.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:891c769072637c74e9a5a77a3bc782894696d8ffec83b938df8536dee7f0ba78", size = 426386961, upload-time = "2026-05-13T14:51:28.406Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d4/7e730dba0c7032a4154dc9056b76cf9625515e030e269cfbf8098fcfee7d/torch-2.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e2ad3eb85d39c3cab62dfa93ed5a73516e6a53c6713cb97d004004fe089f0f1f", size = 532272265, upload-time = "2026-05-13T14:51:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b4/92c80d1bbfee1c0036c06d1d2155a3065bd2423134c83bf8a47e65cd6b9b/torch-2.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:c66696857e987efb8bc1777a37357ec4f60ab5e8af6250b83d6034437fa2d8f3", size = 122987138, upload-time = "2026-05-13T14:51:45.942Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/2e12b37ce50a19a037d7bc62d652a5a8f27385a7b05859d6bc9204f20cfe/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", size = 88320100, upload-time = "2026-05-13T14:51:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/83c450ec7b0bb40a7b74611c1b5440f9260e33c54c90d556fd4a1f0fd955/torch-2.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a43ac605a5e13116c72b64c359644cce0229f213dde48d2ae0ae5eb5becf7feb", size = 426391871, upload-time = "2026-05-13T14:52:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e9/1a0b575d98d0afedd8f157d23fa3d2759421483660448e60d0a4b10b6daa/torch-2.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a7512adfdd7f6732e40de1c620831e3c75b39b98cef60b11d0c5f0a76473ec5", size = 532192241, upload-time = "2026-05-13T14:51:07.795Z" }, + { url = "https://files.pythonhosted.org/packages/88/21/afadd25ecd81b3cea1e11c73cf1ab41a983a50271548c3ec7ec3b9efc3e9/torch-2.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f96b63f8287f66a005dd1b5a6abba2920f11156c5e5c4d815f3e2050fd1aa16", size = 123231092, upload-time = "2026-05-13T14:51:18.854Z" }, ] [[package]] @@ -6471,6 +6375,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/b9/9dbdf76b2b49a75ba8088df6f7c755bdb520afb6c6dbac0102b46cde5e99/torchvision-0.27.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1c01f0d1091ae22b9dfc082b0a0fe5faaf053686a29b4fb082ba7691375c73cf", size = 7791430, upload-time = "2026-05-13T14:56:56.206Z" }, { url = "https://files.pythonhosted.org/packages/5c/6a/e4a16cf2f3310c2ea7760dc5d9054496844391e0f4c1fae87fefac2f3d9e/torchvision-0.27.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dadea3c5ecfd05bbb2a3312ab0374f213c58bf6459cb059122e2f4dfe13d10ed", size = 7668441, upload-time = "2026-05-13T14:57:02.127Z" }, { url = "https://files.pythonhosted.org/packages/00/70/01b6461117a6a94b5af3f8ee166bb0f045056f3cf187750c110dabfdfffa/torchvision-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a49e55055a39a8506fe7e59850522cab004efb2c3839f6057658889c1d69c815", size = 4141602, upload-time = "2026-05-13T14:56:53.449Z" }, + { url = "https://files.pythonhosted.org/packages/92/22/c0633677b3b3f3e69554a21ac087bf705f829c40cd5e3783507b8c006681/torchvision-0.27.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c1fac0fc2a7adf29481fc1938a0e7845c57ba1147a986784109c4d98f434ea8c", size = 1758818, upload-time = "2026-05-13T14:56:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/48/e8/55f9d9667b56dae470e69e31beac9b00d458ea393feec1aae95cc4f3f1c9/torchvision-0.27.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:cbf89764fc76f3f17fbf80c12d5a89c691e91cb9d82c38412aaf0568655ffb19", size = 7789667, upload-time = "2026-05-13T14:56:48.858Z" }, + { url = "https://files.pythonhosted.org/packages/00/bc/6f8681daf3bbc4c315bb0005110f99d28e3ecd675bf9c8f2c0d393fbac7a/torchvision-0.27.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:91f61b9865423037c327eb56afa207cc72de874e458c361840db9dcf5ce0c0eb", size = 7579848, upload-time = "2026-05-13T14:56:38.209Z" }, + { url = "https://files.pythonhosted.org/packages/19/6c/8d8020e6bd1e46c53e487c9c4e9457a07f2ee28931028fb5d71e2da40adc/torchvision-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:5bb82fc3c55daf1788621e504310b0a286f1069627a8742f692aebb075ef25a7", size = 4119284, upload-time = "2026-05-13T14:56:46.625Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7e/e78c48662a8d551606efdbe11c6b9c1d6d2391b92cd0e4591b9e6a2412b8/torchvision-0.27.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2c4099a15150143b9b034730b404a56d572efe0b79489b4c765d929cb4eac7f3", size = 1758828, upload-time = "2026-05-13T14:56:52.293Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/d03ee9f9ee7bf11a8c7c776fb8e7fd6102f59c013791a2a4e5175bd6cba7/torchvision-0.27.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b4c6bb0a670dcba017b3643e21902c9b8a1cc1c127d602f1488fa29ec3c6e865", size = 7790618, upload-time = "2026-05-13T14:56:44.721Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/4002336a74742be70728603ec1769feb2b55e0d19c532c9ec9f92008de76/torchvision-0.27.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1c2db4bde82bc48ebff73436a6adf34d4f809448268a70d9a1285f5c8f92313d", size = 7580217, upload-time = "2026-05-13T14:56:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cb/4dd4783eb3565f526ba6e64b6f6ca26c00eacc924cdfe60455db9d91b84b/torchvision-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:72bf547e58ddb948689734eed6f4b6a2031f979dba4fb08e3690688b392e929f", size = 4226392, upload-time = "2026-05-13T14:56:40.235Z" }, ] [[package]] @@ -6504,8 +6416,8 @@ wheels = [ [[package]] name = "tracksdata" -version = "0.1.0rc3.dev3+gb605d2d6a" -source = { git = "https://github.com/royerlab/tracksdata?rev=b605d2d6aa4bd12a31c07b499f5acf8411bac518#b605d2d6aa4bd12a31c07b499f5acf8411bac518" } +version = "0.1.0rc4" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, { name = "blosc2" }, @@ -6524,6 +6436,11 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tqdm" }, { name = "typing-extensions" }, + { name = "zarr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/54/87f53a7cb255d1d76e6340b4799a84cfcedcf83aaa76926836236a7af3f8/tracksdata-0.1.0rc4.tar.gz", hash = "sha256:7f1f66293369ab1cf9de1884ba302682e494babca138d21d04e90cc7ae4538bc", size = 223977, upload-time = "2026-05-29T00:36:47.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/19/c2f4e1f8f347b4d8fb1266d65461d012a8b2b0baddec23814749ee01df8a/tracksdata-0.1.0rc4-py3-none-any.whl", hash = "sha256:0db3477549b8324647aa86d0a76983ae4aee9e6a8943fbb4187169c66e08e6dc", size = 253578, upload-time = "2026-05-29T00:36:45.876Z" }, ] [[package]] @@ -6566,6 +6483,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/b1/b7507bb9815d403927c8dd51d4158ed2e11751a92dbc118a044f247b6848/triton-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a35d7afe3f3f058e7ec49fcce09794049e0ffc5c59019ac25ec3413741b8c4e7", size = 201453566, upload-time = "2026-05-07T18:46:20.427Z" }, { url = "https://files.pythonhosted.org/packages/a6/8f/0bea7a6a0c989315c9135a1d7fb37e41905cfb3a17cbc1f10044ebd4cc3a/triton-3.7.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d61c172d257db80ddf42595131fb196ad2e9bdd751e90fe2ef13531734e8b", size = 188612899, upload-time = "2026-05-07T19:05:24.955Z" }, { url = "https://files.pythonhosted.org/packages/e1/02/d96f57828d0912aec733b9bc7e0e7dbfd2c6f079a8fa433ac25cb93d1a30/triton-3.7.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70fb9bbdc9f400afc54bbf6eb2670af28829a6ae3996863317964783141daf56", size = 201553816, upload-time = "2026-05-07T18:46:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/82a802dac4689f2a2fb2e69302e6a138eecc3e175bbe976ba3cfc717683a/triton-3.7.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a44a8476d0d3571eac4e4d1048e1ff75aad81a09ff4602ccfc56c6dea1672e", size = 188507879, upload-time = "2026-05-07T19:05:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/8f/af/9904ec6d3c93d9b24e5ec360445bbdf758b7f00bfbeedb89cb0eb64eb8bb/triton-3.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b85e72968a9d8bba5ddb24e9b64aaabaf48affb042f2755cb7cfa92b7531ce", size = 201460637, upload-time = "2026-05-07T18:46:34.749Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/4835a8ea746b88727d8899f4e3ccce4f9cacb38abfc3bb0a638266c53111/triton-3.7.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a160de426fd99f92b0baf509045360afbd3bfaa0b4a5171dde800ec9f09684", size = 188608706, upload-time = "2026-05-07T19:05:39.218Z" }, + { url = "https://files.pythonhosted.org/packages/c1/68/fa86e5a39608000f645535b2c124920126327ab731f8c4fafd5b07ff8d4b/triton-3.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce061073102714b725f3660ec6939d94a1da7984b3aa99c921417cae273672f5", size = 201546766, upload-time = "2026-05-07T18:46:42.088Z" }, ] [[package]] @@ -6626,131 +6547,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] -[[package]] -name = "ultrack" -source = { editable = "../../../../../home/eduardo.hirata/repos/ultrack" } -dependencies = [ - { name = "blosc2" }, - { name = "click" }, - { name = "cloudpickle" }, - { name = "edt" }, - { name = "fastapi" }, - { name = "fasteners" }, - { name = "geff" }, - { name = "gurobipy" }, - { name = "higra" }, - { name = "httpx" }, - { name = "imagecodecs" }, - { name = "imageio" }, - { name = "magicgui" }, - { name = "mip" }, - { name = "napari" }, - { name = "numba" }, - { name = "numcodecs" }, - { name = "ome-zarr" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "psycopg2-binary" }, - { name = "psygnal" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pydot" }, - { name = "qtawesome" }, - { name = "qtpy" }, - { name = "rich" }, - { name = "scikit-image" }, - { name = "seaborn" }, - { name = "sqlalchemy" }, - { name = "toml" }, - { name = "torch" }, - { name = "urllib3" }, - { name = "uvicorn" }, - { name = "websockets" }, - { name = "zarr" }, -] - -[package.metadata] -requires-dist = [ - { name = "asv", marker = "extra == 'test'", specifier = ">=0.5.1" }, - { name = "autodoc-pydantic", marker = "extra == 'docs'" }, - { name = "blosc2", specifier = ">=2.2.0" }, - { name = "catboost", marker = "extra == 'ml'", specifier = ">=1.2.7" }, - { name = "click", specifier = ">=8.1.3" }, - { name = "cloudpickle", specifier = ">=3.0.0" }, - { name = "edt", specifier = ">=2.3.2" }, - { name = "fastapi", specifier = ">=0.109.2" }, - { name = "fasteners", specifier = ">=0.20" }, - { name = "furo", marker = "extra == 'docs'" }, - { name = "geff", specifier = ">=1.0.0" }, - { name = "gurobipy", specifier = ">=9.0.0,!=12.0.0" }, - { name = "higra", specifier = ">=0.6.10" }, - { name = "httpx", specifier = ">=0.26.0" }, - { name = "imagecodecs", specifier = ">=2023.3.16" }, - { name = "imageio", specifier = ">=2.28.0" }, - { name = "magicgui", specifier = ">=0.7.2" }, - { name = "mip", specifier = ">=1.16rc0" }, - { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" }, - { name = "napari", specifier = ">=0.4.18" }, - { name = "napari", extras = ["testing"], marker = "extra == 'test'", specifier = ">0.4.18" }, - { name = "nbsphinx", marker = "extra == 'docs'", specifier = ">=0.9.3" }, - { name = "numba", specifier = ">=0.57.0" }, - { name = "numcodecs" }, - { name = "ome-zarr", specifier = ">=0.9.0" }, - { name = "pandas", specifier = ">=2.0.1" }, - { name = "pillow", specifier = ">=10.0.0" }, - { name = "pre-commit", marker = "extra == 'test'", specifier = ">=3.2.2" }, - { name = "psycopg2-binary", specifier = ">=2.9.6" }, - { name = "psygnal", specifier = ">=0.9.0" }, - { name = "pyarrow", specifier = ">=16.1.0,<20" }, - { name = "pydantic", specifier = ">=2.11.0" }, - { name = "pydantic-settings" }, - { name = "pydot", specifier = ">=2.0.0" }, - { name = "pyqt5", marker = "extra == 'test'", specifier = ">=5.15.4" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=7.3.1" }, - { name = "pytest-qt", marker = "extra == 'test'", specifier = ">=4.2.0" }, - { name = "pytrackmate", marker = "extra == 'test'", specifier = ">=1.3.4" }, - { name = "qtawesome", specifier = ">=1.3.1" }, - { name = "qtpy" }, - { name = "rich", specifier = ">=13.3.5" }, - { name = "scikit-image", specifier = ">=0.23.0" }, - { name = "scikit-learn", marker = "extra == 'ml'", specifier = ">=1.6.0" }, - { name = "seaborn", specifier = ">=0.13.0" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = "==6.2.1" }, - { name = "sphinx-click", marker = "extra == 'docs'", specifier = ">=5.0.1,<6.0.0" }, - { name = "sphinx-copybutton", marker = "extra == 'docs'" }, - { name = "sphinx-gallery", marker = "extra == 'docs'", specifier = "==0.15.0" }, - { name = "sphinxcontrib-applehelp", marker = "extra == 'docs'", specifier = "==1.0.8" }, - { name = "sqlalchemy", specifier = ">=2.0.0" }, - { name = "toml", specifier = ">=0.10.2" }, - { name = "torch", specifier = ">=2.0.1" }, - { name = "urllib3" }, - { name = "uvicorn", specifier = ">=0.27.0.post1" }, - { name = "websockets", specifier = ">=12.0" }, - { name = "zarr", specifier = ">=3.0.0" }, -] -provides-extras = ["docs", "ml", "test"] - -[[package]] -name = "ultrack-td" -version = "0.0.1" -source = { editable = "../../../../../home/eduardo.hirata/repos/ultrack-td" } -dependencies = [ - { name = "numpy" }, - { name = "tracksdata" }, -] - -[package.metadata] -requires-dist = [ - { name = "edt", marker = "extra == 'test'" }, - { name = "numpy", specifier = ">=1.24.4" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "rustworkx", marker = "extra == 'test'" }, - { name = "tracksdata", specifier = ">=0.1.0rc0" }, - { name = "ultrack", marker = "extra == 'compatibility'" }, -] -provides-extras = ["compatibility", "test"] - [[package]] name = "umap-learn" version = "0.5.12" @@ -6786,19 +6582,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] -[[package]] -name = "uvicorn" -version = "0.49.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, -] - [[package]] name = "verspec" version = "0.1.0" @@ -6824,6 +6607,7 @@ dev = [ { name = "jupyterlab" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, ] doc = [ { name = "mike" }, @@ -6837,6 +6621,7 @@ jupyter = [ test = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -6853,6 +6638,7 @@ dev = [ { name = "jupyterlab", specifier = ">=4.5.3" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7" }, + { name = "pytest-xdist", specifier = ">=3.8" }, ] doc = [ { name = "mike", git = "https://github.com/squidfunk/mike.git" }, @@ -6866,6 +6652,7 @@ jupyter = [ test = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7" }, + { name = "pytest-xdist", specifier = ">=3.8" }, ] [[package]] @@ -6974,6 +6761,7 @@ dependencies = [ [package.optional-dependencies] celldiff = [ { name = "diffusers" }, + { name = "scipy" }, { name = "torchdiffeq" }, ] @@ -6993,6 +6781,7 @@ requires-dist = [ { name = "monai", specifier = ">=1.5.2" }, { name = "numpy", specifier = ">=2.4.1" }, { name = "pytorch-metric-learning" }, + { name = "scipy", marker = "extra == 'celldiff'" }, { name = "timm", specifier = ">=1.0.15" }, { name = "torch", specifier = ">=2.10" }, { name = "torchdiffeq", marker = "extra == 'celldiff'" }, @@ -7201,7 +6990,7 @@ requires-dist = [ { name = "umap-learn", marker = "extra == 'all'" }, { name = "umap-learn", marker = "extra == 'eval'" }, { name = "viscy-data", editable = "packages/viscy-data" }, - { name = "wandb" }, + { name = "wandb", specifier = ">=0.12.10" }, { name = "xarray" }, ] provides-extras = ["all", "anndata", "eval"] @@ -7222,31 +7011,6 @@ test = [ { name = "scikit-learn" }, ] -[[package]] -name = "vispy" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "freetype-py" }, - { name = "hsluv" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/2e/ef2697f963111cf1bc83568bbe55f262b7c7c8a72948a6e802a7c236f2c1/vispy-0.15.2.tar.gz", hash = "sha256:d52d10c0697f48990555cea2a2bad3f9f5a772391856fda364ea4bbc69fd075c", size = 2513383, upload-time = "2025-05-19T13:26:41.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/7e/31102425ea26bafab27bb5a675b499c57d29e72b1cc25fe3ae8facdd374e/vispy-0.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c8cbc22bc789f238423abadfcc3ba7589b85729be6b179b321720b511012fbee", size = 1478919, upload-time = "2025-05-19T13:26:16.962Z" }, - { url = "https://files.pythonhosted.org/packages/8d/63/a601b8f1e8e418d3821d7b4a465c2eb6695ab8eccadfce886b99ffdfe92a/vispy-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf995b86dcc1eab265fa2bf9a8a5ca5a225b86c3ed4ac63900f0c5e90d5e8100", size = 1471915, upload-time = "2025-05-19T13:26:18.441Z" }, - { url = "https://files.pythonhosted.org/packages/79/1f/ca0deb4a876148c0fa515bf03d03e1dfad4553be8e6f2ab51433c074310b/vispy-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0aa64fcab1cd1730a10a1c9188e9cef8aefe6d099a46f2f87e2458bcef719918", size = 1866848, upload-time = "2025-05-19T13:26:19.6Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/dc6f335e54877f5d26ad4e4aac8f49b2d6e7719dee3e08f363d882c8aba4/vispy-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d38228cedd23876cb2359ff568d523a97284d225259d450d5e5e2129e98eb1", size = 1870433, upload-time = "2025-05-19T13:26:20.794Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/5ffa34d7300c35e8423d51789d594d35eaa7256d210f9909afa9fdd0aa37/vispy-0.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:a84531c1a89f8b9d3992eb0d0ab74f84884f49d1f46a8be3755c809d7507cb01", size = 1468068, upload-time = "2025-05-19T13:26:22.073Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/6664b7f0d28e0008a28111bae847a1fa3eedd409458bf01ca45a6e99da6f/vispy-0.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ac46aab46e208b919dcf0bb26869bcd083a0a1a4927bcaf41631ba38c247639", size = 1477880, upload-time = "2025-05-19T13:26:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/cf/6f/b9b36f841c5ff7320764f64822e79df3fea8a2c92270cda7f3a634d9a031/vispy-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7e197a012503850a77d47177964d572edab964b2a8ea0f9d998c35b81325256c", size = 1471035, upload-time = "2025-05-19T13:26:24.554Z" }, - { url = "https://files.pythonhosted.org/packages/4c/af/b892a3c9b4be29755ce4d1fc17ecb8249446bdd0e17bb5b2a9cb39bcf0f4/vispy-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d9eff397ecdbaf0052baae0563caa9e276272d6dc78fbaab3f5e51dbf5f7f92", size = 1860412, upload-time = "2025-05-19T13:26:26.212Z" }, - { url = "https://files.pythonhosted.org/packages/e8/13/8997d96bdc0a81f331dfd41b368935d79e8ea2917840266567e6dc40d684/vispy-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13ee0cb95bbff6d7b235e1a8f96e620eee15ccb6d5ad74902427ab7e56dc8ab", size = 1865860, upload-time = "2025-05-19T13:26:27.928Z" }, - { url = "https://files.pythonhosted.org/packages/6e/37/abb30db1853b69aed4c32813cf312f301ec3641b8846193be9a6d892d607/vispy-0.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:f5955821e9b452980490706648a702da8cb2b82e762b6d6589e5872118301b84", size = 1467882, upload-time = "2025-05-19T13:26:29.608Z" }, -] - [[package]] name = "wandb" version = "0.27.0" @@ -7360,33 +7124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] - [[package]] name = "werkzeug" version = "3.1.8" @@ -7414,20 +7151,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] -[[package]] -name = "witty" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cython" }, - { name = "nanobind" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/55/8235436e9664dc824c07e9d50b4ff68088a7c3a53d4c971558616cb0fe63/witty-0.3.1.tar.gz", hash = "sha256:8ee71ba3321c77b64fd9d3a33c2f9ed2424cd7798ee22a4519687c2a88e0f108", size = 12198, upload-time = "2025-07-25T14:59:09.615Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/5e/82387eb68d7c9de328ae6ad0dbd12e3a6b71425f831f87e05738de509971/witty-0.3.1-py3-none-any.whl", hash = "sha256:e9bb379e06888a6857472f39e865e66ebb9b1523eb4725772f0b71f338e7dea6", size = 8845, upload-time = "2025-07-25T14:59:08.38Z" }, -] - [[package]] name = "wrapt" version = "2.2.1" @@ -7467,6 +7190,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, ] @@ -7529,6 +7274,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] From ad467d94ec235faab00ad50aafc73f203b5f8fea Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:25:55 -0700 Subject: [PATCH 25/39] fix(viscy-utils): declare torchvision, which evaluation.metrics imports viscy_utils.evaluation.metrics imports torchvision.ops at module level, and losses.MixedLoss imports that module, but viscy-utils never listed torchvision. Installs only worked because another workspace member pulled it in. The new test-utils CI job syncs viscy-utils alone and failed to collect six test modules with ModuleNotFoundError. The lock gains only the dependency edge; no version moves. Co-Authored-By: Claude Opus 5.5 --- packages/viscy-utils/pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/viscy-utils/pyproject.toml b/packages/viscy-utils/pyproject.toml index 10e46f85f..42c56feb0 100644 --- a/packages/viscy-utils/pyproject.toml +++ b/packages/viscy-utils/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "tensorboard", "tensorstore", "torch>=2.10", + "torchvision", "viscy-data", "wandb>=0.12.10", "xarray", diff --git a/uv.lock b/uv.lock index 9356f4a71..bfd8cdd5a 100644 --- a/uv.lock +++ b/uv.lock @@ -6921,6 +6921,7 @@ dependencies = [ { name = "tensorboard" }, { name = "tensorstore" }, { name = "torch" }, + { name = "torchvision" }, { name = "viscy-data" }, { name = "wandb" }, { name = "xarray" }, @@ -6987,6 +6988,7 @@ requires-dist = [ { name = "tensorboard" }, { name = "tensorstore" }, { name = "torch", specifier = ">=2.10" }, + { name = "torchvision" }, { name = "umap-learn", marker = "extra == 'all'" }, { name = "umap-learn", marker = "extra == 'eval'" }, { name = "viscy-data", editable = "packages/viscy-data" }, From 0da2de68f6e75b535f1acbc33b0506eda0fb2384 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:26:10 -0700 Subject: [PATCH 26/39] ci(dynacell): keep main's batch-launcher test and the writer tests out Two corrections to the test-dynacell-configs job: - main's tests/test_submit_benchmark_batch.py resolves leaves under /hpc and fails on a runner (5 PermissionErrors in CI). The dynacell-models version that runs there arrives with the DynaCell PR, which adds it back. - the four viscy-utils test files (prediction writer, prediction metadata, monitor health, LR cooldown) now run in the test-utils job on both Python versions, so the dynacell job no longer repeats them or syncs viscy-utils' test group for them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1d5dcbef..9ae69745d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -166,8 +166,7 @@ jobs: # (falls back to numpy/scikit-image without CUDA), so the eval extra # installs and runs on a GPU-less runner. eval_gpu (cupy/cucim) is NOT # installed — those are the only CUDA-only deps. - # Include viscy-utils' test group for the prediction-writer fixtures. - run: uv sync --frozen --package dynacell --package viscy-utils --extra eval --group test + run: uv sync --frozen --package dynacell --extra eval --group test working-directory: applications/dynacell - name: Run benchmark configs and pipeline regressions @@ -180,15 +179,10 @@ jobs: # segmenter + feature-extractor model loads — but pipeline import still # needs the eval stack). test_evaluation_grouped drives the # multi-condition driver against the same cache-only fixture. - # - # test_submit_benchmark_batch covers the OTHER two srun emitters (array - # + chained-serial), which drifted out of sync with the single-job - # template because only the latter was under test. run: | uv run --frozen --no-sync pytest \ tests/test_benchmark_config_composition.py \ tests/test_submit_benchmark_job.py \ - tests/test_submit_benchmark_batch.py \ tests/test_runtime.py \ tests/test_evaluation_pipeline_parallel.py \ tests/test_evaluation_pipeline_parallel_cpu.py \ @@ -196,10 +190,6 @@ jobs: tests/test_engine.py \ tests/test_pipeline_cache.py \ src/dynacell/evaluation/feature_metrics_test.py \ - ../../packages/viscy-utils/tests/test_prediction_writer.py \ - ../../packages/viscy-utils/tests/test_prediction_metadata.py \ - ../../packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py \ - ../../packages/viscy-utils/src/viscy_utils/callbacks/linear_lr_cooldown_test.py \ -v working-directory: applications/dynacell From 3b8fd66acaa6e0b70354247ff16afc6d915e2ed8 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:40:07 -0700 Subject: [PATCH 27/39] refactor(viscy-utils): share the fit-callbacks lookup between CLI injectors _inject_checkpoint_guardrails and _inject_optimizer_health_guard each repeated the same walk from the config to fit's trainer.callbacks plus the already-injected check; the `else config` branch of that walk could never run behind the `subcommand != "fit"` return. Both now call _fit_callbacks_without. _callback_field collapses its two identical branches, and the monitored checkpoint's init_args are kept from the search loop instead of being looked up twice. No behavior change. Co-Authored-By: Claude Opus 5.5 --- packages/viscy-utils/src/viscy_utils/cli.py | 69 ++++++++++----------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/cli.py b/packages/viscy-utils/src/viscy_utils/cli.py index 4329daeff..eb282404b 100644 --- a/packages/viscy-utils/src/viscy_utils/cli.py +++ b/packages/viscy-utils/src/viscy_utils/cli.py @@ -77,11 +77,7 @@ def _configure_wandb_logger( def _callback_field(callback, key: str): """Return ``key`` from a callback config entry (Namespace or dict), else None.""" - if isinstance(callback, Namespace): - return callback.get(key) - if isinstance(callback, dict): - return callback.get(key) - return None + return callback.get(key) if isinstance(callback, (Namespace, dict)) else None def _init_args_of(callback) -> Namespace | dict | None: @@ -90,6 +86,29 @@ def _init_args_of(callback) -> Namespace | dict | None: return init_args if isinstance(init_args, (Namespace, dict)) else None +def _fit_callbacks_without(config: Namespace, subcommand: str | None, class_path: str) -> list | None: + """Return the ``fit`` trainer's mutable callback list, or None to skip injecting. + + None when the subcommand is not ``fit``, when the resolved config has no + ``trainer.callbacks`` list, or when a ``class_path`` callback is already there, + which keeps each injection idempotent. + """ + if subcommand != "fit": + return None + root = config.get(subcommand) + if not isinstance(root, Namespace): + return None + trainer = root.get("trainer") + if not isinstance(trainer, Namespace): + return None + callbacks = trainer.get("callbacks") + if not isinstance(callbacks, list): + return None + if any(_callback_field(callback, "class_path") == class_path for callback in callbacks): + return None + return callbacks + + def _inject_checkpoint_guardrails(config: Namespace, subcommand: str | None) -> None: """Guarantee latest-weights checkpoints and fail loud on a dead monitor. @@ -118,35 +137,22 @@ def _inject_checkpoint_guardrails(config: Namespace, subcommand: str | None) -> leaves override ``trainer.callbacks`` wholesale to set their own ``dirpath`` — a recipe-level default would be silently dropped by every real leaf. """ - if subcommand != "fit": - return - root = config.get(subcommand) if subcommand is not None else config - if not isinstance(root, Namespace): - return - trainer = root.get("trainer") - if not isinstance(trainer, Namespace): - return - callbacks = trainer.get("callbacks") - if not isinstance(callbacks, list): - return - # Idempotent: a config that already carries the guardrails is left alone. - for callback in callbacks: - if _callback_field(callback, "class_path") == _MONITOR_HEALTH_CLASS_PATH: - return + callbacks = _fit_callbacks_without(config, subcommand, _MONITOR_HEALTH_CLASS_PATH) + if callbacks is None: + return - monitored = None + monitored_args = None for callback in callbacks: if _callback_field(callback, "class_path") != _MODEL_CHECKPOINT_CLASS_PATH: continue init_args = _init_args_of(callback) if init_args is not None and init_args.get("monitor"): - monitored = callback + monitored_args = init_args break - if monitored is None: + if monitored_args is None: return - monitored_args = _init_args_of(monitored) monitor = monitored_args.get("monitor") dirpath = monitored_args.get("dirpath") # Hand `last.ckpt` to the unconditional callback so it tracks the newest epoch. @@ -200,20 +206,9 @@ def _inject_optimizer_health_guard(config: Namespace, subcommand: str | None) -> declaring a monitored ``ModelCheckpoint`` — a run with no checkpointing at all can still stall — so it only requires a ``trainer.callbacks`` list to append to. """ - if subcommand != "fit": - return - root = config.get(subcommand) if subcommand is not None else config - if not isinstance(root, Namespace): - return - trainer = root.get("trainer") - if not isinstance(trainer, Namespace): + callbacks = _fit_callbacks_without(config, subcommand, _OPTIMIZER_HEALTH_CLASS_PATH) + if callbacks is None: return - callbacks = trainer.get("callbacks") - if not isinstance(callbacks, list): - return - for callback in callbacks: - if _callback_field(callback, "class_path") == _OPTIMIZER_HEALTH_CLASS_PATH: - return callbacks.append(Namespace(class_path=_OPTIMIZER_HEALTH_CLASS_PATH)) From 149b5a9f7c0f09d241b837f00a96044800691c92 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:40:10 -0700 Subject: [PATCH 28/39] refactor(viscy-utils): drop MonitorHealthCheck's unused check_finite flag Nothing sets check_finite (the CLI injects monitor and patience only, and no config or test passes it), so the callback always checks finiteness; it now does so with math.isfinite instead of building a tensor for one float. Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_utils/callbacks/monitor_health.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py index 66cb77705..c4b9479cf 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health.py @@ -14,6 +14,7 @@ checkpoint a run considers "best". """ +import math from collections import deque import torch @@ -37,8 +38,6 @@ class MonitorHealthCheck(Callback): patience : int Number of consecutive validation epochs that must share one identical value before the run is declared frozen. Must be >= 2. - check_finite : bool - Raise as soon as the monitored value is NaN or infinite. Raises ------ @@ -56,12 +55,11 @@ class MonitorHealthCheck(Callback): legitimately absent on the first epochs of some schedules. """ - def __init__(self, monitor: str = "loss/validate", patience: int = 10, check_finite: bool = True) -> None: + def __init__(self, monitor: str = "loss/validate", patience: int = 10) -> None: if patience < 2: raise ValueError(f"patience must be >= 2 to detect a frozen metric, got {patience}") self.monitor = monitor self.patience = patience - self.check_finite = check_finite self._recent: deque[float] = deque(maxlen=patience) def on_validation_end(self, trainer: Trainer, pl_module: LightningModule) -> None: @@ -73,7 +71,7 @@ def on_validation_end(self, trainer: Trainer, pl_module: LightningModule) -> Non return scalar = value.item() if isinstance(value, torch.Tensor) else float(value) - if self.check_finite and not torch.isfinite(torch.tensor(scalar)): + if not math.isfinite(scalar): raise MonitorHealthError( f"checkpoint monitor {self.monitor!r} is non-finite ({scalar}) at epoch {trainer.current_epoch}. " "ModelCheckpoint cannot rank a non-finite metric, so no further checkpoint will be written " From e87ac87c21af22579783a645b077ec151d0d668f Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:40:13 -0700 Subject: [PATCH 29/39] docs(viscy-utils): stop describing fg masks as built from Otsu thresholds generate_fg_masks does not read the stored otsu_threshold; it binarizes a CLAHE-equalized, median-filtered volume. Its summary line and VisCyTrainer.preprocess's compute_fg_masks entry still said "from Otsu thresholds". Co-Authored-By: Claude Opus 5.5 --- packages/viscy-utils/src/viscy_utils/meta_utils.py | 2 +- packages/viscy-utils/src/viscy_utils/trainer.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/meta_utils.py b/packages/viscy-utils/src/viscy_utils/meta_utils.py index ce949bcae..eff4bbe94 100644 --- a/packages/viscy-utils/src/viscy_utils/meta_utils.py +++ b/packages/viscy-utils/src/viscy_utils/meta_utils.py @@ -418,7 +418,7 @@ def generate_fg_masks( channel_names, fg_mask_key="fg_mask", ): - """Precompute binary foreground masks from Otsu thresholds. + """Precompute binary foreground masks with the CLAHE, median and Otsu recipe. For each FOV and specified channel, loads the full-resolution image one timepoint at a time and binarizes it with diff --git a/packages/viscy-utils/src/viscy_utils/trainer.py b/packages/viscy-utils/src/viscy_utils/trainer.py index dfa2dbe8e..1139dab53 100644 --- a/packages/viscy-utils/src/viscy_utils/trainer.py +++ b/packages/viscy-utils/src/viscy_utils/trainer.py @@ -47,8 +47,9 @@ def preprocess( Whether to compute Otsu thresholds for Spotlight loss, by default False. compute_fg_masks : bool, optional - Whether to precompute binary foreground masks from Otsu - thresholds, by default False. Requires ``compute_otsu=True``. + Whether to precompute binary foreground masks (see + :func:`~viscy_utils.meta_utils.generate_fg_masks`), by default + False. Requires ``compute_otsu=True``. fg_mask_channels : list[str] or None, optional Channel names to compute FG masks for. Defaults to all channels that had Otsu thresholds computed (``channel_names``). From 5467d3a95e538b091c4e6608c52452c7830f6915 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:40:16 -0700 Subject: [PATCH 30/39] build: leave the hf_demo pytest path to the DynaCell PR Nothing on this branch imports from applications/dynacell/examples/ hf_demo: its only test, build_demo_data_test.py, arrives with the DynaCell PR, which adds the pythonpath entry back with it. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4d6138e55..134d60ef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ lint.pydocstyle.convention = "numpy" minversion = "9.0" testpaths = [ "packages/*/tests", "applications/*/tests" ] addopts = [ "-ra", "-q", "--import-mode=importlib", "-m", "not slow" ] -pythonpath = [ "applications/dynacell/tools", "applications/dynacell/examples/hf_demo" ] +pythonpath = [ "applications/dynacell/tools" ] # Set as an ini option, NOT as --strict-markers in addopts. pytest >= 9 backs the # flag with this ini option and only honours the flag from the command line — in # addopts it parses (config.option.strict_markers reads True) but never enforces, From a871e4eea5a7e6dfb700a22fec6ca0f47b881e50 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:51:31 -0700 Subject: [PATCH 31/39] fix(viscy-utils): guard a fit that sets no callbacks Trainer.callbacks defaults to None, and the CLI injectors returned early unless they found a list, so a fit that set no callbacks ran without OptimizerHealthCheck, the guard meant for every fit. The shared lookup now gives such a fit an empty list to append to (None and [] mean the same to Trainer). The checkpoint guardrails still need a monitored ModelCheckpoint and remain a no-op there. Reported by Copilot on #533. Co-Authored-By: Claude Opus 5.5 --- .../viscy_utils/callbacks/optimizer_health_test.py | 8 ++++++++ packages/viscy-utils/src/viscy_utils/cli.py | 11 +++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py index c5d0a1023..e2524e4f6 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health_test.py @@ -215,6 +215,14 @@ def test_guard_is_injected_into_a_fit_config(): assert paths == ["viscy_utils.callbacks.OptimizerHealthCheck"] +def test_guard_is_injected_into_a_fit_without_callbacks(): + """A fit that leaves ``trainer.callbacks`` unset still gets the guard.""" + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=None))) + _inject_optimizer_health_guard(config, "fit") + paths = [callback.class_path for callback in config.fit.trainer.callbacks] + assert paths == ["viscy_utils.callbacks.OptimizerHealthCheck"] + + def test_guard_injection_is_idempotent(): """Re-running the injector (e.g. a resumed resolved config) must not duplicate it.""" config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[]))) diff --git a/packages/viscy-utils/src/viscy_utils/cli.py b/packages/viscy-utils/src/viscy_utils/cli.py index eb282404b..94d708aa5 100644 --- a/packages/viscy-utils/src/viscy_utils/cli.py +++ b/packages/viscy-utils/src/viscy_utils/cli.py @@ -89,9 +89,10 @@ def _init_args_of(callback) -> Namespace | dict | None: def _fit_callbacks_without(config: Namespace, subcommand: str | None, class_path: str) -> list | None: """Return the ``fit`` trainer's mutable callback list, or None to skip injecting. - None when the subcommand is not ``fit``, when the resolved config has no - ``trainer.callbacks`` list, or when a ``class_path`` callback is already there, - which keeps each injection idempotent. + A fit whose ``trainer.callbacks`` is unset (``None``) gets an empty list to + append to. None when the subcommand is not ``fit``, when the config has no + ``trainer`` section, or when a ``class_path`` callback is already there, which + keeps each injection idempotent. """ if subcommand != "fit": return None @@ -102,6 +103,8 @@ def _fit_callbacks_without(config: Namespace, subcommand: str | None, class_path if not isinstance(trainer, Namespace): return None callbacks = trainer.get("callbacks") + if callbacks is None: + callbacks = trainer["callbacks"] = [] if not isinstance(callbacks, list): return None if any(_callback_field(callback, "class_path") == class_path for callback in callbacks): @@ -204,7 +207,7 @@ def _inject_optimizer_health_guard(config: Namespace, subcommand: str | None) -> Unlike :func:`_inject_checkpoint_guardrails` this does not depend on the leaf declaring a monitored ``ModelCheckpoint`` — a run with no checkpointing at all - can still stall — so it only requires a ``trainer.callbacks`` list to append to. + can still stall — so it also covers a fit that sets no callbacks. """ callbacks = _fit_callbacks_without(config, subcommand, _OPTIMIZER_HEALTH_CLASS_PATH) if callbacks is None: From f094b0f032f9e818f921cae2e9c9133b992c22b0 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 16:51:37 -0700 Subject: [PATCH 32/39] docs(viscy-utils): say what the AMP skip counter actually counts The counter resets only when GradScaler grows its scale, so successful optimizer steps can sit between the reductions it counts; the error message claimed every step was skipped "in a row". Describe it as reductions without growth in between, which is what it detects: the backward overflowing faster than growth_interval clean steps can restore the scale. Behavior unchanged. Reported by Copilot on #533. Co-Authored-By: Claude Opus 5.5 --- .../viscy_utils/callbacks/optimizer_health.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py index 432e33439..a0b7a8dda 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/optimizer_health.py @@ -50,9 +50,12 @@ class OptimizerHealthCheck(Callback): scaler is shrinking gradients rather than protecting them, which no healthy run needs. Ignored when the run has no scaler. max_consecutive_skips : int - Raise after this many consecutive scale *reductions* with no growth in - between. Each reduction is one skipped optimizer step. A handful during - AMP warmup is normal and expected; a couple of dozen in a row is not. + Raise after this many scale *reductions* with no growth in between. Each + reduction is one skipped optimizer step; successful steps may separate + them, but the scale only grows after ``growth_interval`` of those in a + row, so a run of reductions means the backward keeps overflowing faster + than the scale can recover. A handful during AMP warmup is normal and + expected; a couple of dozen is not. max_consecutive_nonfinite : int Raise after this many consecutive training batches whose loss is NaN or infinite. Small but non-zero so a single transient overflow does not kill @@ -139,9 +142,10 @@ def _check_scale(self, trainer: Trainer) -> None: ) if self._consecutive_skips >= self.max_consecutive_skips: raise OptimizerHealthError( - f"GradScaler halved its scale {self._consecutive_skips} times in a row without growing " - f"(now {scale}, step {trainer.global_step}). Every one of those optimizer steps was skipped " - "for a non-finite gradient, so the weights are not moving even though the loss is finite." + f"GradScaler halved its scale {self._consecutive_skips} times without growing in between " + f"(now {scale}, step {trainer.global_step}). Each halving skipped an optimizer step for a " + "non-finite gradient, and the scale never recovered, so the backward keeps overflowing even " + "though the loss is finite." ) def state_dict(self) -> dict: From 657c9763c8010f3b7da458867b76b1684c6549b1 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 17:33:00 -0700 Subject: [PATCH 33/39] fix(viscy-utils): resolve viscy_utils.callbacks names lazily VisCyCLI now injects MonitorHealthCheck and OptimizerHealthCheck into every fit by class path through viscy_utils.callbacks, whose __init__ eagerly imported OnlineEvalCallback and with it scikit-learn, which only the eval extra installs. A fit on a base install would therefore fail before training. The package now resolves its exports lazily (PEP 562), like viscy_utils itself. The new test blocks sklearn and imports the health callbacks: the eager __init__ fails it with ModuleNotFoundError, the lazy one passes. Reported by Copilot on #533. Co-Authored-By: Claude Opus 5.5 --- .../src/viscy_utils/callbacks/__init__.py | 45 +++++++++++-------- .../viscy-utils/tests/test_lazy_imports.py | 19 ++++++++ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py b/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py index aba381445..14eef220b 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/__init__.py @@ -1,19 +1,28 @@ -from viscy_utils.callbacks.embedding_snapshot import EmbeddingSnapshotCallback -from viscy_utils.callbacks.embedding_writer import EmbeddingWriter -from viscy_utils.callbacks.linear_lr_cooldown import LinearLRCooldown -from viscy_utils.callbacks.monitor_health import MonitorHealthCheck, MonitorHealthError -from viscy_utils.callbacks.online_eval import OnlineEvalCallback -from viscy_utils.callbacks.optimizer_health import OptimizerHealthCheck, OptimizerHealthError -from viscy_utils.callbacks.prediction_writer import HCSPredictionWriter +import importlib -__all__ = [ - "EmbeddingSnapshotCallback", - "EmbeddingWriter", - "LinearLRCooldown", - "MonitorHealthCheck", - "MonitorHealthError", - "OnlineEvalCallback", - "OptimizerHealthCheck", - "OptimizerHealthError", - "HCSPredictionWriter", -] +# Resolved lazily (PEP 562). VisCyCLI injects the health callbacks into every fit +# through this package, and OnlineEvalCallback needs scikit-learn, which only the +# ``eval`` extra installs, so an eager import would break fits on a base install. +_LAZY_ATTRS = { + "EmbeddingSnapshotCallback": "viscy_utils.callbacks.embedding_snapshot", + "EmbeddingWriter": "viscy_utils.callbacks.embedding_writer", + "LinearLRCooldown": "viscy_utils.callbacks.linear_lr_cooldown", + "MonitorHealthCheck": "viscy_utils.callbacks.monitor_health", + "MonitorHealthError": "viscy_utils.callbacks.monitor_health", + "OnlineEvalCallback": "viscy_utils.callbacks.online_eval", + "OptimizerHealthCheck": "viscy_utils.callbacks.optimizer_health", + "OptimizerHealthError": "viscy_utils.callbacks.optimizer_health", + "HCSPredictionWriter": "viscy_utils.callbacks.prediction_writer", +} + +__all__ = list(_LAZY_ATTRS) + + +def __getattr__(name: str): + if name not in _LAZY_ATTRS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(importlib.import_module(_LAZY_ATTRS[name]), name) + + +def __dir__() -> list[str]: + return sorted([*globals(), *__all__]) diff --git a/packages/viscy-utils/tests/test_lazy_imports.py b/packages/viscy-utils/tests/test_lazy_imports.py index 85036d8d4..16628b2db 100644 --- a/packages/viscy-utils/tests/test_lazy_imports.py +++ b/packages/viscy-utils/tests/test_lazy_imports.py @@ -4,6 +4,7 @@ import pytest import viscy_utils +import viscy_utils.callbacks # Submodules that evaluation code imports without training anything. LIGHT_SUBMODULES = [ @@ -24,11 +25,29 @@ def test_light_submodules_skip_torch_and_lightning(): assert result.stdout.strip() == "[]" +def test_health_callbacks_import_without_the_eval_extra(): + """The callbacks VisCyCLI injects into every fit must import without scikit-learn. + + ``sys.modules["sklearn"] = None`` makes every sklearn import fail, as on a base + install; OnlineEvalCallback imports it at module level. + """ + code = ( + "import sys; sys.modules['sklearn'] = None; " + "from viscy_utils.callbacks import MonitorHealthCheck, OptimizerHealthCheck" + ) + subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True) + + @pytest.mark.parametrize("name", viscy_utils.__all__) def test_public_names_importable(name): exec(f"from viscy_utils import {name}", {}) +@pytest.mark.parametrize("name", viscy_utils.callbacks.__all__) +def test_public_callbacks_importable(name): + exec(f"from viscy_utils.callbacks import {name}", {}) + + def test_unknown_attribute_raises(): with pytest.raises(AttributeError): viscy_utils.not_a_name # noqa: B018 From 95744120845ef6e40df5a279d1d2eb3eedee0721 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 18:11:36 -0700 Subject: [PATCH 34/39] docs(cytoland): install viscy-data from GitHub in the VCP tutorials The tutorials install viscy-utils from the GitHub monorepo and let the siblings resolve from PyPI. The writer they predict with now reads SlidingWindowDataset.z_planes, which the PyPI viscy-data 0.1.0 does not have (checked in its wheel), so predict would fail with AttributeError in on_predict_start. Install viscy-data from GitHub too, in the .py sources and their paired notebooks. Found by /code-review on #533. Co-Authored-By: Claude Opus 5.5 --- .../cytoland/examples/vcp_tutorials/hek293t.ipynb | 6 +++--- applications/cytoland/examples/vcp_tutorials/hek293t.py | 6 +++--- .../cytoland/examples/vcp_tutorials/neuromast.ipynb | 6 +++--- applications/cytoland/examples/vcp_tutorials/neuromast.py | 6 +++--- .../cytoland/examples/vcp_tutorials/quick_start.ipynb | 8 ++++---- .../cytoland/examples/vcp_tutorials/quick_start.py | 8 ++++---- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb b/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb index ce81bebf1..35e5b4401 100644 --- a/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb +++ b/applications/cytoland/examples/vcp_tutorials/hek293t.ipynb @@ -89,10 +89,10 @@ "outputs": [], "source": [ "# Install the modular Cytoland/VisCy packages required for this example\n", - "# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from\n", - "# the GitHub monorepo (their siblings resolve from PyPI automatically).\n", + "# `cytoland`, `viscy-utils` and `viscy-data` are installed from the GitHub\n", + "# monorepo: the prediction writer needs a viscy-data newer than its PyPI release.\n", "# Here stackview and ipycanvas are installed for visualization\n", - "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" stackview ipycanvas==0.11" + "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"viscy-data @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-data\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" stackview ipycanvas==0.11" ] }, { diff --git a/applications/cytoland/examples/vcp_tutorials/hek293t.py b/applications/cytoland/examples/vcp_tutorials/hek293t.py index 2d66762f6..2ef77b901 100644 --- a/applications/cytoland/examples/vcp_tutorials/hek293t.py +++ b/applications/cytoland/examples/vcp_tutorials/hek293t.py @@ -56,10 +56,10 @@ # %% # Install the modular Cytoland/VisCy packages required for this example -# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from -# the GitHub monorepo (their siblings resolve from PyPI automatically). +# `cytoland`, `viscy-utils` and `viscy-data` are installed from the GitHub +# monorepo: the prediction writer needs a viscy-data newer than its PyPI release. # Here stackview and ipycanvas are installed for visualization -# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" stackview ipycanvas==0.11 +# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "viscy-data @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-data" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" stackview ipycanvas==0.11 # %% # Restart kernel if running in Google Colab diff --git a/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb b/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb index e66db361d..ec3c526c6 100644 --- a/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb +++ b/applications/cytoland/examples/vcp_tutorials/neuromast.ipynb @@ -97,10 +97,10 @@ "outputs": [], "source": [ "# Install the modular Cytoland/VisCy packages required for this example\n", - "# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from\n", - "# the GitHub monorepo (their siblings resolve from PyPI automatically).\n", + "# `cytoland`, `viscy-utils` and `viscy-data` are installed from the GitHub\n", + "# monorepo: the prediction writer needs a viscy-data newer than its PyPI release.\n", "# Here stackview and ipycanvas are installed for visualization, and iohub provides OME-Zarr access\n", - "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" iohub stackview ipycanvas" + "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"viscy-data @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-data\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" iohub stackview ipycanvas" ] }, { diff --git a/applications/cytoland/examples/vcp_tutorials/neuromast.py b/applications/cytoland/examples/vcp_tutorials/neuromast.py index d88ab659c..9a11678a8 100644 --- a/applications/cytoland/examples/vcp_tutorials/neuromast.py +++ b/applications/cytoland/examples/vcp_tutorials/neuromast.py @@ -64,10 +64,10 @@ # %% # Install the modular Cytoland/VisCy packages required for this example -# `cytoland` and `viscy-utils` are not yet on PyPI, so they are installed from -# the GitHub monorepo (their siblings resolve from PyPI automatically). +# `cytoland`, `viscy-utils` and `viscy-data` are installed from the GitHub +# monorepo: the prediction writer needs a viscy-data newer than its PyPI release. # Here stackview and ipycanvas are installed for visualization, and iohub provides OME-Zarr access -# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" iohub stackview ipycanvas +# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "viscy-data @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-data" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" iohub stackview ipycanvas # %% # Restart kernel if running in Google Colab diff --git a/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb b/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb index 5c4726fb2..81f58a6da 100644 --- a/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb +++ b/applications/cytoland/examples/vcp_tutorials/quick_start.ipynb @@ -119,10 +119,10 @@ "outputs": [], "source": [ "# Install the modular packages required for this example\n", - "# `cytoland` and `viscy-utils` are not yet published to PyPI, so they are\n", - "# installed from the GitHub monorepo; their siblings (viscy-data, viscy-models,\n", - "# viscy-transforms) resolve from PyPI automatically.\n", - "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" torchview cmap iohub" + "# `cytoland`, `viscy-utils` and `viscy-data` are installed from the GitHub\n", + "# monorepo: the prediction writer needs a viscy-data newer than its PyPI\n", + "# release. viscy-models and viscy-transforms resolve from PyPI.\n", + "!pip install \"viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils\" \"viscy-data @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-data\" \"cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland\" torchview cmap iohub" ] }, { diff --git a/applications/cytoland/examples/vcp_tutorials/quick_start.py b/applications/cytoland/examples/vcp_tutorials/quick_start.py index 7f1dc3c74..a40e45d9e 100644 --- a/applications/cytoland/examples/vcp_tutorials/quick_start.py +++ b/applications/cytoland/examples/vcp_tutorials/quick_start.py @@ -87,10 +87,10 @@ # %% # Install the modular packages required for this example -# `cytoland` and `viscy-utils` are not yet published to PyPI, so they are -# installed from the GitHub monorepo; their siblings (viscy-data, viscy-models, -# viscy-transforms) resolve from PyPI automatically. -# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" torchview cmap iohub +# `cytoland`, `viscy-utils` and `viscy-data` are installed from the GitHub +# monorepo: the prediction writer needs a viscy-data newer than its PyPI +# release. viscy-models and viscy-transforms resolve from PyPI. +# !pip install "viscy-utils @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-utils" "viscy-data @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=packages/viscy-data" "cytoland @ git+https://github.com/mehta-lab/VisCy.git#subdirectory=applications/cytoland" torchview cmap iohub # %% # restart kernel if running in Google Colab From a169ed7c52a5ef7d534aceb351a82bd65b8c02ff Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 18:14:08 -0700 Subject: [PATCH 35/39] fix(viscy-utils): return an empty CLAHE result when the clip range is zero foreground_mask_from_volume guards a constant volume, but a blank volume with a few hot pixels (under 0.2% of voxels) has equal 0.1/99.9 percentiles too, and equalize_clahe then divided by zero and fed NaN to CLAHE (RuntimeWarning: divide by zero, reproduced on a (20, 64, 64) volume). equalize_clahe now returns zeros there, which the mask's existing flat-histogram guard turns into an empty mask, as for a constant volume. Volumes with any contrast are unchanged. Found by /code-review on #533. Co-Authored-By: Claude Opus 5.5 --- packages/viscy-utils/src/viscy_utils/meta_utils.py | 4 ++++ packages/viscy-utils/tests/test_meta_utils.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/viscy-utils/src/viscy_utils/meta_utils.py b/packages/viscy-utils/src/viscy_utils/meta_utils.py index eff4bbe94..d7f924acb 100644 --- a/packages/viscy-utils/src/viscy_utils/meta_utils.py +++ b/packages/viscy-utils/src/viscy_utils/meta_utils.py @@ -211,6 +211,10 @@ def equalize_clahe(array, tile_shape=_CLAHE_TILE, clip_limit=_CLAHE_CLIP_LIMIT, input, so a threshold taken here is not comparable to a raw one. """ lo, hi = np.percentile(array, (_CLAHE_PERCENTILE, 100 - _CLAHE_PERCENTILE)) + if hi <= lo: + # No contrast between the clip percentiles (e.g. a blank timepoint with a + # few hot pixels): nothing to equalize, and the rescale would divide by 0. + return np.zeros(array.shape, dtype=np.float32) scaled = np.clip((array - lo) / (hi - lo), 0.0, 1.0).astype(np.float32) n_tiles = tuple(max(int(dim // tile), 1) for dim, tile in zip(array.shape, tile_shape)) kernel = tuple(int(dim // n) for dim, n in zip(array.shape, n_tiles)) diff --git a/packages/viscy-utils/tests/test_meta_utils.py b/packages/viscy-utils/tests/test_meta_utils.py index fb8646052..6a56ddd83 100644 --- a/packages/viscy-utils/tests/test_meta_utils.py +++ b/packages/viscy-utils/tests/test_meta_utils.py @@ -219,3 +219,13 @@ def test_constant_volume_gives_an_empty_mask_without_warnings(): warnings.simplefilter("error") mask = foreground_mask_from_volume(np.full((4, 64, 64), 3.0, dtype=np.float32)) assert mask.dtype == bool and mask.shape == (4, 64, 64) and not mask.any() + + +def test_near_constant_volume_gives_an_empty_mask_without_warnings(): + """A few hot pixels in a blank volume leave the clip percentiles equal: still empty, no NaN.""" + volume = np.zeros((20, 64, 64), dtype=np.float32) + volume.flat[::5000] = 100.0 + with warnings.catch_warnings(): + warnings.simplefilter("error") + mask = foreground_mask_from_volume(volume) + assert not mask.any() From 6df8ebee8d41b5b622d9de088f69826d63bc9c28 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 18:14:09 -0700 Subject: [PATCH 36/39] fix(viscy-utils): keep a step-cadence leaf's last.ckpt on its step trigger The injected latest-weights ModelCheckpoint takes over save_last from the monitored one but copied only every_n_epochs (defaulting to 1). A leaf that checkpoints every N steps, typical for max_steps runs with long epochs, would get last.ckpt only at epoch boundaries, so a preempted run resumed from much older weights. The injected callback now copies the monitored callback's every_n_train_steps or train_time_interval when set; epoch-cadence leaves are unchanged. No current leaf combines a monitored checkpoint with a step trigger. Found by /code-review on #533. Co-Authored-By: Claude Opus 5.5 --- .../viscy_utils/callbacks/monitor_health_test.py | 14 ++++++++++++++ packages/viscy-utils/src/viscy_utils/cli.py | 12 +++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py index 2e227114e..3c75b4337 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py @@ -245,6 +245,20 @@ def test_injection_defaults_cadence_to_every_epoch(): assert config["fit"]["trainer"]["callbacks"][1]["init_args"]["every_n_epochs"] == 1 +def test_injection_inherits_a_step_cadence(): + """A leaf that saves every N steps keeps a step-fresh ``last.ckpt``, not an epoch one.""" + monitored = Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace(monitor="loss/validate", every_n_train_steps=1000, save_last=True, dirpath="/d"), + ) + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=[monitored]))) + _inject_checkpoint_guardrails(config, "fit") + + latest = config["fit"]["trainer"]["callbacks"][1]["init_args"] + assert latest["every_n_train_steps"] == 1000 + assert "every_n_epochs" not in latest + + def test_inherited_cadence_still_saves_when_monitor_is_frozen(tmp_path): """End-to-end: cadence 4 writes latest weights without writing every epoch. diff --git a/packages/viscy-utils/src/viscy_utils/cli.py b/packages/viscy-utils/src/viscy_utils/cli.py index 94d708aa5..bcb8b275a 100644 --- a/packages/viscy-utils/src/viscy_utils/cli.py +++ b/packages/viscy-utils/src/viscy_utils/cli.py @@ -171,14 +171,20 @@ def _inject_checkpoint_guardrails(config: Namespace, subcommand: str | None) -> # The guarantee is unaffected: an *unmonitored* callback writes on its cadence # unconditionally, so the newest weights are always within `every_n_epochs` # epochs of where the run stopped, however dead the monitor is. Defaults to 1 - # when the leaf is silent, preserving the original behaviour. - every_n_epochs = monitored_args.get("every_n_epochs") or 1 + # when the leaf is silent, preserving the original behaviour. A leaf that saves + # on a step or time trigger keeps that trigger instead: once `save_last` moves + # here, an epoch cadence would leave a max_steps run with long epochs no recent + # `last.ckpt` to resume from. + cadence = {key: monitored_args.get(key) for key in ("every_n_train_steps", "train_time_interval")} + cadence = {key: value for key, value in cadence.items() if value} or { + "every_n_epochs": monitored_args.get("every_n_epochs") or 1 + } latest_args = { "monitor": None, "filename": _LATEST_CKPT_FILENAME, "auto_insert_metric_name": False, - "every_n_epochs": every_n_epochs, + **cadence, "save_top_k": 1, "save_last": True, } From 0b1cb979c1ee2460148837516bff43728c2bf34e Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Mon, 26 Oct 2026 18:46:10 -0700 Subject: [PATCH 37/39] fix(viscy-utils): take last.ckpt from every monitored checkpoint The guardrail turned save_last off only on the first monitored ModelCheckpoint. A leaf with two monitored checkpoints (say loss and a validation metric) kept a second last.ckpt writer next to the injected latest-weights callback, so last.ckpt would alternate between "latest" and "that monitor's cadence". All monitored callbacks now give it up; the first still names the monitor, directory and cadence. No current leaf has two monitored checkpoints. Found by /code-review on #533. Co-Authored-By: Claude Opus 5.5 --- .../callbacks/monitor_health_test.py | 17 ++++++++++++++ packages/viscy-utils/src/viscy_utils/cli.py | 23 +++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py index 3c75b4337..31ed3df4b 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/monitor_health_test.py @@ -245,6 +245,23 @@ def test_injection_defaults_cadence_to_every_epoch(): assert config["fit"]["trainer"]["callbacks"][1]["init_args"]["every_n_epochs"] == 1 +def test_injection_takes_last_ckpt_from_every_monitored_checkpoint(): + """With two monitored checkpoints, only the injected latest-weights callback writes ``last.ckpt``.""" + monitored = [ + Namespace( + class_path="lightning.pytorch.callbacks.ModelCheckpoint", + init_args=Namespace(monitor=monitor, save_last=True, dirpath="/d"), + ) + for monitor in ("loss/validate", "metrics/dice") + ] + config = Namespace(fit=Namespace(trainer=Namespace(callbacks=list(monitored)))) + _inject_checkpoint_guardrails(config, "fit") + + callbacks = config["fit"]["trainer"]["callbacks"] + writers = [cb["init_args"].get("monitor") for cb in callbacks if cb["init_args"].get("save_last")] + assert writers == [None] + + def test_injection_inherits_a_step_cadence(): """A leaf that saves every N steps keeps a step-fresh ``last.ckpt``, not an epoch one.""" monitored = Namespace( diff --git a/packages/viscy-utils/src/viscy_utils/cli.py b/packages/viscy-utils/src/viscy_utils/cli.py index bcb8b275a..3c2620708 100644 --- a/packages/viscy-utils/src/viscy_utils/cli.py +++ b/packages/viscy-utils/src/viscy_utils/cli.py @@ -145,21 +145,24 @@ def _inject_checkpoint_guardrails(config: Namespace, subcommand: str | None) -> if callbacks is None: return - monitored_args = None - for callback in callbacks: - if _callback_field(callback, "class_path") != _MODEL_CHECKPOINT_CLASS_PATH: - continue - init_args = _init_args_of(callback) - if init_args is not None and init_args.get("monitor"): - monitored_args = init_args - break - if monitored_args is None: + monitored = [ + init_args + for callback in callbacks + if _callback_field(callback, "class_path") == _MODEL_CHECKPOINT_CLASS_PATH + and (init_args := _init_args_of(callback)) is not None + and init_args.get("monitor") + ] + if not monitored: return + # The first monitored callback names the monitor, directory and cadence. + monitored_args = monitored[0] monitor = monitored_args.get("monitor") dirpath = monitored_args.get("dirpath") # Hand `last.ckpt` to the unconditional callback so it tracks the newest epoch. - monitored_args["save_last"] = False + # Every monitored callback gives it up, or a second one would keep writing it. + for init_args in monitored: + init_args["save_last"] = False # Inherit the leaf's write cadence instead of forcing every epoch. A leaf that # sets `every_n_epochs` is making a deliberate write-amplification decision: From 48347a73149aff3444007e5898d9e0a6c7f96dfe Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Fri, 30 Oct 2026 13:34:57 -0700 Subject: [PATCH 38/39] fix(viscy-utils): write AnnData zarr without switching pandas string inference off anndata 0.12.6 (capped below 0.12.9, which needs pandas<3) cannot write pandas 3's default Arrow-backed strings to zarr. The workaround was global: write_embedding_dataset set pd.options.future.infer_string = False in the caller's process, and the test conftest set it for the whole session. Tests therefore never ran under the configuration users have, and the other writers (EmbeddingSnapshot's rewrite, annotation convert) worked only after the embedding writer had flipped the option. write_anndata_zarr converts obs/var Arrow strings (columns, categories, index) to object just before writing, reusing the conversion append_to_anndata_zarr already had; all three writers and the tests use it, and both global switches are gone. New tests pin a round trip under the default, that plain write_zarr still fails (so the TODO is dropped deliberately once anndata 0.13 lands), and that the embedding writer leaves the option alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MzpgExfYMKCgogfnhcSgDc --- packages/viscy-utils/pyproject.toml | 4 +- .../callbacks/embedding_snapshot.py | 4 +- .../viscy_utils/callbacks/embedding_writer.py | 14 +---- .../src/viscy_utils/evaluation/annotation.py | 4 +- .../src/viscy_utils/evaluation/zarr_utils.py | 51 ++++++++++++++----- packages/viscy-utils/tests/conftest.py | 18 +++---- .../tests/test_linear_classifier.py | 9 ++-- packages/viscy-utils/tests/test_zarr_utils.py | 51 +++++++++++++++++++ 8 files changed, 113 insertions(+), 42 deletions(-) create mode 100644 packages/viscy-utils/tests/test_zarr_utils.py diff --git a/packages/viscy-utils/pyproject.toml b/packages/viscy-utils/pyproject.toml index 42c56feb0..5ea32d75b 100644 --- a/packages/viscy-utils/pyproject.toml +++ b/packages/viscy-utils/pyproject.toml @@ -51,8 +51,8 @@ dependencies = [ optional-dependencies.all = [ "viscy-utils[anndata,eval]" ] # Cap anndata to <0.12.9 — 0.12.9 hard-requires pandas<3, but the rest of -# the stack runs on pandas 3 (see ArrowStringArray downcast workaround in -# callbacks/embedding_writer.py). Lift this cap once anndata 0.13 lands +# the stack runs on pandas 3 (see the ArrowStringArray downcast in +# evaluation/zarr_utils.py). Lift this cap once anndata 0.13 lands # with native pandas-3 support. optional-dependencies.anndata = [ "anndata<0.12.9", "natsort" ] optional-dependencies.eval = [ diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/embedding_snapshot.py b/packages/viscy-utils/src/viscy_utils/callbacks/embedding_snapshot.py index c6ebf7645..bc0a45374 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/embedding_snapshot.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/embedding_snapshot.py @@ -153,12 +153,14 @@ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule) if self.store_images and self._images is not None: import anndata as ad + from viscy_utils.evaluation.zarr_utils import write_anndata_zarr + adata = ad.read_zarr(output_path) b = self._images.shape[0] adata.obsm["X_images"] = self._images.reshape(b, -1) # Mid-Z extraction produces (C, Y, X) per sample adata.uns["image_shape_cyx"] = list(self._images.shape[1:]) - adata.write_zarr(output_path) + write_anndata_zarr(adata, output_path) _logger.info(f"Embedding snapshot saved: {output_path} ({features_np.shape[0]} samples)") self._reset() diff --git a/packages/viscy-utils/src/viscy_utils/callbacks/embedding_writer.py b/packages/viscy-utils/src/viscy_utils/callbacks/embedding_writer.py index 373507b8f..f5346808a 100644 --- a/packages/viscy-utils/src/viscy_utils/callbacks/embedding_writer.py +++ b/packages/viscy-utils/src/viscy_utils/callbacks/embedding_writer.py @@ -146,8 +146,7 @@ def write_embedding_dataset( """ import anndata as ad - # pandas 2.x with PyArrow defaults ArrowStringArray; anndata zarr writer requires numpy-backed strings - pd.options.future.infer_string = False + from viscy_utils.evaluation.zarr_utils import write_anndata_zarr output_path = Path(output_path) @@ -156,15 +155,6 @@ def write_embedding_dataset( ultrack_indices = index_df.copy() ultrack_indices["fov_name"] = ultrack_indices["fov_name"].str.strip("/") - # TODO: remove once anndata 0.13 supports pandas 3 Arrow-backed strings natively. - # anndata 0.12.9+ requires pandas <3, so we stay on 0.12.6 + pandas 3 and - # must manually downcast ArrowStringArray columns to object dtype before writing. - for col in ultrack_indices.columns: - s = ultrack_indices[col] - if isinstance(s.dtype, pd.StringDtype): - ultrack_indices[col] = s.astype(object) - elif hasattr(s, "cat") and isinstance(s.cat.categories.dtype, pd.StringDtype): - ultrack_indices[col] = s.cat.rename_categories(s.cat.categories.astype(object)) if embedding_key == "projections": if projections is None: @@ -213,7 +203,7 @@ def write_embedding_dataset( adata.uns.update(uns_metadata) _logger.debug(f"Writing dataset to {output_path}") - adata.write_zarr(output_path) + write_anndata_zarr(adata, output_path) class EmbeddingWriter(BasePredictionWriter): diff --git a/packages/viscy-utils/src/viscy_utils/evaluation/annotation.py b/packages/viscy-utils/src/viscy_utils/evaluation/annotation.py index fe106b5e8..d215027ad 100644 --- a/packages/viscy-utils/src/viscy_utils/evaluation/annotation.py +++ b/packages/viscy-utils/src/viscy_utils/evaluation/annotation.py @@ -57,6 +57,8 @@ def convert( try: import anndata as ad from natsort import natsorted + + from viscy_utils.evaluation.zarr_utils import write_anndata_zarr except ImportError as e: raise ImportError( "anndata and natsort are required for embedding conversion. " @@ -101,7 +103,7 @@ def convert( adata = ad.AnnData(X=X, obs=tracking_df, obsm=obsm) - adata.write_zarr(output_path) + write_anndata_zarr(adata, output_path) if return_anndata: return adata diff --git a/packages/viscy-utils/src/viscy_utils/evaluation/zarr_utils.py b/packages/viscy-utils/src/viscy_utils/evaluation/zarr_utils.py index e4566b029..065b7c176 100644 --- a/packages/viscy-utils/src/viscy_utils/evaluation/zarr_utils.py +++ b/packages/viscy-utils/src/viscy_utils/evaluation/zarr_utils.py @@ -10,6 +10,44 @@ from pandas.arrays import ArrowStringArray +def _object_strings(df: pd.DataFrame) -> pd.DataFrame: + """Copy of ``df`` with Arrow-backed strings (columns, categories, index) as object. + + TODO: remove once anndata 0.13 supports pandas 3 Arrow-backed strings natively. + anndata 0.12.9+ requires pandas <3, so we stay on 0.12.6 + pandas 3, whose zarr + writer has no method for ``ArrowStringArray``. + """ + df = df.copy() + for col in df.columns: + arr = df[col].array + if isinstance(arr, ArrowStringArray): + df[col] = df[col].astype(object) + elif isinstance(arr, pd.Categorical) and isinstance(arr.categories._values, ArrowStringArray): + df[col] = df[col].cat.rename_categories(arr.categories.astype(object)) + if isinstance(df.index._values, ArrowStringArray): + df.index = df.index.astype(object) + return df + + +def write_anndata_zarr(adata: ad.AnnData, zarr_path: str | Path) -> None: + """Write ``adata`` to a zarr store under pandas' default string inference. + + Replaces ``adata.obs`` and ``adata.var`` with copies whose Arrow-backed strings + are object arrays (see :func:`_object_strings`), then writes. Use this instead of + ``adata.write_zarr`` or setting ``pd.options.future.infer_string`` globally. + + Parameters + ---------- + adata : ad.AnnData + AnnData to write; its obs and var frames are replaced in place. + zarr_path : str | Path + Output zarr store. + """ + adata.obs = _object_strings(adata.obs) + adata.var = _object_strings(adata.var) + adata.write_zarr(zarr_path) + + def append_to_anndata_zarr( zarr_path: str | Path, *, @@ -39,18 +77,7 @@ def append_to_anndata_zarr( ad.settings.allow_write_nullable_strings = True if obs is not None: - # TODO: remove once anndata 0.13 supports pandas 3 Arrow-backed strings natively. - # anndata 0.12.9+ requires pandas <3, so we stay on 0.12.6 + pandas 3 and - # must manually downcast ArrowStringArray columns to object dtype before writing. - obs = obs.copy() - for col in obs.columns: - arr = obs[col].array - if isinstance(arr, ArrowStringArray): - obs[col] = obs[col].astype(object) - elif isinstance(arr, pd.Categorical) and isinstance(arr.categories._values, ArrowStringArray): - obs[col] = obs[col].cat.rename_categories(arr.categories.astype(object)) - if isinstance(obs.index._values, ArrowStringArray): - obs.index = obs.index.astype(object) + obs = _object_strings(obs) if "obs" in store: del store["obs"] write_elem(store, "obs", obs) diff --git a/packages/viscy-utils/tests/conftest.py b/packages/viscy-utils/tests/conftest.py index c9e8a58d8..38e63a7a9 100644 --- a/packages/viscy-utils/tests/conftest.py +++ b/packages/viscy-utils/tests/conftest.py @@ -1,16 +1,14 @@ from __future__ import annotations -import pandas as pd - -# anndata 0.12.x zarr writer does not support pandas ArrowStringArray (default in pandas 2.x with PyArrow installed) -pd.options.future.infer_string = False +from pathlib import Path -from pathlib import Path # noqa: E402 +import anndata as ad +import numpy as np +import pandas as pd +from iohub import open_ome_zarr +from pytest import TempPathFactory, fixture -import anndata as ad # noqa: E402 -import numpy as np # noqa: E402 -from iohub import open_ome_zarr # noqa: E402 -from pytest import TempPathFactory, fixture # noqa: E402 +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr channel_names = ["Phase", "GFP"] @@ -53,7 +51,7 @@ def annotated_adata() -> ad.AnnData: def annotated_adata_zarr(annotated_adata, tmp_path) -> dict: """Write annotated_adata to zarr + CSV and return dataset dict.""" zarr_path = tmp_path / "emb.zarr" - annotated_adata.write_zarr(zarr_path) + write_anndata_zarr(annotated_adata, zarr_path) csv_path = tmp_path / "ann.csv" annotated_adata.obs[["fov_name", "id", "cell_death_state"]].to_csv(csv_path, index=False) diff --git a/packages/viscy-utils/tests/test_linear_classifier.py b/packages/viscy-utils/tests/test_linear_classifier.py index efcd356b8..4bd685f5d 100644 --- a/packages/viscy-utils/tests/test_linear_classifier.py +++ b/packages/viscy-utils/tests/test_linear_classifier.py @@ -17,6 +17,7 @@ LinearClassifierInferenceConfig, LinearClassifierTrainConfig, ) +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr @pytest.fixture @@ -228,7 +229,7 @@ def test_filters_unknown_labels(self, tmp_path): adata = ad.AnnData(X=X, obs=obs) zarr_path = tmp_path / "emb.zarr" - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) csv_path = tmp_path / "ann.csv" ann_df = pd.DataFrame({"fov_name": ["A/1/0"] * n, "id": range(n), "cell_death_state": labels}) @@ -249,7 +250,7 @@ def test_filters_nan_labels(self, tmp_path): adata = ad.AnnData(X=X, obs=obs) zarr_path = tmp_path / "emb.zarr" - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) csv_path = tmp_path / "ann.csv" ann_df = pd.DataFrame({"fov_name": ["A/1/0"] * n, "id": range(n), "cell_death_state": labels}) @@ -269,7 +270,7 @@ def test_raises_on_empty(self, tmp_path): adata = ad.AnnData(X=X, obs=obs) zarr_path = tmp_path / "emb.zarr" - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) csv_path = tmp_path / "ann.csv" ann_df = pd.DataFrame( @@ -296,7 +297,7 @@ def test_multiple_datasets(self, annotated_adata_zarr, tmp_path): adata = ad.AnnData(X=X, obs=obs) zarr_path = tmp_path / "emb2.zarr" - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) csv_path = tmp_path / "ann2.csv" ann_df = pd.DataFrame({"fov_name": ["B/1/0"] * n, "id": range(n), "cell_death_state": labels}) diff --git a/packages/viscy-utils/tests/test_zarr_utils.py b/packages/viscy-utils/tests/test_zarr_utils.py new file mode 100644 index 000000000..b43891930 --- /dev/null +++ b/packages/viscy-utils/tests/test_zarr_utils.py @@ -0,0 +1,51 @@ +"""Writing AnnData zarr stores under pandas 3's default Arrow-backed strings.""" + +import anndata as ad +import numpy as np +import pandas as pd +import pytest +from anndata._io.specs.registry import IORegistryError + +from viscy_utils.callbacks.embedding_writer import write_embedding_dataset +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr + + +@pytest.fixture +def arrow_adata() -> ad.AnnData: + """AnnData built under pandas' default, so its strings are Arrow-backed.""" + assert pd.options.future.infer_string, "pandas 3 default must be on for this test" + obs = pd.DataFrame( + { + "fov_name": ["A/1/0", "B/2/0", "A/1/0"], + "state": pd.Categorical(["alive", "dead", "alive"]), + "id": [0, 1, 2], + } + ) + return ad.AnnData(X=np.zeros((3, 2), dtype=np.float32), obs=obs) + + +def test_plain_write_zarr_still_rejects_arrow_strings(arrow_adata, tmp_path) -> None: + """Why the helper exists; fails once anndata writes Arrow strings natively.""" + with pytest.raises(IORegistryError, match="ArrowStringArray"): + arrow_adata.write_zarr(tmp_path / "plain.zarr") + + +def test_write_anndata_zarr_round_trips_arrow_strings(arrow_adata, tmp_path) -> None: + """Strings survive the write, and pandas' global option is left as it was.""" + write_anndata_zarr(arrow_adata, tmp_path / "emb.zarr") + + back = ad.read_zarr(tmp_path / "emb.zarr") + assert list(back.obs["fov_name"]) == ["A/1/0", "B/2/0", "A/1/0"] + assert list(back.obs["state"]) == ["alive", "dead", "alive"] + assert list(back.obs_names) == ["0", "1", "2"] + assert pd.options.future.infer_string + + +def test_write_embedding_dataset_leaves_pandas_default(tmp_path) -> None: + """The embedding writer no longer switches string inference off process-wide.""" + index_df = pd.DataFrame({"fov_name": ["/A/1/0", "/B/2/0"], "track_id": [1, 2], "t": [0, 0], "id": [0, 1]}) + write_embedding_dataset(tmp_path / "emb.zarr", np.ones((2, 4), dtype=np.float32), index_df) + + back = ad.read_zarr(tmp_path / "emb.zarr") + assert list(back.obs["fov_name"]) == ["A/1/0", "B/2/0"] + assert pd.options.future.infer_string From 1b2e227e490e297bebd8a501771afde43f051a14 Mon Sep 17 00:00:00 2001 From: Alexandr Kalinin Date: Fri, 30 Oct 2026 13:35:01 -0700 Subject: [PATCH 39/39] fix(dynaclr): drop the session-wide pandas string-inference switch dynaclr's pytest_configure set pd.options.future.infer_string = False for the whole pytest session, so in a repo-wide run every package's tests ran with pandas 3's default off (it hid the viscy-utils guard added in the previous commit). split_embeddings flipped it in the caller's process too. Both go; the eleven test writes and the split writer use viscy_utils' write_anndata_zarr instead. The cellanome embedding scripts keep their own switch: each owns its process and has no test to check a change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MzpgExfYMKCgogfnhcSgDc --- .../linear_classifiers/orchestrated_test.py | 5 +++-- .../src/dynaclr/evaluation/split_embeddings.py | 7 +++---- applications/dynaclr/tests/conftest.py | 12 ++---------- applications/dynaclr/tests/test_mmd.py | 3 ++- .../dynaclr/tests/test_reduce_dimensionality.py | 13 +++++++------ 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py b/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py index fc90cadf4..398258857 100644 --- a/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py +++ b/applications/dynaclr/src/dynaclr/evaluation/linear_classifiers/orchestrated_test.py @@ -9,6 +9,7 @@ from dynaclr.evaluation.evaluate_config import AnnotationSource, LinearClassifiersStepConfig, TaskSpec from dynaclr.evaluation.linear_classifiers.orchestrated import run_linear_classifiers +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr def _make_embeddings_zarr( @@ -58,7 +59,7 @@ def _make_embeddings_zarr( df.index = pd.Index([str(i) for i in range(total)], dtype=object) var = pd.DataFrame(index=pd.Index([str(i) for i in range(n_features)], dtype=object)) adata = ad.AnnData(X=X, obs=df, var=var) - adata.write_zarr(path) + write_anndata_zarr(adata, path) return adata @@ -248,7 +249,7 @@ def test_run_linear_classifiers_missing_metadata_raises(tmp_path): obs.index = pd.Index([str(i) for i in range(50)], dtype=object) var = pd.DataFrame(index=pd.Index([str(i) for i in range(8)], dtype=object)) zarr_path = tmp_path / "embeddings.zarr" - ad.AnnData(X=X, obs=obs, var=var).write_zarr(zarr_path) + write_anndata_zarr(ad.AnnData(X=X, obs=obs, var=var), zarr_path) config = LinearClassifiersStepConfig( annotations=[AnnotationSource(experiment="exp_A", path=str(tmp_path / "ann.csv"))], diff --git a/applications/dynaclr/src/dynaclr/evaluation/split_embeddings.py b/applications/dynaclr/src/dynaclr/evaluation/split_embeddings.py index c55aedbcd..06704c09d 100644 --- a/applications/dynaclr/src/dynaclr/evaluation/split_embeddings.py +++ b/applications/dynaclr/src/dynaclr/evaluation/split_embeddings.py @@ -39,11 +39,10 @@ def split_embeddings(input_path: Path, output_dir: Path) -> list[Path]: """ import anndata as ad + from viscy_utils.evaluation.zarr_utils import write_anndata_zarr + if hasattr(ad, "settings") and hasattr(ad.settings, "allow_write_nullable_strings"): ad.settings.allow_write_nullable_strings = True - import pandas as pd - - pd.options.future.infer_string = False click.echo(f"Loading embeddings from {input_path}") adata = ad.read_zarr(input_path) @@ -66,7 +65,7 @@ def split_embeddings(input_path: Path, output_dir: Path) -> list[Path]: adata_exp = adata[mask].copy() out_path = output_dir / f"{exp}.zarr" click.echo(f" Writing {exp}: {adata_exp.n_obs} cells → {out_path}") - adata_exp.write_zarr(out_path) + write_anndata_zarr(adata_exp, out_path) written.append(out_path) click.echo(f"\nRemoving combined zarr: {input_path}") diff --git a/applications/dynaclr/tests/conftest.py b/applications/dynaclr/tests/conftest.py index f4d9fae0c..c0b54bcbd 100644 --- a/applications/dynaclr/tests/conftest.py +++ b/applications/dynaclr/tests/conftest.py @@ -13,6 +13,7 @@ from viscy_data._typing import TripletSample from viscy_data.collection import Collection, ExperimentEntry, save_collection +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr # --------------------------------------------------------------------------- # HPC / GPU skip markers @@ -329,15 +330,6 @@ def val_dataloader(self) -> DataLoader: # --------------------------------------------------------------------------- -def pytest_configure(config): - # anndata zarr writer does not support pandas ArrowStringArray (default in - # pandas 2.x when PyArrow is installed). Verified still broken in - # anndata==0.12.6 + zarr==3.x: IORegistryError on write_zarr with an - # ArrowStringArray index. Remove when anndata fixes zarr 3 support. - # See: https://github.com/scverse/anndata/issues/1510 - pd.options.future.infer_string = False - - def pytest_collection_modifyitems(config, items): """Auto-skip tests marked ``hpc_integration`` when HPC paths or GPU are unavailable.""" if _HPC_PATHS_AVAILABLE and _GPU_AVAILABLE: @@ -386,7 +378,7 @@ def annotated_adata() -> ad.AnnData: def annotated_adata_zarr(annotated_adata, tmp_path) -> dict: """Write annotated_adata to zarr + CSV and return dataset dict.""" zarr_path = tmp_path / "emb.zarr" - annotated_adata.write_zarr(zarr_path) + write_anndata_zarr(annotated_adata, zarr_path) csv_path = tmp_path / "ann.csv" annotated_adata.obs[["fov_name", "id", "cell_death_state"]].to_csv(csv_path, index=False) diff --git a/applications/dynaclr/tests/test_mmd.py b/applications/dynaclr/tests/test_mmd.py index 1b02196f2..359d3e7c5 100644 --- a/applications/dynaclr/tests/test_mmd.py +++ b/applications/dynaclr/tests/test_mmd.py @@ -10,6 +10,7 @@ from dynaclr.evaluation.mmd.compute_mmd import run_mmd_analysis, run_mmd_pooled from dynaclr.evaluation.mmd.config import ComparisonSpec, MMDEvalConfig, MMDPooledConfig, MMDSettings from viscy_utils.evaluation.mmd import compute_mmd_unbiased, median_heuristic, mmd_permutation_test +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr # --------------------------------------------------------------------------- # Helpers @@ -424,7 +425,7 @@ def _save_adata_zarr(adata: ad.AnnData, path: str) -> None: if os.path.exists(path): shutil.rmtree(path) - adata.write_zarr(path) + write_anndata_zarr(adata, path) def test_run_mmd_pooled_columns(tmp_path): diff --git a/applications/dynaclr/tests/test_reduce_dimensionality.py b/applications/dynaclr/tests/test_reduce_dimensionality.py index fbfd4c56a..dd1ea7bf4 100644 --- a/applications/dynaclr/tests/test_reduce_dimensionality.py +++ b/applications/dynaclr/tests/test_reduce_dimensionality.py @@ -18,6 +18,7 @@ _run_phate, _run_umap, ) +from viscy_utils.evaluation.zarr_utils import write_anndata_zarr @pytest.fixture @@ -30,7 +31,7 @@ def synthetic_zarr(tmp_path): adata = ad.AnnData(X=X) zarr_path = tmp_path / "embeddings.zarr" ad.settings.allow_write_nullable_strings = True - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) return str(zarr_path) @@ -183,7 +184,7 @@ def test_overwrite_keys_protection(self, synthetic_zarr, tmp_path): # Pre-populate X_pca adata = ad.read_zarr(synthetic_zarr) adata.obsm["X_pca"] = np.zeros((100, 5)) - adata.write_zarr(synthetic_zarr) + write_anndata_zarr(adata, synthetic_zarr) config_content = f"input_path: {synthetic_zarr}\npca:\n n_components: 10\n" config_path = tmp_path / "test_config.yaml" @@ -204,7 +205,7 @@ def test_overwrite_keys_allowed(self, synthetic_zarr, tmp_path): # Pre-populate X_pca adata = ad.read_zarr(synthetic_zarr) adata.obsm["X_pca"] = np.zeros((100, 5)) - adata.write_zarr(synthetic_zarr) + write_anndata_zarr(adata, synthetic_zarr) config_content = f"input_path: {synthetic_zarr}\noverwrite_keys: true\npca:\n n_components: 10\n" config_path = tmp_path / "test_config.yaml" @@ -249,7 +250,7 @@ def test_uns_per_key_preserves_existing(self, tmp_path): adata.uns["existing_list"] = ["a", "b"] zarr_path = tmp_path / "test.zarr" ad.settings.allow_write_nullable_strings = True - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) append_to_anndata_zarr(zarr_path, uns={"new_key": ["path1", "path2"]}) @@ -267,7 +268,7 @@ def test_uns_overwrites_specific_key(self, tmp_path): adata.uns["other_key"] = "untouched" zarr_path = tmp_path / "test.zarr" ad.settings.allow_write_nullable_strings = True - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) append_to_anndata_zarr(zarr_path, uns={"my_key": "new_value"}) @@ -317,7 +318,7 @@ def two_synthetic_zarrs(self, tmp_path): adata.uns["classifier_version"] = f"v{i}" adata.uns["predicted_classes"] = ["alive", "dead"] zarr_path = tmp_path / f"store_{i}.zarr" - adata.write_zarr(zarr_path) + write_anndata_zarr(adata, zarr_path) paths.append(str(zarr_path)) return paths