diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..cf5e0174 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +### What problem this PR addreseses + + + +### What should the reviewer(s) do + + + + diff --git a/.github/workflows/check-pr-main-version.yml b/.github/workflows/check-pr-main-version.yml index 809e3158..d867ade3 100644 --- a/.github/workflows/check-pr-main-version.yml +++ b/.github/workflows/check-pr-main-version.yml @@ -84,10 +84,6 @@ jobs: echo "New version is greater — creating release branch" git checkout -b release/$PR_VERSION git push origin release/$PR_VERSION - - echo "Tagging release v$PR_VERSION" - git tag -a v$PR_VERSION -m "Release v$PR_VERSION" - git push origin v$PR_VERSION fi elif [[ "$RESULT" -eq 0 ]]; then @@ -108,10 +104,6 @@ jobs: git checkout -b release/$NEXT_VERSION git push origin release/$NEXT_VERSION - echo "Tagging release v$NEXT_VERSION" - git tag -a v$NEXT_VERSION -m "Release v$NEXT_VERSION" - git push origin v$NEXT_VERSION - PR_BODY=$(printf "This PR was automatically created because the submitted version \`%s\` matched the current release on \`main\`.\n\nIt bumps the patch version to \`%s\` and starts a new release process." "$PR_VERSION" "$NEXT_VERSION") gh pr create \ --repo "$REPO" \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f4eab293..fd58e7c6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: write + jobs: uv-deploy: name: deploy @@ -14,15 +17,41 @@ jobs: steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Verify lock file is up to date run: uv lock --check + - name: Read version + id: version + run: | + VERSION=$(uv version --short) + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Build the project run: uv build + - name: Create Git tag + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag v${{ steps.version.outputs.version }} + git push origin v${{ steps.version.outputs.version }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + generate_release_notes: true + files: | + dist/* + - name: Publish to PyPi run: uv publish diff --git a/.github/workflows/uv-pytests.yml b/.github/workflows/uv-pytests.yml index 474ad347..fe14f19c 100644 --- a/.github/workflows/uv-pytests.yml +++ b/.github/workflows/uv-pytests.yml @@ -2,9 +2,7 @@ name: PyTests on: pull_request: - branches: - - dev - - main + workflow_dispatch: jobs: uv-pytests: diff --git a/.gitignore b/.gitignore index d87d85c8..3c3252a5 100644 --- a/.gitignore +++ b/.gitignore @@ -167,6 +167,9 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# BasedPyRight Config +pyrightconfig.json + # Ruff stuff: .ruff_cache/ @@ -185,9 +188,24 @@ ipynb-playground/ *.h5 *.npy -# cursor +# cursor/CLI .cursor +.claude +CLAUDE.md +AGENTS.md +AGENT.md # widget (JS build artifacts) node_modules/ widget/src/quantem/widget/static/ + +# widget — local-only (per-developer notebooks, docs scratch, build/test scripts). +# Track only src/, js/, tests/test_*.py for now. +widget/.gitignore +widget/docs/ +widget/notebooks/ +widget/scripts/ +!widget/scripts/build.mjs +widget/tests/integration/ +widget/tests/snapshots/ + diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 1659b33b..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "python.languageServer": "None", - "python.terminal.activateEnvInCurrentTerminal": true, - "python.defaultInterpreterPath": ".venv/bin/python", - "cSpell.words": [ - "abtem", - "altk", - "arcsinh", - "argmin", - "asnumpy", - "astropy's", - "axsize", - "BFGS", - "cbar", - "cmap", - "coefs", - "colorbar", - "colorbars", - "colormaps", - "colorspacious", - "cspace", - "cupy", - "datacube", - "deconvolve", - "dstack", - "dstem", - "emdfile", - "errstate", - "fftfreq", - "fftshifted", - "Fienup", - "figax", - "figsize", - "frameon", - "Guizar", - "halfrange", - "hstack", - "ifft", - "ifftshift", - "imshow", - "inds", - "interp", - "iscomplexobj", - "isscalar", - "issubdtype", - "lowpass", - "maxiter", - "meshgrid", - "metadatabundle", - "minmax", - "ncols", - "ndarray", - "ndimage", - "ndindex", - "ndinfo", - "nrows", - "nval", - "polyfit", - "polyval", - "powerlimits", - "quantem", - "rosettasciio", - "rsciio", - "Scalebar", - "scanline", - "scanlines", - "Sicairos", - "sinc", - "Subarray", - "ticklabels", - "toolkits", - "tukey", - "upsampling", - "vmax", - "vmin", - "xticks", - "yaxis", - "yticks" - ], - "basedpyright.analysis.typeCheckingMode": "standard", -} diff --git a/pyproject.toml b/pyproject.toml index 5ca6787b..4df9fc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ members = ["widget"] [project] name = "quantem" -version = "0.1.8" +version = "0.1.9" description = "quantitative electron microscopy analysis toolkit." keywords = ["EM","TEM","STEM","4DSTEM"] readme = "README.md" diff --git a/src/quantem/__init__.py b/src/quantem/__init__.py index b9aa54b4..ba70f629 100644 --- a/src/quantem/__init__.py +++ b/src/quantem/__init__.py @@ -1,4 +1,5 @@ from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) from importlib.metadata import version diff --git a/src/quantem/core/datastructures/dataset.py b/src/quantem/core/datastructures/dataset.py index 764e2343..94744978 100644 --- a/src/quantem/core/datastructures/dataset.py +++ b/src/quantem/core/datastructures/dataset.py @@ -1,9 +1,10 @@ -import os import numbers +import os from pathlib import Path from typing import Any, Literal, Optional, Self, Union, overload import numpy as np +import torch from numpy.typing import DTypeLike, NDArray from quantem.core.io.serialize import AutoSerialize @@ -23,7 +24,7 @@ class Dataset(AutoSerialize): Attributes (Properties): array (NDArray): The underlying n-dimensional NumPy array data. name (str): A descriptive name for the dataset. - origin (NDArray): The origin coordinates for each dimension (1D array). + origin (NDArray): The origin coordinates for each dimension (1D array) in calibrated units. sampling (NDArray): The sampling rate/spacing for each dimension (1D array). units (list[str]): Units for each dimension. signal_units (str): Units for the array values. @@ -38,22 +39,38 @@ class Dataset(AutoSerialize): def __init__( self, - array: Any, # Input can be array-like - name: str, - origin: NDArray | tuple | list | float | int, - sampling: NDArray | tuple | list | float | int, - units: list[str] | tuple | list, + array: NDArray | None = None, + tensor: torch.Tensor | None = None, + name: str = "", + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, signal_units: str = "arb. units", metadata: Optional[dict] = None, _token: object | None = None, ): if _token is not self._token: - raise RuntimeError("Use Dataset.from_array() to instantiate this class.") + raise RuntimeError( + "Use Dataset.from_array() or Dataset.from_tensor() to instantiate this class." + ) super().__init__() - arr = ensure_valid_array(array) - if not isinstance(arr, np.ndarray): - raise TypeError("Dataset requires a NumPy array (CuPy is not supported on this branch).") - self._array = arr + # Dual-slot storage: exactly one of (_array, _tensor) is set. + # TODO: remove dual-init guards once torch transition is complete. + if array is None and tensor is None: + raise ValueError("Provide either `array` (numpy) or `tensor` (torch).") + if array is not None and tensor is not None: + raise ValueError("Provide only one of `array` or `tensor`, not both.") + if array is not None: + arr = ensure_valid_array(array) + if not isinstance(arr, np.ndarray): + raise TypeError(f"Dataset.array must be numpy.ndarray, got {type(arr).__name__}.") + self._array = arr + self._tensor = None + else: + if not isinstance(tensor, torch.Tensor): + raise TypeError(f"Dataset.tensor must be torch.Tensor, got {type(tensor).__name__}.") + self._array = None + self._tensor = tensor self.name = name self.origin = origin self.sampling = sampling @@ -82,7 +99,7 @@ def from_array( name: str | None The name of the Dataset. origin: NDArray | tuple | list | float | int | None - The origin of the Dataset. + The origin of the Dataset in calibrated units. sampling: NDArray | tuple | list | float | int | None The sampling of the Dataset. units: list[str] | tuple | list | None @@ -97,7 +114,9 @@ def from_array( """ validated_array = ensure_valid_array(array) if not isinstance(validated_array, np.ndarray): - raise TypeError("Dataset requires a NumPy array (CuPy is not supported on this branch).") + raise TypeError( + "Dataset requires a NumPy array (CuPy is not supported on this branch)." + ) _ndim = validated_array.ndim # Set defaults if None @@ -118,17 +137,31 @@ def from_array( # --- Properties --- @property - def array(self) -> NDArray: - """The underlying n-dimensional NumPy array data.""" - return self._array + def array(self) -> NDArray | None: + """The underlying n-dimensional NumPy array data. + + Returns ``None`` for tensor-backed datasets. Use ``.tensor`` for the + torch tensor, or ``.numpy()`` to materialize a numpy copy explicitly. + """ + return getattr(self, "_array", None) @array.setter def array(self, value: NDArray) -> None: - arr = ensure_valid_array(value, ndim=self.ndim) # want to allow changing dtype + arr = ensure_valid_array(value, ndim=self.ndim) if not isinstance(arr, np.ndarray): - raise TypeError("Dataset requires a NumPy array (CuPy is not supported on this branch).") + raise TypeError(f"Dataset.array must be numpy.ndarray, got {type(arr).__name__}.") self._array = arr - # self._array = ensure_valid_array(value, dtype=self.dtype, ndim=self.ndim) + + @property + def tensor(self) -> torch.Tensor: + """Torch tensor backing the data. AttributeError if numpy-backed.""" + # getattr handles AutoSerialize-restored instances (no __init__ run). + tensor = getattr(self, "_tensor", None) + if tensor is None: + raise AttributeError( + f"Dataset '{self.name}' is numpy-backed; use Dataset.from_tensor() at construction." + ) + return tensor @property def metadata(self) -> dict: @@ -185,26 +218,62 @@ def file_path(self, value: os.PathLike | str | None) -> None: # --- Derived Properties --- @property def shape(self) -> tuple[int, ...]: - return self.array.shape + # Direct slot access (never triggers .array derive, which would force + # a full GPU->CPU copy on tensor-backed datasets). getattr handles + # AutoSerialize-restored instances (no __init__ run). + array = getattr(self, "_array", None) + return tuple((array if array is not None else self._tensor).shape) @property def ndim(self) -> int: - return self.array.ndim + array = getattr(self, "_array", None) + return (array if array is not None else self._tensor).ndim @property - def dtype(self) -> DTypeLike: - return self.array.dtype + def dtype(self) -> DTypeLike | torch.dtype: + array = getattr(self, "_array", None) + return (array if array is not None else self._tensor).dtype @property def device(self) -> str: + """Device string for the underlying storage. numpy 2.x ndarray and torch.Tensor + both expose ``.device`` (array-API convention), so this is uniform. """ - Outputting a string is likely temporary -- once we have our use cases we can - figure out a more permanent device solution that enables easier translation between - numpy <-> torch <-> numpy, etc. + array = getattr(self, "_array", None) + return str((array if array is not None else self._tensor).device) + + def numpy(self) -> NDArray: + """Return the data as a numpy array (mirrors ``torch.Tensor.numpy()``). - For NumPy-only datasets, this is always "cpu". + For numpy-backed datasets, returns ``self.array`` directly. For + tensor-backed datasets, materializes a read-only CPU copy via + ``.detach().cpu().numpy()``. ``flags.writeable=False`` so accidental + in-place writes raise instead of silently being lost (the copy is not + the tensor). + """ + array = getattr(self, "_array", None) + if array is not None: + return array + arr = self._tensor.detach().cpu().numpy() + arr.flags.writeable = False + return arr + + def to(self, device) -> Self: + """Move the underlying tensor to ``device``. Raises if numpy-backed. + + ``device`` is normalized via :func:`quantem.core.config.validate_device` + so values like ``"cuda"``, ``0``, ``"cuda:0"``, ``torch.device("cuda:0")`` + all resolve to the same canonical device. """ - return "cpu" + from quantem.core import config + tensor = getattr(self, "_tensor", None) + if tensor is None: + raise AttributeError( + f"Cannot .to({device!r}) on numpy-backed Dataset '{self.name}'." + ) + dev, _ = config.validate_device(device) + self._tensor = tensor.to(dev) + return self # --- Summaries --- def __repr__(self) -> str: @@ -350,8 +419,9 @@ def min(self, axes: int | tuple[int, ...] | None = None) -> Any: @overload def pad( self, - pad_width: int | tuple[int, int] | tuple[tuple[int, int], ...] | None, - output_shape: tuple[int, ...] | None, + pad_width: int | tuple[int, int] | tuple[tuple[int, int], ...] | None = None, + output_shape: tuple[int, ...] | None = None, + *, modify_in_place: Literal[True], **kwargs: Any, ) -> None: ... @@ -363,7 +433,7 @@ def pad( output_shape: tuple[int, ...] | None = None, modify_in_place: Literal[False] = False, **kwargs: Any, - ) -> "Dataset": ... + ) -> Self: ... def pad( self, @@ -371,7 +441,7 @@ def pad( output_shape: tuple[int, ...] | None = None, modify_in_place: bool = False, **kwargs: Any, - ) -> "Dataset | None": + ) -> Self | None: """ Pads Dataset data array using numpy.pad. Metadata (origin, sampling) is not modified. @@ -426,7 +496,8 @@ def pad( def crop( self, crop_widths: tuple[tuple[int, int], ...], - axes: tuple | None, + axes: tuple | None = None, + *, modify_in_place: Literal[True], ) -> None: ... @@ -444,27 +515,56 @@ def crop( axes: tuple | None = None, modify_in_place: bool = False, ) -> Self | None: - """ - Crops Dataset + """Select a sub-region of the dataset along specified axes + + Each ``crop_widths`` entry is a ``(start, stop)`` pair defining + which elements to keep. A ``stop`` of ``0`` keeps everything from + ``start`` to the end. Parameters ---------- - crop_widths:tuple - Min and max for cropping each axis specified as a tuple - axes: - Axes over which to crop. If None specified, all are cropped. - modify_in_place: bool - If True, modifies dataset + crop_widths : tuple[tuple[int, int], ...] + ``(start, stop)`` indices for each axis specified in ``axes``. + axes : tuple | None + Axes to crop. If None, all axes are cropped. + modify_in_place : bool + If True, modifies this dataset in-place and frees the original + array. If False, returns a new dataset. Returns + ------- + Dataset | None + Cropped dataset if ``modify_in_place`` is False, otherwise None. + + Examples -------- - Dataset (cropped) only if modify_in_place is False + Crop real-space to a 128x128 region: + + >>> dset_cropped = dset.crop( + ... crop_widths=((64, 192), (64, 192)), + ... axes=(0, 1), + ... ) + + Crop k-space to keep the first 180 pixels: + + >>> dset_preview = dset.crop( + ... crop_widths=((0, 180), (0, 180)), + ... axes=(2, 3), + ... ) + + Crop k-space in-place to free memory: + + >>> dset.crop( + ... crop_widths=((4, 92), (4, 92)), + ... axes=(2, 3), + ... modify_in_place=True, + ... ) """ if axes is None: if len(crop_widths) != self.ndim: raise ValueError("crop_widths must match number of dimensions when axes is None.") axes = tuple(range(self.ndim)) - elif np.isscalar(axes): + elif isinstance(axes, int | float): axes = (int(axes),) crop_widths = (crop_widths[0],) # Take first crop_width for single axis else: @@ -474,29 +574,36 @@ def crop( raise ValueError("Length of crop_widths must match length of axes.") full_slices = [] + new_origin = self.origin.astype(float).copy() crop_dict = dict(zip(axes, crop_widths)) - for axis, _ in enumerate(self.shape): + for axis, axis_size in enumerate(self.shape): if axis in crop_dict: before, after = crop_dict[axis] start = before stop = after if after != 0 else None - full_slices.append(slice(start, stop)) + axis_slice = slice(start, stop) + normalized_start, _, _ = axis_slice.indices(axis_size) + full_slices.append(axis_slice) + new_origin[axis] = new_origin[axis] + normalized_start * self.sampling[axis] else: full_slices.append(slice(None)) if modify_in_place is False: dataset = self.copy() dataset.array = dataset.array[tuple(full_slices)] + dataset.origin = new_origin return dataset self.array = self.array[tuple(full_slices)] + self.origin = new_origin return None @overload def bin( self, bin_factors, - axes, + axes=None, + *, modify_in_place: Literal[True], reducer: str = "sum", ) -> None: ... @@ -517,20 +624,31 @@ def bin( modify_in_place: bool = False, reducer: str = "sum", ) -> Self | None: - """ - Bin the Dataset by integer factors along selected axes using block reduction. + """Reduce the dataset resolution by grouping pixels into blocks + + Useful for reducing diffraction pattern size to speed up + reconstruction or lower memory usage. Sampling metadata is + updated automatically. Parameters ---------- bin_factors : int | tuple[int, ...] - Bin factors per specified axis (positive integers). + A single integer bins all axes by the same factor. A tuple + specifies a different factor per axis, e.g. ``(1, 1, 2, 2)`` + to bin only the last two axes by 2x. axes : int | tuple[int, ...] | None Axes to bin. If None, all axes are binned. modify_in_place : bool - If True, modifies this dataset; otherwise returns a new Dataset. - reducer : {"sum","mean"} - Reduction applied within each block. "sum" (default) preserves counts; - "mean" averages over each block (block volume = product of factors). + If True, modifies this dataset in-place. If False, returns + a new dataset. + reducer : {"sum", "mean"} + Reduction applied within each block. "sum" (default) preserves + counts; "mean" averages over each block. + + Returns + ------- + Dataset | None + Binned dataset if ``modify_in_place`` is False, otherwise None. Notes ----- @@ -538,6 +656,19 @@ def bin( - Sampling is multiplied by the factor on each binned axis. - Origin is shifted to the center of the first block: origin_new = origin_old + 0.5 * (factor - 1) * sampling_old + + Examples + -------- + Bin diffraction space by 2x to reduce memory: + + >>> dset.bin( + ... bin_factors=(1, 1, 2, 2), + ... modify_in_place=True, + ... ) + + Bin all axes by 2x and return a new dataset: + + >>> dset_binned = dset.bin(bin_factors=2) """ reducer_norm = str(reducer).lower() if reducer_norm not in ("sum", "mean"): @@ -545,7 +676,7 @@ def bin( if axes is None: axes = tuple(range(self.ndim)) - elif np.isscalar(axes): + elif isinstance(axes, int | float): axes = (int(axes),) else: axes = tuple(int(ax) for ax in axes) @@ -660,7 +791,7 @@ def fourier_resample( """ if axes is None: axes = tuple(range(self.ndim)) - elif np.isscalar(axes): + elif isinstance(axes, int | float): axes = (int(axes),) else: axes = tuple(int(a0) for a0 in axes) @@ -670,14 +801,17 @@ def fourier_resample( # Resolve out_shape & factors if factors is not None: - if np.isscalar(factors): + if isinstance(factors, int | float): factors = (float(factors),) * len(axes) else: factors = tuple(float(f) for f in factors) if len(factors) != len(axes): raise ValueError("factors length must match number of axes.") - out_shape = tuple(max(1, int(round(self.shape[a1] * f))) for a1, f in zip(axes, factors)) + out_shape = tuple( + max(1, int(round(self.shape[a1] * f))) for a1, f in zip(axes, factors) + ) else: + assert out_shape is not None # Guaranteed by check above if len(out_shape) != len(axes): raise ValueError("out_shape length must match number of axes.") out_shape = tuple(int(nl) for nl in out_shape) @@ -804,20 +938,25 @@ def __getitem__(self, index) -> Self: # Compute which dimensions are kept kept_axes = [i for i, idx in enumerate(index) if not isinstance(idx, (int, np.integer))] + kept_axis_to_index = {axis: j for j, axis in enumerate(kept_axes)} # Slice/reduce metadata accordingly - new_origin = np.asarray(self.origin)[kept_axes] if np.ndim(self.origin) > 0 else self.origin + origin_array = np.asarray(self.origin, dtype=float) + sampling_array = np.asarray(self.sampling, dtype=float) + new_origin = origin_array[kept_axes].copy() if np.ndim(self.origin) > 0 else self.origin new_sampling = ( - np.asarray(self.sampling)[kept_axes] if np.ndim(self.sampling) > 0 else self.sampling + sampling_array[kept_axes].copy() if np.ndim(self.sampling) > 0 else self.sampling ) new_units = [self.units[i] for i in kept_axes] if len(self.units) > 0 else self.units - # Adjust sampling for slice steps (e.g. [::2] doubles spacing) + # Adjust origin/sampling for sliced axes. for i, idx in enumerate(index): - if isinstance(idx, slice) and idx.step not in (None, 1): - if i in kept_axes: - j = kept_axes.index(i) - new_sampling[j] *= idx.step + if isinstance(idx, slice) and i in kept_axis_to_index: + j = kept_axis_to_index[i] + normalized_start, _, normalized_step = idx.indices(self.shape[i]) + new_origin[j] = new_origin[j] + normalized_start * sampling_array[i] + if normalized_step != 1: + new_sampling[j] *= normalized_step out_ndim = array_view.ndim diff --git a/src/quantem/core/datastructures/dataset2d.py b/src/quantem/core/datastructures/dataset2d.py index a5a94eac..13dac401 100644 --- a/src/quantem/core/datastructures/dataset2d.py +++ b/src/quantem/core/datastructures/dataset2d.py @@ -40,7 +40,7 @@ def __init__( name : str A descriptive name for the dataset origin : NDArray | tuple | list | float | int - The origin coordinates for each dimension + The origin coordinates for each dimension in calibrated units sampling : NDArray | tuple | list | float | int The sampling rate/spacing for each dimension units : list[str] | tuple | list @@ -102,9 +102,9 @@ def from_array( name : str | None, optional A descriptive name for the dataset. If None, defaults to "2D dataset" origin : NDArray | tuple | list | float | int | None, optional - The origin coordinates for each dimension. If None, defaults to zeros + The origin coordinates for each dimension in calibrated units. If None, defaults to zeros sampling : NDArray | tuple | list | float | int | None, optional - The sampling rate/spacing for each dimension. If None, defaults to ones + The sampling rate/spacing for each dimension in calibrated units. If None, defaults to ones units : list[str] | tuple | list | None, optional Units for each dimension. If None, defaults to ["pixels"] * 4 signal_units : str, optional diff --git a/src/quantem/core/datastructures/dataset3d.py b/src/quantem/core/datastructures/dataset3d.py index 118ec66f..1af7b4e6 100644 --- a/src/quantem/core/datastructures/dataset3d.py +++ b/src/quantem/core/datastructures/dataset3d.py @@ -41,7 +41,7 @@ def __init__( name : str A descriptive name for the dataset origin : NDArray | tuple | list | float | int - The origin coordinates for each dimension + The origin coordinates for each dimension in calibrated units sampling : NDArray | tuple | list | float | int The sampling rate/spacing for each dimension units : list[str] | tuple | list @@ -80,7 +80,7 @@ def from_array( name : str | None Dataset name. Default: "3D dataset" origin : NDArray | tuple | list | float | int | None - Origin for each dimension. Default: [0, 0, 0] + Origin for each dimension in calibrated units. Default: [0, 0, 0] sampling : NDArray | tuple | list | float | int | None Sampling for each dimension. Default: [1, 1, 1] units : list[str] | tuple | list | None @@ -148,7 +148,7 @@ def from_shape( fill_value : float Value to fill array with. Default: 0.0 origin : NDArray | tuple | list | float | int | None - Origin for each dimension + Origin for each dimension in calibrated units. sampling : NDArray | tuple | list | float | int | None Sampling for each dimension units : list[str] | tuple | list | None @@ -318,8 +318,7 @@ def show( ncols = min(ncols, n_frames) # Don't create more columns than frames images = [self.array[i] for i in frame_idx] labels = [ - f"Frame {i}" if title_prefix is None else f"{title_prefix} {i}" - for i in frame_idx + f"Frame {i}" if title_prefix is None else f"{title_prefix} {i}" for i in frame_idx ] # Pad last row to complete the grid (show_2d requires rectangular input) remainder = n_frames % ncols diff --git a/src/quantem/core/datastructures/dataset4d.py b/src/quantem/core/datastructures/dataset4d.py index 981243df..f5681730 100644 --- a/src/quantem/core/datastructures/dataset4d.py +++ b/src/quantem/core/datastructures/dataset4d.py @@ -1,6 +1,7 @@ from typing import Any, Self, Union import numpy as np +import torch from numpy.typing import NDArray from quantem.core.datastructures.dataset import Dataset @@ -21,11 +22,12 @@ class Dataset4d(Dataset): def __init__( self, - array: NDArray | Any, - name: str, - origin: NDArray | tuple | list | float | int, - sampling: NDArray | tuple | list | float | int, - units: list[str] | tuple | list, + array: NDArray | None = None, + tensor: torch.Tensor | None = None, + name: str = "", + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, signal_units: str = "arb. units", metadata: dict = {}, _token: object | None = None, @@ -34,12 +36,14 @@ def __init__( Parameters ---------- - array : NDArray | Any - The underlying 3D array data + array : NDArray | None + The underlying 4D numpy array. Provide exactly one of ``array`` or ``tensor``. + tensor : torch.Tensor | None + The underlying 4D torch tensor (any device). Provide exactly one of ``array`` or ``tensor``. name : str A descriptive name for the dataset origin : NDArray | tuple | list | float | int - The origin coordinates for each dimension + The origin coordinates for each dimension in calibrated units sampling : NDArray | tuple | list | float | int The sampling rate/spacing for each dimension units : list[str] | tuple | list @@ -51,6 +55,7 @@ def __init__( """ super().__init__( array=array, + tensor=tensor, name=name, origin=origin, sampling=sampling, diff --git a/src/quantem/core/datastructures/dataset4dstem.py b/src/quantem/core/datastructures/dataset4dstem.py index 28328636..004db427 100644 --- a/src/quantem/core/datastructures/dataset4dstem.py +++ b/src/quantem/core/datastructures/dataset4dstem.py @@ -2,6 +2,7 @@ import matplotlib.pyplot as plt import numpy as np +import torch from matplotlib.patches import Circle, Wedge from numpy.typing import NDArray @@ -41,11 +42,12 @@ class Dataset4dstem(Dataset4d): def __init__( self, - array: NDArray | Any, - name: str, - origin: NDArray | tuple | list | float | int, - sampling: NDArray | tuple | list | float | int, - units: list[str] | tuple | list, + array: NDArray | None = None, + tensor: torch.Tensor | None = None, + name: str = "", + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, signal_units: str = "arb. units", metadata: dict = {}, _token: object | None = None, @@ -56,10 +58,13 @@ def __init__( ---------- array : NDArray | Any The underlying 4D array data + tensor : torch.Tensor | None, optional + Alternative to ``array``: the underlying 4D torch tensor (any device). + Provide exactly one of ``array`` or ``tensor``. name : str A descriptive name for the dataset origin : NDArray | tuple | list | float | int - The origin coordinates for each dimension + The origin coordinates for each dimension in calibrated units sampling : NDArray | tuple | list | float | int The sampling rate/spacing for each dimension units : list[str] | tuple | list @@ -79,6 +84,7 @@ def __init__( super().__init__( array=array, + tensor=tensor, name=name, origin=origin, sampling=sampling, @@ -133,7 +139,7 @@ def from_array( name : str | None, optional A descriptive name for the dataset. If None, defaults to "4D-STEM dataset" origin : NDArray | tuple | list | float | int | None, optional - The origin coordinates for each dimension. If None, defaults to zeros + The origin coordinates for each dimension in calibrated units. If None, defaults to zeros sampling : NDArray | tuple | list | float | int | None, optional The sampling rate/spacing for each dimension. If None, defaults to ones units : list[str] | tuple | list | None, optional @@ -157,6 +163,47 @@ def from_array( _token=cls._token, ) + @classmethod + def from_tensor( + cls, + tensor: torch.Tensor, + name: str | None = None, + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, + signal_units: str = "arb. units", + metadata: dict | None = None, + ) -> Self: + """Create a Dataset4dstem from a torch tensor (any device). + + Use this when raw data is GPU-resident (CUDA pipelines, live detector + frames, GPU file readers) to skip the VRAM<->RAM round-trip. + + For cupy / jax arrays, wrap with ``torch.from_dlpack(arr)`` first. + """ + # TODO: factor type + ndim checks into `ensure_valid_tensor(value, ndim=4)` + # in validators.py, matching `ensure_valid_array` pattern. Cuts bloat. + if not isinstance(tensor, torch.Tensor): + raise TypeError( + f"from_tensor requires torch.Tensor, got {type(tensor).__name__}. " + f"For cupy / jax, wrap with `torch.from_dlpack(arr)` first." + ) + if tensor.ndim != 4: + raise ValueError( + f"Dataset4dstem.from_tensor requires a 4D tensor " + f"(scan_row, scan_col, dp_row, dp_col), got shape {tuple(tensor.shape)}." + ) + return cls( + tensor=tensor, + name=name if name is not None else "4D-STEM dataset (torch)", + origin=origin if origin is not None else np.zeros(4), + sampling=sampling if sampling is not None else np.ones(4), + units=units if units is not None else ["pixels"] * 4, + signal_units=signal_units, + metadata=metadata if metadata is not None else {}, + _token=cls._token, + ) + @property def virtual_images(self) -> dict[str, Dataset2d]: """ diff --git a/src/quantem/core/datastructures/vector.py b/src/quantem/core/datastructures/vector.py index 9bc513a4..aa2627d2 100644 --- a/src/quantem/core/datastructures/vector.py +++ b/src/quantem/core/datastructures/vector.py @@ -1,1026 +1,1310 @@ -from typing import ( - Any, - List, - Optional, - Tuple, - Union, - cast, - overload, -) +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any, Literal, Sequence import numpy as np -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray from quantem.core.io.serialize import AutoSerialize from quantem.core.utils.validators import ( validate_fields, validate_num_fields, validate_shape, - validate_vector_data, - validate_vector_data_for_inference, validate_vector_units, ) class Vector(AutoSerialize): - """ - A class for holding vector data with ragged array lengths. This class supports any number of fixed dimensions - (indexed first) followed by a ragged numpy array that can have any number of entries (rows) and columns (fields). - Inherits from AutoSerialize for serialization support. - - Basic Usage: - ----------- - # Create a 2D vector with shape=(4, 3) and 3 named fields - v = Vector.from_shape(shape=(4, 3), fields=['field0', 'field1', 'field2']) - - # Alternative creation with num_fields instead of fields - v = Vector.from_shape(shape=(4, 3), num_fields=3) # Fields will be named field_0, field_1, field_2 - - # Create with custom name and units - v = Vector.from_shape( - shape=(4, 3), - fields=['field0', 'field1', 'field2'], - name='my_vector', - units=['unit0', 'unit1', 'unit2'], - ) - - # Access data at specific indices - data = v[0, 1] # Returns numpy array at position (0,1) - - # Set data at specific indices - v[0, 1] = np.array([[1.0, 2.0, 3.0]]) # Must match num_fields - - # Create a deep copy - v_copy = v.copy() - - Example usage of from_data: - ----------------------------------- - data = [ - np.array([[1, 2], [3, 4]]), - np.array([[5, 6], [7, 8], [9, 10]]) - ] - v = Vector.from_data( - data, - fields=['x', 'y'], - name='my_ragged_vector', - units=['m', 'm'] - ) - - # Or using lists instead of numpy arrays: - data = [ - [[1, 2], [3, 4]], - [[5, 6], [7, 8], [9, 10]], - ] - v = Vector.from_data( - data, - fields=['x', 'y'], - name='my_ragged_vector', - units=['m', 'm'] - ) - - Field Operations: - ---------------- - # Access a specific field - field_data = v['field0'] # Returns a FieldView object - - # Perform operations on a field - v['field0'] += 16 # Add 16 to all field0 values - - # Apply a function to a field - v['field2'] = lambda x: x * 2 # Double all field2 values - - # Get flattened field data - field_flat = v['field0'].flatten() # Returns 1D numpy array - - # Set field data from flattened array - v['field2'].set_flattened(new_values) # Must match total length - - Advanced Operations: - ------------------- - # Complex field calculations - scale = v['field0'].flatten() / (v['field0'].flatten()**2 + v['field1'].flatten()**2) - v['field2'].set_flattened(v['field2'].flatten() * scale) - - # Slicing and assignment - v[2:4, 1] = v[1:3, 1] # Copy data from one region to another - - # Boolean indexing - mask = v['field0'].flatten() > 0 - v['field2'].set_flattened(v['field2'].flatten() * mask) - - # Field management - v.add_fields(('field3', 'field4', 'field5')) # Add new fields - v.remove_fields(('field3', 'field4', 'field5')) # Remove fields - - Direct Data Access: - ------------------ - # Get data with integer indexing - data = v.get_data(0, 1) # Returns numpy array at (0,1) - - # Get data with slice indexing - data = v.get_data(slice(0, 2), 1) # Returns list of arrays for rows 0-1 at column 1 - - # Set data with integer indexing - v.set_data(np.array([[1.0, 2.0, 3.0]]), 0, 1) # Set data at (0,1) - - # Set data with slice indexing - v.set_data([np.array([[1.0, 2.0, 3.0]]), np.array([[4.0, 5.0, 6.0]])], - slice(0, 2), 1) # Set data for rows 0-1 at column 1 - - Notes: + """Ragged cell data on a fixed grid. + + A ``Vector`` has two independent axes of structure: + - fixed-grid dimensions given by ``shape`` + - ragged rows stored inside each fixed-grid cell + + Each ragged row has one value per named field, so each cell behaves like a + small 2D array with shape ``(n_rows, num_fields)``, where ``n_rows`` may + vary from cell to cell. + + Parameters + ---------- + shape : tuple of int + Fixed-grid shape. + fields : sequence of str + Field names in column order. + units : sequence of str, optional + Units corresponding to ``fields``. If omitted, units default to + ``"none"`` for all fields. + name : str, optional + Descriptive name for the Vector. + metadata : dict, optional + Additional user metadata. + + Notes ----- - - All numpy arrays stored in the vector must have the same number of columns (fields) - - Field names must be unique - - Slicing operations return new Vector instances - - Field operations are performed in-place - - Units are stored for each field and can be accessed via the units attribute - - The name attribute can be used to identify the vector in a larger context + The public API keeps fixed-grid indexing and field selection separate: + - use ``[]`` for fixed-grid indexing + - use ``select_fields(...)`` for field selection + + Fixed-grid indexing always returns a ``Vector``. A 0D selection exposes its + underlying cell array through ``.array``. Multi-cell selections can be + concatenated with ``flatten()``. + + The internal representation is compact: + - ``_state["data"]`` stores all ragged rows in one numeric 2D array + - ``_state["cell_starts"]`` stores the start offset for each cell + - ``_state["cell_lengths"]`` stores the row count for each cell + + A ``Vector`` selection is a write-through view over shared storage. Views + track only the selected fixed-grid shape, selected cell indices, and selected + field names. + + Examples + -------- + Create a Vector and assign one cell: + + >>> import numpy as np + >>> v = Vector.from_shape((2, 2), fields=("kx", "ky", "intensity")) + >>> v[0, 0] = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + >>> v[0, 0].array.shape + (2, 3) + + Select fields and apply in-place arithmetic: + + >>> kx = v.select_fields("kx") + >>> kx += 16 + >>> kx.flatten().shape + (2, 1) + + Apply a rowwise transform with ``flatten()`` and ``set_flattened()``: + + >>> kx = v.select_fields("kx") + >>> ky = v.select_fields("ky") + >>> kx.set_flattened( + ... np.where( + ... ((kx.flatten() - 16) ** 2 + (ky.flatten() - 16) ** 2) < 12, + ... 10, + ... kx.flatten(), + ... ) + ... ) """ + __array_priority__ = 1000 _token = object() + # ------------------------------------------------------------------ # + # Construction + # ------------------------------------------------------------------ # + def __init__( self, - shape: Tuple[int, ...], - fields: List[str], - units: List[str], - name: str, - metadata: dict = {}, + shape: tuple[int, ...], + fields: Sequence[str], + units: Sequence[str] | None = None, + name: str | None = None, + metadata: dict[str, Any] | None = None, _token: object | None = None, ) -> None: if _token is not self._token: - raise RuntimeError("Use Vector.from_shape() or Vector.from_data() to instantiate.") + raise RuntimeError( + "Use Vector.from_shape() or Vector.from_data() to instantiate this class." + ) + root_shape = validate_shape(shape) + root_fields = validate_fields(list(fields)) + root_units = validate_vector_units( + list(units) if units is not None else None, + len(root_fields), + ) - self.shape = shape - self.fields = fields - self.units = units - self.name = name - self._data = nested_list(self.shape, fill=None) - self._metadata = metadata + self._state = { + "shape": root_shape, + "fields": list(root_fields), + "units": list(root_units), + "name": name or f"{len(root_shape)}d ragged array", + "metadata": dict(metadata or {}), + "data": np.empty((0, len(root_fields)), dtype=float), + "cell_starts": np.zeros(_cell_count(root_shape), dtype=np.int64), + "cell_lengths": np.zeros(_cell_count(root_shape), dtype=np.int64), + } + self._selection_shape = root_shape + self._selection_indices: NDArray[np.int64] | None = None + self._selected_fields: tuple[str, ...] | None = None @classmethod - def from_shape( + def _from_view( cls, - shape: Tuple[int, ...], - num_fields: Optional[int] = None, - fields: Optional[List[str]] = None, - units: Optional[List[str]] = None, - name: Optional[str] = None, + state: dict[str, Any], + selection_shape: tuple[int, ...], + selection_indices: NDArray[np.int64] | None, + selected_fields: tuple[str, ...] | None, ) -> "Vector": - """ - Factory method to create a Vector with the specified shape and fields. - - Parameters - ---------- - shape : Tuple[int, ...] - The shape of the vector (dimensions) - num_fields : Optional[int] - Number of fields in the vector - name : Optional[str] - Name of the vector - fields : Optional[List[str]] - List of field names - units : Optional[List[str]] - List of units for each field - - Returns - ------- - Vector - A new Vector instance - """ - validated_shape = validate_shape(shape) - ndim = len(validated_shape) - - if fields is not None: - validated_fields = validate_fields(fields) - validated_num_fields = len(validated_fields) - if num_fields is not None and validated_num_fields != num_fields: - raise ValueError( - f"num_fields ({num_fields}) does not match length of fields ({validated_num_fields})" - ) - elif num_fields is not None: - validated_num_fields = validate_num_fields(num_fields) - validated_fields = [f"field_{i}" for i in range(validated_num_fields)] - else: - raise ValueError("Must specify either 'fields' or 'num_fields'.") - - validated_units = validate_vector_units(units, validated_num_fields) - name = name or f"{ndim}d ragged array" + """Build a view that shares backing storage with another Vector.""" + obj = cls.__new__(cls) + obj._state = state + obj._selection_shape = selection_shape + obj._selection_indices = ( + None if selection_indices is None else selection_indices.astype(np.int64, copy=False) + ) + obj._selected_fields = selected_fields + return obj + @classmethod + def from_shape( + cls, + shape: tuple[int, ...], + num_fields: int | None = None, + fields: Sequence[str] | None = None, + units: Sequence[str] | None = None, + name: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> "Vector": + """Create an empty Vector with the given fixed-grid shape and fields.""" + fields = _resolve_fields(fields, num_fields, None) return cls( - shape=validated_shape, - fields=validated_fields, - units=validated_units, + shape=shape, + fields=fields, + units=units, name=name, + metadata=metadata, _token=cls._token, ) @classmethod def from_data( cls, - data: List[Any], - num_fields: Optional[int] = None, - fields: Optional[List[str]] = None, - units: Optional[List[str]] = None, - name: Optional[str] = None, + data: Sequence[Any], + num_fields: int | None = None, + fields: Sequence[str] | None = None, + units: Sequence[str] | None = None, + name: str | None = None, + metadata: dict[str, Any] | None = None, ) -> "Vector": - """ - Factory method to create a Vector from a list of - ragged lists or ragged numpy arrays. + """Create a Vector from nested fixed-grid data. - Parameters - ---------- - data : List[Any] - A list of ragged lists containing the vector data. - Each element should be a numpy array with shape (n, num_fields). - num_fields : Optional[int] - Number of fields in the vector. If not provided, it will be inferred from the data. - fields : Optional[List[str]] - List of field names - units : Optional[List[str]] - List of units for each field - name : Optional[str] - Name of the vector - - Returns - ------- - Vector - A new Vector instance with the provided data - - Raises - ------ - ValueError - If the data structure is invalid or inconsistent - TypeError - If the data contains invalid types + The outer nesting defines the fixed-grid shape. Each leaf must coerce to a + 2D cell array with consistent field count across all cells. """ - inferred_shape, inferred_num_fields = validate_vector_data_for_inference(data) - - final_num_fields = num_fields or inferred_num_fields - if num_fields is not None and num_fields != inferred_num_fields: - raise ValueError( - f"Provided num_fields ({num_fields}) does not match inferred ({inferred_num_fields})." - ) - - vector = cls.from_shape( - shape=inferred_shape, - num_fields=final_num_fields, - fields=fields, + if not isinstance(data, (list, tuple)): + raise TypeError(f"Data must be a list or tuple, got {type(data)}") + root_shape, cell_arrays = _flatten_fixed_grid(data) if len(data) > 0 else ((0,), []) + inferred_counts = {array.shape[1] for array in cell_arrays} + if len(inferred_counts) > 1: + raise ValueError("All cell arrays must have the same number of fields.") + inferred_fields = cell_arrays[0].shape[1] if cell_arrays else 0 + + vector = cls( + shape=root_shape, + fields=_resolve_fields(fields, num_fields, inferred_fields), units=units, name=name, + metadata=metadata, + _token=cls._token, ) - - # Now fully validate and set the data - vector.data = data + vector._replace_cells(np.arange(len(cell_arrays), dtype=np.int64), cell_arrays) return vector - def get_data( - self, *indices: Union[int, slice, List[int], np.ndarray[Any, np.dtype[Any]]] - ) -> Union[NDArray, List[NDArray]]: - """ - Get data at specified indices. - - Parameters: - ----------- - *indices : Union[int, slice, List[int], np.ndarray] - Indices to access. Must match the number of dimensions in the vector. - Supports fancy indexing with lists or numpy arrays. - - Returns: - -------- - numpy.ndarray or list - The data at the specified indices. - - Raises: - ------- - IndexError - If indices are out of bounds. - ValueError - If the number of indices does not match the vector dimensions. - """ - if len(indices) != len(self._shape): - raise ValueError(f"Expected {len(self._shape)} indices, got {len(indices)}") - - # Handle fancy indexing and slicing - def get_indices(dim_idx: Any, dim_size: int) -> np.ndarray: - if isinstance(dim_idx, slice): - start, stop, step = dim_idx.indices(dim_size) - return np.arange(start, stop, step) - elif isinstance(dim_idx, (np.ndarray, list)): - idx = np.asarray(dim_idx) - if np.any((idx < 0) | (idx >= dim_size)): - raise IndexError(f"Index out of bounds for axis with size {dim_size}") - return idx - elif isinstance(dim_idx, (int, np.integer)): - if dim_idx < 0 or dim_idx >= dim_size: - raise IndexError( - f"Index {dim_idx} out of bounds for axis with size {dim_size}" - ) - return np.array([dim_idx]) - return np.arange(dim_size) - - # Get indices for each dimension - indices_arrays = [get_indices(i, s) for i, s in zip(indices, self._shape)] - - # If all indices are single integers, return a single array - if all(len(i) == 1 for i in indices_arrays): - ref = self._data - for idx in (i[0] for i in indices_arrays): - ref = ref[idx] - return ref - - # Create result structure for fancy indexing - result = [] - for idx in np.ndindex(*[len(i) for i in indices_arrays]): - src_idx = tuple(ind[i] for ind, i in zip(indices_arrays, idx)) - result.append(self._data[src_idx[0]][src_idx[1]]) + # ------------------------------------------------------------------ # + # Identity properties + # ------------------------------------------------------------------ # - return result + @property + def name(self) -> str: + """Human-readable Vector name.""" + return self._state["name"] - def set_data( - self, - value: Union[NDArray, List[NDArray]], - *indices: Union[int, slice, List[int], np.ndarray[Any, np.dtype[Any]]], - ) -> None: - """ - Set data at specified indices. + @name.setter + def name(self, value: str) -> None: + self._state["name"] = str(value) - Parameters - ---------- - value : Union[NDArray, List[NDArray]] - The numpy array(s) to set at the specified indices. Must have shape (_, num_fields). - For fancy indexing, can be a list of arrays. - *indices : Union[int, slice, List[int], np.ndarray] - Indices to set data at. Must match the number of dimensions in the vector. - Supports fancy indexing with lists or numpy arrays. - - Raises - ------ - IndexError - If indices are out of bounds. - ValueError - If the number of indices does not match the vector dimensions, - or if the value shape doesn't match the expected shape. - TypeError - If the value is not a numpy array or list of numpy arrays. - """ - if len(indices) != len(self._shape): - raise ValueError(f"Expected {len(self._shape)} indices, got {len(indices)}") - - # Handle fancy indexing and slicing - def get_indices(dim_idx: Any, dim_size: int) -> np.ndarray: - if isinstance(dim_idx, slice): - start, stop, step = dim_idx.indices(dim_size) - return np.arange(start, stop, step) - elif isinstance(dim_idx, (np.ndarray, list)): - idx = np.asarray(dim_idx) - if np.any((idx < 0) | (idx >= dim_size)): - raise IndexError(f"Index out of bounds for axis with size {dim_size}") - return idx - elif isinstance(dim_idx, (int, np.integer)): - if dim_idx < 0 or dim_idx >= dim_size: - raise IndexError( - f"Index {dim_idx} out of bounds for axis with size {dim_size}" - ) - return np.array([dim_idx]) - return np.arange(dim_size) - - # Get indices for each dimension - indices_arrays = [get_indices(i, s) for i, s in zip(indices, self._shape)] - - # If all indices are single integers, handle as single value - if all(len(i) == 1 for i in indices_arrays): - if not isinstance(value, np.ndarray): - raise TypeError(f"Value must be a numpy array, got {type(value).__name__}") - if value.ndim != 2 or value.shape[1] != self.num_fields: - raise ValueError( - f"Expected a numpy array with shape (_, {self.num_fields}), got {value.shape}" - ) - ref = self._data - for idx in (i[0] for i in indices_arrays[:-1]): - ref = ref[idx] - ref[indices_arrays[-1][0]] = value - return + @property + def metadata(self) -> dict[str, Any]: + """Mutable metadata dictionary shared by all views.""" + return self._state["metadata"] - # Handle fancy indexing - if not isinstance(value, list): - raise TypeError("For fancy indexing, value must be a list of numpy arrays") - - # Validate and set values - for idx in np.ndindex(*[len(i) for i in indices_arrays]): - src_idx = tuple(ind[i] for ind, i in zip(indices_arrays, idx)) - if not isinstance(value[idx[0]], np.ndarray): - raise TypeError(f"Expected numpy array, got {type(value[idx[0]]).__name__}") - if value[idx[0]].ndim != 2 or value[idx[0]].shape[1] != self.num_fields: - raise ValueError( - f"Expected array with shape (_, {self.num_fields}), got {value[idx[0]].shape}" - ) - ref = self._data - for i in src_idx[:-1]: - ref = ref[i] - ref[src_idx[-1]] = value[idx[0]] - - @overload - def __getitem__(self, idx: str) -> "_FieldView": ... - @overload - def __getitem__( - self, - idx: Union[Tuple[Union[int, slice, List[int]], ...], int, slice, List[int]], - ) -> Union[NDArray, "Vector"]: ... + # ------------------------------------------------------------------ # + # Shape & structure properties + # ------------------------------------------------------------------ # - def __getitem__( - self, - idx: Union[str, Tuple[Union[int, slice, List[int]], ...], int, slice, List[int]], - ) -> Union["_FieldView", NDArray, "Vector"]: - """Get data or a view of the vector at specified indices.""" - if isinstance(idx, str): - if idx not in self._fields: - raise KeyError(f"Field '{idx}' not found.") - return _FieldView(self, idx) - - # Normalize idx to tuple - normalized: Tuple[Any, ...] = (idx,) if not isinstance(idx, tuple) else idx - - # Convert lists/arrays to ndarray - idx_converted: Tuple[Union[int, slice, np.ndarray[Any, np.dtype[Any]]], ...] = tuple( - np.asarray(i) if isinstance(i, (list, np.ndarray)) else i for i in normalized - ) + @property + def shape(self) -> tuple[int, ...]: + """Return the fixed-grid shape of this selection.""" + return self._selection_shape - # Check if we should return a numpy array (all indices are integers) - return_np = all(isinstance(i, (int, np.integer)) for i in idx_converted[: len(self.shape)]) - if len(idx_converted) < len(self.shape): - return_np = False - - if return_np: - view = self._data - for i in idx_converted: - view = view[i] - return cast(NDArray[Any], view) - - # Handle fancy indexing and slicing - def get_indices(dim_idx: Any, dim_size: int) -> np.ndarray: - if isinstance(dim_idx, slice): - start, stop, step = dim_idx.indices(dim_size) - return np.arange(start, stop, step) - elif isinstance(dim_idx, (np.ndarray, list)): - return np.asarray(dim_idx) - elif isinstance(dim_idx, (int, np.integer)): - return np.array([dim_idx]) - return np.arange(dim_size) - - # Get indices for each dimension - full_idx = list(idx_converted) + [slice(None)] * (len(self.shape) - len(idx_converted)) - indices = [get_indices(i, s) for i, s in zip(full_idx, self.shape)] - - # Create new shape and data - new_shape = [len(i) for i in indices] - new_data = [[None] * new_shape[-1] for _ in range(new_shape[0])] - - # Fill the new data structure - for out_idx in np.ndindex(*new_shape): - src_idx = tuple(ind[i] for ind, i in zip(indices, out_idx)) - new_data[out_idx[0]][out_idx[1]] = self._data[src_idx[0]][src_idx[1]] - - # Create new Vector - vector_new = Vector.from_shape( - shape=tuple(new_shape), - num_fields=self.num_fields, - name=self.name + "[view]", - fields=self.fields, - units=self.units, - ) - vector_new._data = new_data - return vector_new + @property + def fields(self) -> list[str]: + """Return selected field names in column order.""" + if self._selected_fields is None: + return list(self._state["fields"]) + return list(self._selected_fields) - def __setitem__( - self, - idx: Union[Tuple[Union[int, slice, List[int]], ...], int, slice, List[int], str], - value: Union[NDArray, List[NDArray]], - ) -> None: - """Set data at specified indices.""" - if isinstance(idx, str): - if idx not in self._fields: - raise KeyError(f"Field '{idx}' not found.") - field_view = _FieldView(self, idx) - field_view.set_flattened(value) - return + @property + def units(self) -> list[str]: + """Return units for the selected fields.""" + lookup = dict(zip(self._state["fields"], self._state["units"])) + return [lookup[field] for field in self.fields] - # Normalize idx to tuple - normalized: Tuple[Any, ...] = (idx,) if not isinstance(idx, tuple) else idx + @property + def num_fields(self) -> int: + """Return the number of selected fields.""" + return len(self.fields) - # Convert lists/arrays to ndarray - idx_converted: Tuple[Union[int, slice, np.ndarray[Any, np.dtype[Any]]], ...] = tuple( - np.asarray(i) if isinstance(i, (list, np.ndarray)) else i for i in normalized - ) + @property + def num_cells(self) -> int: + """Return the number of fixed-grid cells in the current selection.""" + return int(self._selected_cell_indices().size) - # Check if we're doing slice‐ or array‐based (multi‐cell) indexing - has_fancy = any( - isinstance(i, slice) or (isinstance(i, np.ndarray) and i.size > 1) - for i in idx_converted[: len(self.shape)] - ) + @property + def total_rows(self) -> int: + """Return the total ragged-row count in the current selection.""" + return int(self._state["cell_lengths"][self._selected_cell_indices()].sum()) - if has_fancy: - # If user passed a Vector, extract its cell arrays - if isinstance(value, Vector): - - def _flatten_cells(data): - if isinstance(data, np.ndarray): - return [data] - out = [] - for sub in data: - out.extend(_flatten_cells(sub)) - return out - - value = _flatten_cells(value._data) - - # For fancy indexing, value should be a list of arrays - if not isinstance(value, list): - raise TypeError( - "For fancy/slice indexing, value must be a list of numpy arrays or a Vector" - ) - - # Get indices for each dimension - def get_indices(dim_idx: Any, dim_size: int) -> np.ndarray: - if isinstance(dim_idx, slice): - start, stop, step = dim_idx.indices(dim_size) - return np.arange(start, stop, step) - elif isinstance(dim_idx, (np.ndarray, list)): - idx = np.asarray(dim_idx) - if np.any((idx < 0) | (idx >= dim_size)): - raise IndexError(f"Index out of bounds for axis with size {dim_size}") - return idx - elif isinstance(dim_idx, (int, np.integer)): - if dim_idx < 0 or dim_idx >= dim_size: - raise IndexError(f"Index out of bounds for axis with size {dim_size}") - return np.array([dim_idx]) - return np.arange(dim_size) - - indices_arrays = [get_indices(i, s) for i, s in zip(idx_converted, self._shape)] - total_indices = np.prod([len(i) for i in indices_arrays]) - - if len(value) != total_indices: - raise ValueError(f"Expected {total_indices} arrays, got {len(value)}") - - # Validate and set values - for array_idx, idx in enumerate(np.ndindex(*[len(i) for i in indices_arrays])): - src_idx = tuple(ind[i] for ind, i in zip(indices_arrays, idx)) - if not isinstance(value[array_idx], np.ndarray): - raise TypeError(f"Expected numpy array, got {type(value[array_idx]).__name__}") - if value[array_idx].ndim != 2 or value[array_idx].shape[1] != self.num_fields: - raise ValueError( - f"Expected array with shape (_, {self.num_fields}), got {value[array_idx].shape}" - ) - ref = self._data - for i in src_idx[:-1]: - ref = ref[i] - ref[src_idx[-1]] = value[array_idx] - else: - # For single value assignment - if not isinstance(value, np.ndarray): - raise TypeError(f"Value must be a numpy array, got {type(value).__name__}") - if value.ndim != 2 or value.shape[1] != self.num_fields: - raise ValueError( - f"Expected a numpy array with shape (_, {self.num_fields}), got {value.shape}" - ) - ref = self._data - for i in idx_converted[:-1]: - ref = ref[i] - ref[idx_converted[-1]] = value - - def add_fields(self, new_fields: Union[str, List[str]]) -> None: + @property + def dtype(self) -> np.dtype[Any]: + """Return the NumPy dtype of the backing row buffer.""" + return self._state["data"].dtype + + # ------------------------------------------------------------------ # + # Data access + # ------------------------------------------------------------------ # + + @property + def array(self) -> NDArray[Any]: + """Return the selected cell as a NumPy array. + + This is only valid for 0D selections. Single-field and contiguous + multi-field selections return writable views into the backing storage. + Non-contiguous multi-field selections return a copy because NumPy cannot + expose a writable column-subset view for that layout. """ - Add new fields to the vector. + if self.shape != (): + raise ValueError(".array is only valid when the selection contains exactly one cell.") + cell = self._cell_matrix(self._selected_cell_indices()[0]) + cols = self._field_indices() + if cols.size == self._full_num_fields: + return cell + if cols.size == 1: + col = int(cols[0]) + return cell[:, col : col + 1] + if _is_contiguous(cols): + return cell[:, int(cols[0]) : int(cols[-1]) + 1] + return cell[:, cols].copy() + + def flatten(self) -> NDArray[Any]: + """Concatenate selected cells in row-major order. + + Returns a 2D array with shape ``(total_rows, num_fields)`` even for + single-field selections. + """ + arrays = [ + self._selected_cell_matrix(index) + for index in self._selected_cell_indices() + if self._cell_row_count(index) > 0 + ] + if arrays: + return np.vstack(arrays) - Parameters - ---------- - new_fields : Union[str, List[str]] - Field name(s) to add. Must be unique and not already present. + dtype = self._state["data"].dtype if self._state["data"].ndim == 2 else float + return np.empty((0, self.num_fields), dtype=dtype) + + def row_counts(self) -> list[int]: + """Return per-cell row counts in the current selection order.""" + return [self._cell_row_count(int(index)) for index in self._selected_cell_indices()] - Raises - ------ - ValueError - If any field name already exists or if there are duplicates + # ------------------------------------------------------------------ # + # Field management + # ------------------------------------------------------------------ # + + def select_fields(self, *field_names: str | Sequence[str]) -> "Vector": + """Return a view containing only the requested fields. + + Accepted forms: + - ``select_fields("kx")`` + - ``select_fields("kx", "ky")`` + - ``select_fields(["kx", "ky"])`` """ - if isinstance(new_fields, str): - new_fields = [new_fields] + if not field_names: + raise ValueError("At least one field name is required.") + if len(field_names) == 1 and not isinstance(field_names[0], str): + selected = _normalize_field_names(field_names[0]) + elif not all(isinstance(n, str) for n in field_names): + raise TypeError( + "select_fields(...) expects field names as strings or one sequence of strings." + ) else: - new_fields = list(new_fields) + selected = _normalize_field_names(field_names) # type: ignore[arg-type] + available = set(self.fields) + missing = [field for field in selected if field not in available] + if missing: + raise KeyError(f"Unknown field(s): {missing}") + + selected_fields = None if selected == tuple(self._state["fields"]) else selected + return self._from_view( + self._state, + self.shape, + self._selection_indices, + selected_fields, + ) - if any(name in self._fields for name in new_fields): + def add_fields( + self, + names: str | Sequence[str], + values: Any | None = None, + units: str | Sequence[str] | None = None, + ) -> None: + """Add one or more new fields to the full Vector schema.""" + self._require_full_field_view("add_fields") + new_fields = _normalize_field_names(names) + if any(field in self._state["fields"] for field in new_fields): raise ValueError("One or more new field names already exist.") - if len(set(new_fields)) != len(new_fields): - raise ValueError("Duplicate field names in input are not allowed.") + new_units = _normalize_units(units, len(new_fields)) + old_fields = list(self._state["fields"]) + self._state["fields"].extend(new_fields) + self._state["units"].extend(new_units) + self._expand_storage(len(new_fields)) - self._fields = list(self._fields) + list(new_fields) - self._units = list(self._units) + ["none"] * len(new_fields) + if values is None: + return - def expand_array(arr: Any) -> Any: - if isinstance(arr, np.ndarray): - if arr.shape[1] != self.num_fields - len(new_fields): - raise ValueError( - f"Expected arrays with {self.num_fields - len(new_fields)} fields, got {arr.shape[1]}" - ) - pad = np.zeros((arr.shape[0], len(new_fields))) - return np.hstack([arr, pad]) - elif isinstance(arr, list): - return [expand_array(sub) for sub in arr] - else: - return arr + target = self.select_fields(*new_fields) + if ( + len(new_fields) > 1 + and isinstance(values, (list, tuple)) + and len(values) == len(new_fields) + ): + for field, value in zip(new_fields, values): + target.select_fields(field)[...] = value + else: + target[...] = values - self._data = expand_array(self._data) + if self._selected_fields is not None and tuple(old_fields) == self._selected_fields: + self._selected_fields = None - def remove_fields(self, fields_to_remove: Union[str, List[str]]) -> None: - """ - Remove fields from the vector. + def rename_fields(self, mapping: dict[str, str]) -> None: + """Rename one or more fields in-place. Parameters ---------- - fields_to_remove : Union[str, List[str]] - Field name(s) to remove. Must exist in the vector. - - Raises - ------ - ValueError - If any field doesn't exist + mapping : dict + Maps each old field name to its new name, e.g. + ``{"kx": "qx", "ky": "qy"}``. """ - if isinstance(fields_to_remove, str): - fields_to_remove = [fields_to_remove] - else: - fields_to_remove = list(fields_to_remove) + old_field_set = set(self._state["fields"]) + missing = [old for old in mapping if old not in old_field_set] + if missing: + raise KeyError(f"Unknown field(s): {missing}") + new_names = list(mapping.values()) + conflicts = [n for n in new_names if n in old_field_set and n not in mapping] + if conflicts: + raise ValueError(f"New field name(s) already exist: {conflicts}") + validate_fields(new_names) + + rename = {old: new for old, new in mapping.items()} + self._state["fields"] = [rename.get(f, f) for f in self._state["fields"]] + if self._selected_fields is not None: + self._selected_fields = tuple(rename.get(f, f) for f in self._selected_fields) + + def remove_fields(self, names: str | Sequence[str]) -> None: + """Remove one or more fields from the full Vector schema.""" + self._require_full_field_view("remove_fields") + to_remove = set(_normalize_field_names(names)) + old_fields = self._state["fields"] + old_units = self._state["units"] + + missing = [field for field in to_remove if field not in old_fields] + if missing: + raise KeyError(f"Unknown field(s): {missing}") + if len(to_remove) == len(old_fields): + raise ValueError("Cannot remove all fields from a Vector.") + + keep = [i for i, field in enumerate(old_fields) if field not in to_remove] + self._state["fields"] = [old_fields[i] for i in keep] + self._state["units"] = [old_units[i] for i in keep] + self._state["data"] = self._state["data"][:, keep] + + if self._selected_fields is not None: + self._selected_fields = tuple( + field for field in self._selected_fields if field in self._state["fields"] + ) + if len(self._selected_fields) == len(self._state["fields"]): + self._selected_fields = None - field_to_index = {name: i for i, name in enumerate(self._fields)} - indices_to_remove = [] - for field in fields_to_remove: - if field not in field_to_index: - print(f"Warning: field '{field}' not found.") - else: - indices_to_remove.append(field_to_index[field]) + # ------------------------------------------------------------------ # + # Cell / row mutation + # ------------------------------------------------------------------ # - if not indices_to_remove: - return + def append_rows(self, idx: Any, rows: Any) -> None: + """Append one or more rows to a single selected cell. - indices_to_remove = sorted(set(indices_to_remove)) - keep_indices = [i for i in range(self.num_fields) if i not in indices_to_remove] + ``idx`` is interpreted with the same fixed-grid indexing rules as + ``__getitem__`` and must resolve to exactly one cell. Appending rows is a + full-cell operation, so all fields must be selected. + """ + target = self[idx] + if target.shape != (): + raise ValueError("append_rows requires an index that selects exactly one cell.") + target._require_full_field_view("append_rows") - # Update metadata - self._fields = [self._fields[i] for i in keep_indices] - self._units = [self._units[i] for i in keep_indices] + new_rows = _coerce_cell_array(rows, target.num_fields) + if new_rows.shape[0] == 0: + return - def prune_array(arr: Any) -> Any: - if isinstance(arr, np.ndarray): - if arr.shape[1] < max(indices_to_remove) + 1: - raise ValueError( - f"Cannot remove field index {max(indices_to_remove)} from array with shape {arr.shape}" - ) - return arr[:, keep_indices] - elif isinstance(arr, list): - return [prune_array(sub) for sub in arr] - else: - return arr + cell_index = int(target._selected_cell_indices()[0]) + existing = target._cell_matrix(cell_index) + combined = np.vstack((existing, new_rows)) if existing.shape[0] > 0 else new_rows.copy() + target._replace_cells(np.array([cell_index], dtype=np.int64), [combined]) - self._data = prune_array(self._data) + def set_flattened(self, values: Any) -> None: + """Write values back in flattened row-major order. - def copy(self) -> "Vector": + This updates existing rows without changing per-cell row counts. It is + the rowwise companion to ``flatten()`` and is especially useful for + NumPy-based transforms that operate on all selected rows at once. """ - Create a deep copy of the vector. + field_indices = self._field_indices() + targets = self._selected_cell_indices() + row_counts = self.row_counts() + total_rows = sum(row_counts) + + if isinstance(values, Vector): + if values.num_fields != self.num_fields: + raise ValueError(f"Expected {self.num_fields} fields, got {values.num_fields}") + flat_values = values.flatten() + if flat_values.shape[0] != total_rows: + raise ValueError(f"Expected {total_rows} rows, got {flat_values.shape[0]}") + else: + flat_values = _broadcast_field_values(values, total_rows, self.num_fields) - Returns - ------- - Vector - A new Vector instance with the same data, shape, fields, and units. + cursor = 0 + for target, rows in zip(targets, row_counts): + cell = self._cell_matrix(int(target)) + if rows > 0: + cell[:, field_indices] = flat_values[cursor : cursor + rows] + cursor += rows + + def compact(self) -> None: + """Repack the backing row buffer to remove dead rows. + + Whole-cell replacement appends new rows and leaves previous rows unused + until compaction. Calling ``compact()`` makes memory usage and save size + predictable at the cost of reallocating the backing buffer. """ - import copy + data = self._state["data"] + used_rows = int(self._state["cell_lengths"].sum()) + if used_rows == 0: + self._state["data"] = np.empty((0, self._full_num_fields), dtype=data.dtype) + self._state["cell_starts"].fill(0) + return + + compacted = np.empty((used_rows, self._full_num_fields), dtype=data.dtype) + starts = np.zeros_like(self._state["cell_starts"]) + cursor = 0 + for linear_index in range(_cell_count(self._state["shape"])): + length = self._cell_row_count(linear_index) + starts[linear_index] = cursor + if length > 0: + cell = self._cell_matrix(linear_index) + compacted[cursor : cursor + length] = cell + cursor += length + self._state["data"] = compacted + self._state["cell_starts"] = starts + + # ------------------------------------------------------------------ # + # Python data model + # ------------------------------------------------------------------ # + + def __len__(self) -> int: + """Return ``shape[0]`` for non-scalar selections.""" + if self.shape == (): + raise TypeError("len() of unsized 0D Vector") + return self.shape[0] - vector_copy = Vector.from_shape( + def __repr__(self) -> str: + return "\n".join( + [ + f"quantem.Vector, shape={self.shape}, name={self.name}", + f" fields = {self.fields}", + f" units: {self.units}", + ] + ) + + __str__ = __repr__ + + def copy(self) -> "Vector": + """Return a deep copy of the current selection.""" + copied = self.__class__( shape=self.shape, - name=self.name, fields=self.fields, units=self.units, + name=self.name, + metadata=copy.deepcopy(self.metadata), + _token=self.__class__._token, + ) + target_cells = copied._selected_cell_indices() + source_arrays = [ + self._selected_cell_matrix(index).copy() for index in self._selected_cell_indices() + ] + copied._replace_cells(target_cells, source_arrays) + return copied + + def __getitem__(self, idx: Any) -> "Vector": + """Return a fixed-grid selection as another Vector view.""" + if _looks_like_field_selector(idx): + raise TypeError("Use select_fields(...) for field selection.") + if idx is Ellipsis: + return self + + selection_shape, selection_indices = _select_linear_indices( + self.shape, + self._selected_cell_indices(), + idx, + ) + return self._from_view( + self._state, + selection_shape, + selection_indices, + self._selected_fields, ) - vector_copy._data = copy.deepcopy(self._data) - return vector_copy - def flatten(self) -> NDArray: - """ - Flatten the vector into a 2D numpy array. + def __setitem__(self, idx: Any, value: Any) -> None: + """Assign to a fixed-grid selection.""" + if idx is Ellipsis: + target = self + else: + target = self[idx] + target._assign(value) - Returns - ------- - NDArray - A 2D numpy array containing all data, with shape (total_rows, num_fields). - """ + # ------------------------------------------------------------------ # + # Arithmetic operators + # ------------------------------------------------------------------ # - def collect_arrays(data: Any) -> List[NDArray]: - if isinstance(data, np.ndarray): - return [data] - elif isinstance(data, list): - arrays = [] - for item in data: - arrays.extend(collect_arrays(item)) - return arrays - else: - return [] - - arrays = collect_arrays(self._data) - if not arrays: - return np.empty((0, self.num_fields)) - return np.vstack(arrays) + def __array_ufunc__(self, ufunc: Any, method: str, *inputs: Any, **kwargs: Any) -> Any: + """Apply supported NumPy ufuncs elementwise. - def __repr__(self) -> str: - description = [ - f"quantem.Vector, shape={self._shape}, name={self._name}", - f" fields = {self._fields}", - f" units: {self._units}", + Supported operations are limited to elementwise ``__call__`` ufuncs. The + result preserves the current selection shape and fields. + """ + if method != "__call__": + return NotImplemented + + out = kwargs.get("out") + if out is not None: + return NotImplemented + + vector_inputs = [value for value in inputs if isinstance(value, Vector)] + if not vector_inputs: + return NotImplemented + + template = vector_inputs[0] + row_counts = template.row_counts() + total_rows = sum(row_counts) + + for other in vector_inputs[1:]: + if other.shape != template.shape: + raise ValueError("Vector ufunc inputs must have matching fixed-grid shapes.") + if other.num_fields != template.num_fields: + raise ValueError("Vector ufunc inputs must have matching field counts.") + if other.row_counts() != row_counts: + raise ValueError("Vector ufunc inputs must have matching per-cell row counts.") + + flat_inputs = [ + _normalize_ufunc_input(value, total_rows, template.num_fields) for value in inputs ] - return "\n".join(description) + result = ufunc(*flat_inputs, **kwargs) + if isinstance(result, tuple): + return tuple(_vector_from_flat_result(template, item, row_counts) for item in result) + return _vector_from_flat_result(template, result, row_counts) - def __str__(self) -> str: - description = [ - f"quantem.Vector, shape={self._shape}, name={self._name}", - f" fields = {self._fields}", - f" units: {self._units}", - ] - return "\n".join(description) + def __add__(self, other: Any) -> "Vector": + return self._binary_op(other, np.add) - @property - def metadata(self) -> dict: - return self._metadata + def __sub__(self, other: Any) -> "Vector": + return self._binary_op(other, np.subtract) - @property - def shape(self) -> Tuple[int, ...]: - """ - Get the shape of the vector. + def __mul__(self, other: Any) -> "Vector": + return self._binary_op(other, np.multiply) - Returns - ------- - Tuple[int, ...] - The dimensions of the vector. - """ - return self._shape + def __truediv__(self, other: Any) -> "Vector": + return self._binary_op(other, np.divide) - @shape.setter - def shape(self, value: Tuple[int, ...]) -> None: - """ - Set the shape of the vector. + def __floordiv__(self, other: Any) -> "Vector": + return self._binary_op(other, np.floor_divide) - Parameters - ---------- - value : Tuple[int, ...] - The new shape. All dimensions must be positive. - - Raises - ------ - ValueError - If any dimension is not positive. - TypeError - If value is not a tuple or contains non-integer values. - """ - self._shape = validate_shape(value) + def __mod__(self, other: Any) -> "Vector": + return self._binary_op(other, np.mod) - @property - def num_fields(self) -> int: - """ - Get the number of fields in the vector. + def __pow__(self, other: Any) -> "Vector": + return self._binary_op(other, np.power) - Returns - ------- - int - The number of fields. - """ - return len(self._fields) + def __radd__(self, other: Any) -> "Vector": + return self._binary_op(other, np.add, reverse=True) - @property - def name(self) -> str: - """ - Get the name of the vector. + def __rmul__(self, other: Any) -> "Vector": + return self._binary_op(other, np.multiply, reverse=True) - Returns - ------- - str - The name of the vector - """ - return self._name + def __rsub__(self, other: Any) -> "Vector": + return self._binary_op(other, np.subtract, reverse=True) - @name.setter - def name(self, value: str) -> None: - """ - Set the name of the vector. + def __rtruediv__(self, other: Any) -> "Vector": + return self._binary_op(other, np.divide, reverse=True) - Parameters - ---------- - value : str - The new name of the vector - """ - self._name = str(value) + def __rfloordiv__(self, other: Any) -> "Vector": + return self._binary_op(other, np.floor_divide, reverse=True) - @property - def fields(self) -> List[str]: - """ - Get the field names of the vector. + def __rmod__(self, other: Any) -> "Vector": + return self._binary_op(other, np.mod, reverse=True) - Returns - ------- - List[str] - The list of field names. - """ - return self._fields + def __rpow__(self, other: Any) -> "Vector": + return self._binary_op(other, np.power, reverse=True) - @fields.setter - def fields(self, value: List[str]) -> None: - """ - Set the field names of the vector. + def __iadd__(self, other: Any) -> "Vector": + self._inplace_op(other, np.add) + return self - Parameters - ---------- - value : List[str] - The new field names. Must match num_fields and be unique. - - Raises - ------ - ValueError - If length doesn't match num_fields or if there are duplicates. - TypeError - If value is not a list or contains non-string values. - """ - self._fields = validate_fields(value) + def __isub__(self, other: Any) -> "Vector": + self._inplace_op(other, np.subtract) + return self - @property - def units(self) -> List[str]: - """ - Get the units of the vector's fields. + def __imul__(self, other: Any) -> "Vector": + self._inplace_op(other, np.multiply) + return self - Returns - ------- - List[str] - The list of units, one per field. - """ - return self._units + def __itruediv__(self, other: Any) -> "Vector": + self._inplace_op(other, np.divide) + return self + + def __ifloordiv__(self, other: Any) -> "Vector": + self._inplace_op(other, np.floor_divide) + return self + + def __imod__(self, other: Any) -> "Vector": + self._inplace_op(other, np.mod) + return self - @units.setter - def units(self, value: List[str]) -> None: + def __ipow__(self, other: Any) -> "Vector": + self._inplace_op(other, np.power) + return self + + def __neg__(self) -> "Vector": + return self._binary_op(-1, np.multiply) + + def __pos__(self) -> "Vector": + return self.copy() + + def __abs__(self) -> "Vector": + result = self.copy() + result._inplace_unary(np.abs) + return result + + # ------------------------------------------------------------------ # + # I/O + # ------------------------------------------------------------------ # + + def save( + self, + path: str | Path, + mode: Literal["w", "o"] = "w", + store: Literal["auto", "zip", "dir"] = "auto", + skip: str | type | Sequence[str | type] = (), + compression_level: int | None = 4, + ) -> None: """ - Set the units of the vector's fields. + Save the Vector object to disk using Zarr serialization. self.compact() is called before + saving to reduce file size if possible. Parameters ---------- - value : List[str] - The new units. Must match num_fields. - - Raises - ------ - ValueError - If length doesn't match num_fields. - TypeError - If value is not a list or contains non-string values. + path : str or Path + Target file path. Use '.zip' extension for zip format, otherwise a directory. + mode : {'w', 'o'} + 'w' = write only if file doesn't exist, 'o' = overwrite if it does. + store : {'auto', 'zip', 'dir'} + Storage format. 'auto' infers from file extension. + skip : str, type, or list of (str or type) + Attribute names/types to skip (by name or type) during serialization. + compression_level : int or None + If set (0–9), applies Zstandard compression with Blosc backend at that level. + Level 0 disables compression. Raises ValueError if > 9. + + Notes + ----- + Skipped attribute names and types are also stored in the file metadata for correct + round-trip skipping during load(). """ - self._units = validate_vector_units(value, self.num_fields) + self.compact() + super().save( + path, + mode=mode, + store=store, + skip=skip, + compression_level=compression_level, + ) + + # ------------------------------------------------------------------ # + # Private helpers — backing-store access + # ------------------------------------------------------------------ # @property - def data(self) -> List[Any]: + def _full_num_fields(self) -> int: + return len(self._state["fields"]) + + def _field_indices(self) -> NDArray[np.int64]: + """Map selected field names to column indices in the backing buffer.""" + if self._selected_fields is None: + return np.arange(self._full_num_fields, dtype=np.int64) + + lookup = {field: i for i, field in enumerate(self._state["fields"])} + try: + return np.array([lookup[field] for field in self._selected_fields], dtype=np.int64) + except KeyError as exc: + raise KeyError(f"Unknown field(s): {[str(exc.args[0])]}") from exc + + def _require_full_field_view(self, operation: str) -> None: + """Raise if a schema-changing/full-row operation is attempted on a field view.""" + if self._selected_fields is not None: + raise ValueError(f"{operation} is only allowed when all fields are selected.") + + def _selected_cell_indices(self) -> NDArray[np.int64]: + """Return linear cell indices for the current fixed-grid selection.""" + if self._selection_indices is None: + return np.arange(_cell_count(self._state["shape"]), dtype=np.int64) + return self._selection_indices + + def _cell_row_count(self, linear_index: int) -> int: + """Return the row count for one cell in the backing buffer.""" + return int(self._state["cell_lengths"][linear_index]) + + def _cell_matrix(self, linear_index: int) -> NDArray[Any]: + """Return the full backing matrix for one cell.""" + start = int(self._state["cell_starts"][linear_index]) + length = int(self._state["cell_lengths"][linear_index]) + return self._state["data"][start : start + length] + + def _selected_cell_matrix(self, linear_index: int) -> NDArray[Any]: + """Return one cell with the current field selection applied.""" + cell = self._cell_matrix(linear_index) + cols = self._field_indices() + if cols.size == self._full_num_fields: + return cell + if cols.size == 1: + col = int(cols[0]) + return cell[:, col : col + 1] + if _is_contiguous(cols): + return cell[:, int(cols[0]) : int(cols[-1]) + 1] + return cell[:, cols].copy() + + def _replace_cells(self, targets: NDArray[np.int64], arrays: Sequence[NDArray[Any]]) -> None: + """Replace complete cells in the compact row buffer. + + Whole-cell replacement is implemented by appending the new payload rows to + the end of the backing buffer and then updating ``cell_starts`` / + ``cell_lengths`` for the targeted cells. This keeps the operation simple + and makes overlapping assignment semantics easy to reason about, but it + leaves the previous rows unreachable until compaction removes them. """ - Get the raw data of the vector. + if len(targets) != len(arrays): + raise ValueError("Target cell count does not match source cell count.") + if len(targets) == 0: + return - Returns - ------- - List[Any] - The nested list structure containing the vector's data. - """ - return self._data + normalized = [_coerce_cell_array(array, self._full_num_fields) for array in arrays] + payloads = [array for array in normalized if array.shape[0] > 0] + if payloads: + appended = np.vstack(payloads) + self._state["data"] = np.concatenate((self._state["data"], appended), axis=0) + + cursor = self._state["data"].shape[0] - sum(array.shape[0] for array in normalized) + for target, array in zip(targets, normalized): + self._state["cell_starts"][target] = cursor + self._state["cell_lengths"][target] = array.shape[0] + cursor += array.shape[0] + + self._maybe_compact_storage() + + def _expand_storage(self, num_new_fields: int) -> None: + """Append new ``np.nan``-initialized columns for added fields.""" + data = self._state["data"] + dtype = np.result_type(data.dtype, float) + if data.shape[0] == 0: + self._state["data"] = np.empty((0, data.shape[1] + num_new_fields), dtype=dtype) + return - @data.setter - def data(self, value: List[Any]) -> None: - """ - Set the raw data of the vector. + filler = np.full((data.shape[0], num_new_fields), np.nan, dtype=dtype) + self._state["data"] = np.concatenate((data.astype(dtype, copy=False), filler), axis=1) - Parameters - ---------- - value : List[Any] - The new data structure. Must match the vector's shape and num_fields. - - Raises - ------ - ValueError - If the data structure doesn't match shape or num_fields. - TypeError - If value is not a list or contains invalid data types. - """ - self._data = validate_vector_data(value, self.shape, self.num_fields) + def _maybe_compact_storage(self) -> None: + """Compact automatically once dead rows become materially larger than live rows.""" + data = self._state["data"] + used_rows = int(self._state["cell_lengths"].sum()) + if data.shape[0] <= used_rows + 1024 or data.shape[0] <= 2 * used_rows: + return + self.compact() + # ------------------------------------------------------------------ # + # Private helpers — assignment + # ------------------------------------------------------------------ # -# Helper function for nesting lists -def nested_list(shape: Tuple[int, ...], fill: Any = None) -> Any: - if len(shape) == 0: - return fill - return [nested_list(shape[1:], fill) for _ in range(shape[0])] + def _assign(self, value: Any) -> None: + """Dispatch assignment based on whether all fields or a subset are selected.""" + if self._selected_fields is None: + self._assign_full_cells(value) + else: + self._assign_selected_fields(value) + def _assign_full_cells(self, value: Any) -> None: + """Replace full cell payloads. -# Helper class for numerical field operations -class _FieldView: - def __init__(self, vector: Vector, field_name: str) -> None: - self.vector = vector - self.field_name = field_name - self.field_index = vector._fields.index(field_name) + Full-cell assignment may change the ragged row count of each targeted + cell, because the existing cell matrix is replaced as a whole. + """ + targets = self._selected_cell_indices() + if isinstance(value, Vector): + source_cells = value._selected_cell_indices() + if len(targets) != len(source_cells): + raise ValueError(f"Expected {len(targets)} cells, got {len(source_cells)}") + if value.num_fields != self.num_fields: + raise ValueError(f"Expected {self.num_fields} fields, got {value.num_fields}") + arrays = [value._selected_cell_matrix(index).copy() for index in source_cells] + self._replace_cells(targets, arrays) + return - def _apply_op(self, op: Any) -> None: - def apply(arr: Any) -> None: - if isinstance(arr, np.ndarray): - arr[:, self.field_index] = op(arr[:, self.field_index]) - elif isinstance(arr, list): - for sub in arr: - apply(sub) + array = _coerce_cell_array(value, self.num_fields) + self._replace_cells(targets, [array] * len(targets)) - apply(self.vector._data) + def _assign_selected_fields(self, value: Any) -> None: + """Update only the selected columns while preserving row counts. - def __iadd__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place addition (+=).""" - self._apply_op(lambda x: x + other) - return self + This is the in-place path for assignments such as + ``vector.select_fields("kx")[...] = rhs``. The target cell structure is + preserved, so each target cell keeps its existing row count and only the + selected columns are overwritten. + """ + targets = self._selected_cell_indices() + field_indices = self._field_indices() + row_counts = [self._cell_row_count(index) for index in targets] + total_rows = sum(row_counts) + + if isinstance(value, Vector): + source_cells = value._selected_cell_indices() + if len(targets) != len(source_cells): + raise ValueError(f"Expected {len(targets)} cells, got {len(source_cells)}") + if value.num_fields != self.num_fields: + raise ValueError(f"Expected {self.num_fields} fields, got {value.num_fields}") + source_counts = [value._cell_row_count(index) for index in source_cells] + if row_counts != source_counts: + raise ValueError("Per-cell row counts must match for field-selected assignment.") + snapshots = [value._selected_cell_matrix(index).copy() for index in source_cells] + for target, array in zip(targets, snapshots): + cell = self._cell_matrix(int(target)) + if array.shape[0] > 0: + cell[:, field_indices] = array + return - def __isub__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place subtraction (-=).""" - self._apply_op(lambda x: x - other) - return self + if np.isscalar(value): + for target in targets: + cell = self._cell_matrix(int(target)) + if cell.shape[0] > 0: + cell[:, field_indices] = value + return - def __imul__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place multiplication (*=).""" - self._apply_op(lambda x: x * other) - return self + broadcast = _broadcast_field_values(value, total_rows, self.num_fields) + cursor = 0 + for target, rows in zip(targets, row_counts): + chunk = broadcast[cursor : cursor + rows] + cell = self._cell_matrix(int(target)) + if rows > 0: + cell[:, field_indices] = chunk + cursor += rows + + # ------------------------------------------------------------------ # + # Private helpers — arithmetic + # ------------------------------------------------------------------ # + + def _binary_op(self, other: Any, op: Any, reverse: bool = False) -> "Vector": + """Return a new Vector produced by elementwise arithmetic.""" + result = self.copy() + result._inplace_op(other, op, reverse=reverse) + return result - def __itruediv__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place division (/=).""" - self._apply_op(lambda x: x / other) - return self + def _inplace_unary(self, op: Any) -> None: + """Apply a unary elementwise operation in-place to the selected fields.""" + targets = self._selected_cell_indices() + field_indices = self._field_indices() + for target in targets: + cell = self._cell_matrix(int(target)) + lhs = cell[:, field_indices] + if lhs.shape[0] > 0: + cell[:, field_indices] = op(lhs) + + def _inplace_op(self, other: Any, op: Any, reverse: bool = False) -> None: + """Apply elementwise arithmetic in-place to the selected fields.""" + targets = self._selected_cell_indices() + field_indices = self._field_indices() + row_counts = [self._cell_row_count(index) for index in targets] + total_rows = sum(row_counts) + + if isinstance(other, Vector): + source_cells = other._selected_cell_indices() + if len(targets) != len(source_cells): + raise ValueError(f"Expected {len(targets)} cells, got {len(source_cells)}") + if other.num_fields != self.num_fields: + raise ValueError(f"Expected {self.num_fields} fields, got {other.num_fields}") + source_counts = [other._cell_row_count(index) for index in source_cells] + if row_counts != source_counts: + raise ValueError("Per-cell row counts must match for Vector arithmetic.") + snapshots = [other._selected_cell_matrix(index).copy() for index in source_cells] + for target, rhs in zip(targets, snapshots): + cell = self._cell_matrix(int(target)) + lhs = cell[:, field_indices] + cell[:, field_indices] = op(rhs, lhs) if reverse else op(lhs, rhs) + return - def __ifloordiv__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place floor division (//=).""" - self._apply_op(lambda x: x // other) - return self + if np.isscalar(other): + for target in targets: + cell = self._cell_matrix(int(target)) + lhs = cell[:, field_indices] + if lhs.shape[0] > 0: + cell[:, field_indices] = op(other, lhs) if reverse else op(lhs, other) + return - def __imod__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place modulo (%=).""" - self._apply_op(lambda x: x % other) - return self + broadcast = _broadcast_field_values(other, total_rows, self.num_fields) + cursor = 0 + for target, rows in zip(targets, row_counts): + chunk = broadcast[cursor : cursor + rows] + cell = self._cell_matrix(int(target)) + lhs = cell[:, field_indices] + if rows > 0: + cell[:, field_indices] = op(chunk, lhs) if reverse else op(lhs, chunk) + cursor += rows + + +def _resolve_fields( + fields: Sequence[str] | None, + num_fields: int | None, + inferred: int | None, +) -> list[str]: + """Resolve field names from constructor arguments. + + ``inferred`` is the field count inferred from data; pass ``None`` when there + is no data source and explicit fields/num_fields are required. + """ + if fields is not None: + root_fields = validate_fields(list(fields)) + count = len(root_fields) + if num_fields is not None and count != num_fields: + raise ValueError( + f"num_fields ({num_fields}) does not match length of fields ({count})" + ) + if inferred is not None and count != inferred: + raise ValueError(f"num_fields ({inferred}) does not match length of fields ({count})") + return root_fields + if num_fields is not None: + count = validate_num_fields(num_fields) + if inferred is not None and count != inferred: + raise ValueError( + f"Provided num_fields ({count}) does not match inferred ({inferred})." + ) + return [f"field_{i}" for i in range(count)] + if inferred is not None: + return [f"field_{i}" for i in range(inferred)] + raise ValueError("Must specify either 'fields' or 'num_fields'.") + + +def _cell_count(shape: tuple[int, ...]) -> int: + """Return the number of fixed-grid cells in a shape.""" + return int(np.prod(shape, dtype=np.int64)) if shape else 1 + + +def _normalize_field_names(field_names: str | Sequence[str]) -> tuple[str, ...]: + """Normalize one-or-many field names into a validated tuple.""" + if isinstance(field_names, str): + normalized = (field_names,) + else: + normalized = tuple(field_names) + if not normalized: + raise ValueError("At least one field name is required.") + validate_fields(list(normalized)) + return normalized + + +def _normalize_units(units: str | Sequence[str] | None, count: int) -> list[str]: + """Normalize field units to a list matching ``count``.""" + if units is None: + return ["none"] * count + if isinstance(units, str): + if count != 1: + raise ValueError("A single unit can only be provided for a single field.") + return [units] + normalized = list(units) + if len(normalized) != count: + raise ValueError(f"Expected {count} units, got {len(normalized)}") + return normalized + + +def _looks_like_field_selector(idx: Any) -> bool: + """Return True for indices that look like field selection by mistake.""" + if isinstance(idx, str): + return True + if isinstance(idx, tuple) and any(_looks_like_field_selector(item) for item in idx): + return True + if isinstance(idx, list) and idx and all(isinstance(item, str) for item in idx): + return True + return False + + +def _coerce_cell_array(value: Any, num_fields: int) -> NDArray[Any]: + """Normalize a single-cell payload to shape ``(n_rows, num_fields)``.""" + if isinstance(value, Vector): + if value.shape != (): + raise ValueError("Expected a 0D Vector for single-cell assignment.") + array = value.array.copy() + else: + array = np.asarray(value) + + if array.ndim == 0: + raise ValueError("Cell assignment requires a 2D array.") + if array.ndim == 1: + if array.size == 0: + array = np.empty((0, num_fields), dtype=float) + elif num_fields == 1: + array = array.reshape(-1, 1) + else: + array = array.reshape(1, -1) + if array.ndim != 2: + raise ValueError("Cell assignment requires a 2D array.") + if array.shape[1] != num_fields: + raise ValueError(f"Expected {num_fields} fields, got {array.shape[1]}") + return array + + +def _flatten_fixed_grid(node: Any) -> tuple[tuple[int, ...], list[NDArray[Any]]]: + """Recursively flatten nested fixed-grid input into row-major cell order.""" + if isinstance(node, np.ndarray): + return (), [_coerce_inferred_cell_array(node)] + if not isinstance(node, (list, tuple)): + raise TypeError("Data must be a nested list/tuple of cell arrays or row sequences.") + if _looks_like_cell_rows(node): + return (), [_coerce_inferred_cell_array(node)] + if len(node) == 0: + return (0,), [] + + child_shape: tuple[int, ...] | None = None + cells: list[NDArray[Any]] = [] + for child in node: + shape, child_cells = _flatten_fixed_grid(child) + if child_shape is None: + child_shape = shape + elif child_shape != shape: + raise ValueError("All nested fixed-grid branches must have matching shapes.") + cells.extend(child_cells) + + assert child_shape is not None + return (len(node),) + child_shape, cells + + +def _looks_like_cell_rows(node: Sequence[Any]) -> bool: + """Return True when a sequence should be interpreted as cell rows, not grid nesting.""" + if len(node) == 0: + return True + return all(_is_row_like(item) for item in node) + + +def _is_row_like(item: Any) -> bool: + """Return True for a single row of scalar values.""" + if isinstance(item, np.ndarray): + return item.ndim == 1 + if not isinstance(item, (list, tuple)): + return False + return all(np.isscalar(value) for value in item) + + +def _coerce_inferred_cell_array(value: Any) -> NDArray[Any]: + """Infer a 2D cell array from row-like input during ``from_data``.""" + array = np.asarray(value) + if array.ndim == 0: + raise ValueError("Cell data must be 1D or 2D.") + if array.ndim == 1: + if array.size == 0: + return np.empty((0, 0), dtype=float) + return array.reshape(1, -1) + if array.ndim != 2: + raise ValueError("Cell data must be 1D or 2D.") + return array + + +def _select_linear_indices( + shape: tuple[int, ...], + current_indices: NDArray[np.int64], + idx: Any, +) -> tuple[tuple[int, ...], NDArray[np.int64]]: + """Apply fixed-grid indexing to a flattened cell-index view. + + ``current_indices`` stores the linear cell indices represented by the current + selection. This helper reshapes those indices to the current selection shape, + applies NumPy-like indexing on the fixed-grid axes, and then returns: + - the output fixed-grid shape + - the flattened linear indices of the selected cells, in row-major order + """ + if shape == (): + if idx in ((), Ellipsis): + return (), np.array([int(current_indices[0])], dtype=np.int64) + raise IndexError("Too many indices for 0D Vector") + + index_tuple = _normalize_index_tuple(idx, len(shape)) + current_grid = current_indices.reshape(shape) + + axis_positions: list[NDArray[np.int64]] = [] + out_shape: list[int] = [] + scalar_axes: list[bool] = [] + for axis, axis_index in enumerate(index_tuple): + positions, is_scalar = _positions_for_axis(axis_index, shape[axis]) + axis_positions.append(positions) + scalar_axes.append(is_scalar) + if not is_scalar: + out_shape.append(len(positions)) + + if all(scalar_axes): + scalar_key = tuple(int(positions[0]) for positions in axis_positions) + value = int(current_grid[scalar_key]) + return (), np.array([value], dtype=np.int64) + + mesh_inputs = [ + positions if not is_scalar else positions[:1] + for positions, is_scalar in zip(axis_positions, scalar_axes) + ] + grids = np.meshgrid(*mesh_inputs, indexing="ij") + selected = np.asarray(current_grid[tuple(grids)], dtype=np.int64).reshape(-1) + return tuple(out_shape), selected + + +def _normalize_index_tuple(idx: Any, ndim: int) -> tuple[Any, ...]: + """Normalize fixed-grid indexing to a full ``ndim``-length tuple.""" + if idx is Ellipsis: + return (slice(None),) * ndim + if not isinstance(idx, tuple): + idx = (idx,) + + ellipsis_count = sum(item is Ellipsis for item in idx) + if ellipsis_count > 1: + raise IndexError("An index can only have a single ellipsis.") + if ellipsis_count == 1: + ellipsis_pos = idx.index(Ellipsis) + fill = ndim - (len(idx) - 1) + idx = idx[:ellipsis_pos] + (slice(None),) * fill + idx[ellipsis_pos + 1 :] + if len(idx) > ndim: + raise IndexError(f"Too many indices for Vector: expected {ndim}, got {len(idx)}") + if len(idx) < ndim: + idx = idx + (slice(None),) * (ndim - len(idx)) + return idx + + +def _positions_for_axis(axis_index: Any, size: int) -> tuple[NDArray[np.int64], bool]: + """Resolve one axis index into concrete positions and scalar-vs-vector shape behavior.""" + if isinstance(axis_index, (bool, np.bool_)): + raise TypeError("Boolean scalars are not valid Vector indices.") + + if isinstance(axis_index, (int, np.integer)): + index = int(axis_index) + if index < 0: + index += size + if index < 0 or index >= size: + raise IndexError("Vector index out of range") + return np.array([index], dtype=np.int64), True + + if isinstance(axis_index, slice): + return np.arange(size, dtype=np.int64)[axis_index], False + + array = np.asarray(axis_index) + if array.ndim == 0: + if np.issubdtype(array.dtype, np.integer): + return _positions_for_axis(int(array.item()), size) + raise TypeError(f"Unsupported index type: {type(axis_index)!r}") + + if array.dtype == bool or np.issubdtype(array.dtype, np.bool_): + if array.ndim != 1: + raise IndexError("Full-grid boolean masks are not supported.") + if array.shape[0] != size: + raise IndexError( + f"Boolean mask length {array.shape[0]} does not match axis length {size}" + ) + return np.flatnonzero(array).astype(np.int64, copy=False), False + + if array.ndim != 1: + raise IndexError("Fancy indexing arrays must be one-dimensional.") + if array.size == 0: + return np.array([], dtype=np.int64), False + if not np.issubdtype(array.dtype, np.integer): + raise TypeError("Fancy indices must be integers or booleans.") + + positions = array.astype(np.int64, copy=True) + positions[positions < 0] += size + if np.any((positions < 0) | (positions >= size)): + raise IndexError("Vector index out of range") + return positions, False + + +def _broadcast_field_values(value: Any, total_rows: int, num_fields: int) -> NDArray[Any]: + """Broadcast array-like input to flattened rowwise assignment shape.""" + array = np.asarray(value) + if array.ndim == 0: + return np.broadcast_to(array.reshape(1, 1), (total_rows, num_fields)) + if num_fields == 1 and array.ndim == 1: + if total_rows == 0 and array.shape[0] == 0: + return array.reshape(0, 1) + if array.shape[0] != total_rows: + raise ValueError(f"Expected {total_rows} values, got {array.shape[0]}") + return array.reshape(total_rows, 1) + try: + return np.broadcast_to(array, (total_rows, num_fields)) + except ValueError as exc: + raise ValueError( + f"Cannot broadcast value with shape {array.shape} to ({total_rows}, {num_fields})" + ) from exc + + +def _normalize_ufunc_input(value: Any, total_rows: int, num_fields: int) -> Any: + """Normalize one ufunc input to flattened Vector-compatible form.""" + if isinstance(value, Vector): + return value.flatten() + if np.isscalar(value): + return value + return _broadcast_field_values(value, total_rows, num_fields) + + +def _vector_from_flat_result( + template: Vector, + values: Any, + row_counts: list[int], +) -> Vector: + """Build a Vector from flattened rowwise result data.""" + total_rows = sum(row_counts) + flat_values = _broadcast_field_values(values, total_rows, template.num_fields) + + result = Vector.from_shape( + shape=template.shape, + fields=template.fields, + units=template.units, + name=template.name, + ) + result._state["metadata"] = copy.deepcopy(template.metadata) - def __ipow__(self, other: Union[float, int, np.ndarray]) -> "_FieldView": - """Handle in-place power (**=).""" - self._apply_op(lambda x: x**other) - return self + if total_rows == 0: + result._state["data"] = np.empty((0, template.num_fields), dtype=flat_values.dtype) + return result + + cursor = 0 + cells: list[NDArray[Any]] = [] + for rows in row_counts: + cells.append(flat_values[cursor : cursor + rows].copy()) + cursor += rows + + result._replace_cells(result._selected_cell_indices(), cells) + return result - def flatten(self) -> NDArray: - def collect(arr: Any) -> List[NDArray]: - if isinstance(arr, np.ndarray): - return [arr[:, self.field_index]] - elif isinstance(arr, list): - result = [] - for sub in arr: - result.extend(collect(sub)) - return result - else: - return [] - - arrays = collect(self.vector._data) - if not arrays: - return np.empty((0,), dtype=float) - return np.concatenate(arrays, axis=0) - - def set_flattened(self, values: ArrayLike) -> None: - """ - Set the field values across the entire Vector from a 1D flattened array. - """ - def fill(arr: Any, values: NDArray, cursor: int) -> int: - if isinstance(arr, np.ndarray): - n = arr.shape[0] - arr[:, self.field_index] = values[cursor : cursor + n] - return cursor + n - elif isinstance(arr, list): - for sub in arr: - cursor = fill(sub, values, cursor) - return cursor - return cursor - - values = np.asarray(values) - if values.ndim != 1: - raise ValueError("Input to set_flattened must be a 1D array.") - - expected = self.flatten().shape[0] - if values.shape[0] != expected: - raise ValueError(f"Expected {expected} values, got {values.shape[0]}") - - fill(self.vector._data, values, cursor=0) - - def __getitem__( - self, idx: Union[Tuple[Union[int, slice], ...], int, slice] - ) -> Union[NDArray, "_FieldView"]: - # Optionally allow v['field0'][0, 1] to get subregion, or v['field0'][...] slice - sub = self.vector[idx] - if isinstance(sub, Vector): - return sub[self.field_name] - elif isinstance(sub, np.ndarray): - return sub[:, self.field_index] - return cast(NDArray, None) - - def __array__(self) -> np.ndarray: - """Convert to numpy array when needed.""" - return self.flatten() +def _is_contiguous(indices: NDArray[np.int64]) -> bool: + """Return True when integer column indices form one contiguous slice.""" + if indices.size <= 1: + return True + return bool(np.all(indices[1:] - indices[:-1] == 1)) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index cb36f1de..4fe72645 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -1,6 +1,7 @@ import importlib from os import PathLike from pathlib import Path +from typing import Any import h5py @@ -14,6 +15,7 @@ def read_4dstem( file_path: str | PathLike, file_type: str | None = None, dataset_index: int | None = None, + hot_pixel_filter: bool = False, **kwargs, ) -> Dataset4dstem: """ @@ -29,18 +31,61 @@ def read_4dstem( dataset_index: int, optional Index of the dataset to load if file contains multiple datasets. If None, automatically selects the first 4D dataset found. + hot_pixel_filter: bool, optional + If True, detect and replace hot detector pixels immediately after + loading using `quantem.core.utils.filter.filter_hot_pixels` with its + default parameters. For custom thresholds, call `filter_hot_pixels` + directly on the array. **kwargs: dict - Additional keyword arguments to pass to the Dataset4dstem constructor. + Additional keyword arguments to pass to the file reader. + + Other Parameters + ---------------- + name : str | None, optional + A descriptive name for the dataset. If None, defaults to "4D-STEM dataset" + origin : NDArray | tuple | list | float | int | None, optional + The origin coordinates for each dimension in calibrated units. If None, defaults to zeros + sampling : NDArray | tuple | list | float | int | None, optional + The sampling rate/spacing for each dimension. If None, defaults to ones + units : list[str] | tuple | list | None, optional + Units for each dimension. If None, defaults to ["pixels"] * 4 + signal_units : str, optional + Units for the array values, by default "arb. units" Returns -------- Dataset4dstem + + Examples + -------- + Load a raw Arina 4D-STEM master file: + + >>> from quantem.core.io import read_4dstem + >>> ds = read_4dstem( + ... '/path/to/gold_013_master.h5', + ... file_type='arina', + ... ) + >>> ds.array.shape + (256, 256, 192, 192) + + Enable the hot pixel filter to repair stuck detector pixels on load: + + >>> ds = read_4dstem( + ... '/path/to/gold_013_master.h5', + ... file_type='arina', + ... hot_pixel_filter=True, + ... ) """ if file_type is None: file_type = Path(file_path).suffix.lower().lstrip(".") + sampling_override = kwargs.pop("sampling", None) + origin_override = kwargs.pop("origin", None) + units_override = kwargs.pop("units", None) + name_override = kwargs.pop("name", None) + file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader - data_list = file_reader(file_path) + data_list = file_reader(file_path, **kwargs) # If specific index provided, use it if dataset_index is not None: @@ -69,25 +114,34 @@ def read_4dstem( imported_axes = imported_data["axes"] - sampling = kwargs.pop( - "sampling", - [ax["scale"] for ax in imported_axes], + sampling = ( + sampling_override + if sampling_override is not None + else [ax.get("scale", 1) for ax in imported_axes] ) - origin = kwargs.pop( - "origin", - [ax["offset"] for ax in imported_axes], + origin = ( + origin_override + if origin_override is not None + else [ax.get("offset", 0) for ax in imported_axes] ) - units = kwargs.pop( - "units", - ["pixels" if ax["units"] == "1" else ax["units"] for ax in imported_axes], + units = ( + units_override + if units_override is not None + else ["pixels" if ax["units"] == "1" else ax["units"] for ax in imported_axes] ) + array = imported_data["data"] + if hot_pixel_filter: + from quantem.core.utils.filter import filter_hot_pixels + + array = filter_hot_pixels(array) + dataset = Dataset4dstem.from_array( - array=imported_data["data"], + array=array, sampling=sampling, origin=origin, units=units, - **kwargs, + name=name_override, ) return dataset @@ -115,7 +169,7 @@ def read_2d( if file_type is None: file_type = Path(file_path).suffix.lower().lstrip(".") - file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader # type: ignore + file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader imported_data = file_reader(file_path)[0] dataset = Dataset2d.from_array( @@ -160,9 +214,9 @@ def read_emdfile_to_4dstem( data_keys = ["datacube_root", "datacube", "data"] if data_keys is None else data_keys print("keys: ", data_keys) try: - data = file + data: Any = file for key in data_keys: - data = data[key] # type: ignore + data = data[key] except KeyError: raise KeyError(f"Could not find key {data_keys} in {file_path}") @@ -175,13 +229,13 @@ def read_emdfile_to_4dstem( try: calibration = file for key in calibration_keys: - calibration = calibration[key] # type: ignore + calibration = calibration[key] except KeyError: raise KeyError(f"Could not find calibration key {calibration_keys} in {file_path}") - r_pixel_size = calibration["R_pixel_size"][()] # type: ignore - q_pixel_size = calibration["Q_pixel_size"][()] # type: ignore - r_pixel_units = calibration["R_pixel_units"][()] # type: ignore - q_pixel_units = calibration["Q_pixel_units"][()] # type: ignore + r_pixel_size = calibration["R_pixel_size"][()] + q_pixel_size = calibration["Q_pixel_size"][()] + r_pixel_units = calibration["R_pixel_units"][()] + q_pixel_units = calibration["Q_pixel_units"][()] dataset = Dataset4dstem.from_array( array=data, @@ -191,3 +245,113 @@ def read_emdfile_to_4dstem( dataset.file_path = file_path return dataset + + +def read_abtem(url: str | PathLike): + """ + Read canonical abTEM Zarr file(s) into quantem Dataset(s). + + Returns + ------- + Dataset or list[Dataset] + """ + + def _open_zarr(url): + import zarr + + if url.endswith(".zip"): + store = zarr.storage.ZipStore(url, mode="r") # type: ignore + return zarr.open(store=store, mode="r") + return zarr.open(url, mode="r") + + def _validate_canonical_format(root): + if "metadata0" in root.attrs: + return + + if "kwargs0" in root.attrs: + raise ValueError( + "Legacy abTEM Zarr format detected.\n\n" + "quantem supports only canonical abTEM Zarr format.\n" + "Re-save using abtem>=1.1.0:\n\n" + " measurement = abtem.from_zarr()\n" + " measurement.to_zarr()" + ) + + raise ValueError("Unrecognized Zarr format.") + + def _iter_metadata_indices(root): + i = 0 + while f"metadata{i}" in root.attrs: + yield i + i += 1 + + def _decode_types(obj) -> Any: + if isinstance(obj, dict): + if obj.get("_type") == "tuple": + return tuple(_decode_types(v) for v in obj["_value"]) + return {k: _decode_types(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_decode_types(v) for v in obj] + return obj + + def _normalize_unit(unit): + if unit is None: + return "pixels" + + unit = unit.strip() + + UNIT_MAP = { + "Å": "A", + "Ångström": "A", + "Angstrom": "A", + "1/Å": "A^-1", + "Å^-1": "A^-1", + "1/A": "A^-1", + } + + return UNIT_MAP.get(unit, unit) + + def _convert_axes(axes_dict): + sampling = [] + origin = [] + units = [] + + for key in sorted(axes_dict, key=lambda x: int(x.split("_")[1])): + axis = axes_dict[key] + + sampling.append(axis.get("sampling", 1.0)) + units.append(_normalize_unit(axis.get("units", None))) + origin.append(0.0) # deliberate design choice + + return tuple(origin), tuple(sampling), tuple(units) + + def _read_single_dataset(root, index): + metadata = _decode_types(root.attrs[f"metadata{index}"]).copy() + + axes_dict = metadata.pop("axes") + dataset_type = metadata.pop("type") + metadata.pop("data_origin", None) + + origin, sampling, units = _convert_axes(axes_dict) + + array = root[f"array{index}"] + signal_units = metadata.get("units", "arb. units") + + dataset = Dataset.from_array( + array=array, + name=dataset_type, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + ) + + dataset._metadata = metadata + return dataset + + root = _open_zarr(url) + _validate_canonical_format(root) + + datasets = [_read_single_dataset(root, i) for i in _iter_metadata_indices(root)] + + return datasets[0] if len(datasets) == 1 else datasets diff --git a/src/quantem/core/ml/__init__.py b/src/quantem/core/ml/__init__.py index ce1f6de0..af2d5b64 100644 --- a/src/quantem/core/ml/__init__.py +++ b/src/quantem/core/ml/__init__.py @@ -1,4 +1,8 @@ from quantem.core.ml.cnn import CNN2d as CNN2d, CNN3d as CNN3d +from quantem.core.ml.optimizer_mixin import ( + OptimizerParams as OptimizerParams, + SchedulerParams as SchedulerParams, +) from quantem.core.ml.inr import HSiren as HSiren from quantem.core.ml.dense_nn import DenseNN as DenseNN from quantem.core.ml.cnn_dense import CNNDense as CNNDense diff --git a/src/quantem/core/ml/constraints.py b/src/quantem/core/ml/constraints.py new file mode 100644 index 00000000..590da4cb --- /dev/null +++ b/src/quantem/core/ml/constraints.py @@ -0,0 +1,109 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Generic, Self, TypeVar + +import numpy as np +import torch +from numpy.typing import NDArray + + +@dataclass +class BaseContext(ABC): + """ + Constraints should contain a context object that contains all necessary data for the constraints to be applied. + """ + pass + +T_ctx = TypeVar("T_ctx", bound=BaseContext) + +@dataclass(slots=False) +class Constraints(ABC): + """ + Any model that inherits from BaseConstraints will contain a Constraints instance that contains soft and hard constraints. + """ + + soft_constraint_keys = [] + hard_constraint_keys = [] + + @property + def allowed_keys(self) -> list[str]: + """ + List of all allowed keys. + """ + return self.hard_constraint_keys + self.soft_constraint_keys + + def copy(self) -> Self: + """ + Copy the constraints. + """ + return deepcopy(self) + + def __str__(self) -> str: + hard = "\n".join(f"{key}: {getattr(self, key)}" for key in self.hard_constraint_keys) + soft = "\n".join(f"{key}: {getattr(self, key)}" for key in self.soft_constraint_keys) + + # Fix: Move the replace operations outside the f-string or assign to variables + hard_indented = hard.replace("\n", "\n ") + soft_indented = soft.replace("\n", "\n ") + + return ( + "Constraints:\n" + " Hard constraints:\n" + f" {hard_indented}\n" + " Soft constraints:\n" + f" {soft_indented}" + ) + + +class BaseConstraints(ABC, Generic[T_ctx]): + """ + Base class for constraints. + """ + + # Default constraints are the dataclasses themselves. + DEFAULT_CONSTRAINTS = Constraints() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._soft_constraint_losses = [] + self.constraints = self.DEFAULT_CONSTRAINTS.copy() + + @property + def soft_constraint_losses(self) -> NDArray[np.float32]: + return np.array(self._soft_constraint_losses, dtype=np.float32) + + @property + def constraints(self) -> Constraints: + """ + Constraints for the model. + """ + return self._constraints + + @constraints.setter + def constraints(self, constraints: Constraints | dict[str, Any]): + """ + Setter for constraints class, can be a Constraints instance or a dictionary. + """ + if isinstance(constraints, Constraints): + self._constraints = constraints + elif isinstance(constraints, dict): + for key, value in constraints.items(): + setattr(self._constraints, key, value) + else: + raise ValueError(f"Invalid constraints type: {type(constraints)}") + + # --- Required methods tha tneeds to implemented in subclasses --- + @abstractmethod + def apply_hard_constraints(self, pred: torch.Tensor) -> torch.Tensor: + """ + Apply hard constraints to the model. + """ + raise NotImplementedError + + @abstractmethod + def apply_soft_constraints(self, ctx: T_ctx) -> torch.Tensor: + """ + Apply soft constraints to the model. + """ + raise NotImplementedError diff --git a/src/quantem/core/ml/ddp.py b/src/quantem/core/ml/ddp.py new file mode 100644 index 00000000..18b7ccd7 --- /dev/null +++ b/src/quantem/core/ml/ddp.py @@ -0,0 +1,185 @@ +import os + +import torch +import torch.distributed as dist +import torch.nn as nn +from torch.utils.data import DataLoader, Dataset, DistributedSampler, random_split + +from quantem.tomography.dataset_models import DatasetModelType + + +def worker_init_fn(worker_id): + os.environ["CUDA_VISIBLE_DEVICES"] = "" + + +class DDPMixin: + """ + Class for setting up all distributed training. + + - + """ + + def setup_distributed(self, device: str | torch.device | None = None): + """ + Initializes parameters depending if multiple-GPU training, single-GPU training, or CPU training. + """ + if "RANK" in os.environ: + if not dist.is_initialized(): + dist.init_process_group( + backend="nccl" if torch.cuda.is_available() else "gloo", init_method="env://" + ) + + self.world_size = dist.get_world_size() + self.global_rank = dist.get_rank() + self.local_rank = int(os.environ["LOCAL_RANK"]) + torch.cuda.set_device(self.local_rank) + device = torch.device("cuda", self.local_rank) + else: + self.world_size = 1 + self.global_rank = 0 + self.local_rank = 0 + + if torch.cuda.is_available(): + device = torch.device("cuda:0" if device is None else device) + torch.cuda.set_device(device.index) + else: + device = torch.device("cpu") + + if device.type == "cuda": + torch.backends.cudnn.benchmark = True + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + + self.device = device + + def setup_dataloader( + self, + dataset: Dataset | DatasetModelType, + batch_size: int, + num_workers: int = 0, + val_fraction: float = 0.0, + ): + pin_mem = self.device.type == "cuda" + persist = num_workers > 0 + # ``multiprocessing_context`` is only valid for multi-process loading; passing it with + # num_workers=0 raises ValueError (and num_workers=0 keeps the dataset in-process, which + # is what CPU / coverage runs use). + mp_ctx = "spawn" if num_workers > 0 else None + + if val_fraction > 0.0: + train_dataset, val_dataset = random_split(dataset, [1 - val_fraction, val_fraction]) # type: ignore[reportArgumentType] --> dataset inherits from torch Dataset so this is fine. + else: + train_dataset = dataset + val_dataset = None + + if self.world_size > 1: + shuffle = True + train_sampler = DistributedSampler( + train_dataset, # type: ignore[reportArgumentType] --> Torch datasets do not have a len method, but still works. + num_replicas=self.world_size, + rank=self.global_rank, + shuffle=shuffle, + ) + + if val_dataset: + val_sampler = DistributedSampler( + val_dataset, + num_replicas=self.world_size, + rank=self.global_rank, + shuffle=False, + ) + else: + val_sampler = None + shuffle = False + + else: + train_sampler = None + val_sampler = None + shuffle = True + + train_dataloader = DataLoader( + train_dataset, # type: ignore[reportArgumentType] --> Torch datasets do not have a len method, but still works. + batch_size=batch_size, + num_workers=num_workers, + sampler=train_sampler, + shuffle=shuffle, + pin_memory=pin_mem, + drop_last=True, + persistent_workers=persist, + multiprocessing_context=mp_ctx, + worker_init_fn=worker_init_fn, + ) + + if val_dataset: + val_dataloader = DataLoader( + val_dataset, + batch_size=batch_size * 4, + num_workers=num_workers, + sampler=val_sampler, + shuffle=False, + pin_memory=pin_mem, + drop_last=False, + persistent_workers=persist, + multiprocessing_context=mp_ctx, + worker_init_fn=worker_init_fn, + ) + val_dataloader = val_dataloader + else: + val_dataloader = None + + if self.global_rank == 0: + print("Dataloader setup complete:") + print(f" Total train samples: {len(train_dataset)}") # pyright: ignore[reportArgumentType] --> Torch datasets do not have a len method, but still works. + print(f" Local batch size: {batch_size}") + print(f" Global batch size: {batch_size * self.world_size}") + print(f" Train batches per GPU per epoch: {len(train_dataloader)}") + + if val_dataset: + print(f" Total val samples: {len(val_dataset)}") + print(f" Val batches per GPU per epoch: {len(val_dataloader)}") # pyright: ignore[reportArgumentType] --> Torch datasets do not have a len method, but still works. + + return train_dataloader, train_sampler, val_dataloader, val_sampler + + def distribute_model( + self, + model: nn.Module, + ) -> nn.Module | nn.parallel.DistributedDataParallel: + """ + Wraps the model with DistributedDataParallel if mulitple GPUs are available. + + Returns the model. + """ + model = model.to(self.device) + + if self.world_size > 1: + model = torch.nn.parallel.DistributedDataParallel( + model, + device_ids=[self.local_rank], + output_device=self.local_rank, + find_unused_parameters=False, + broadcast_buffers=True, + bucket_cap_mb=100, + gradient_as_bucket_view=True, + ) + + if self.global_rank == 0: + print("Model wrapped with DDP and compiled") + + if self.world_size > 1: + if self.global_rank == 0: + print("Model built, distributed, and compiled successfully") + + else: + print("Model built, compiled successfully") + + return model + + @property + def device(self) -> torch.device: + return self._device + + @device.setter + def device(self, device: torch.device | str): + if isinstance(device, str): + device = torch.device(device) + self._device = device diff --git a/src/quantem/core/ml/inr.py b/src/quantem/core/ml/inr.py index 24ee45e4..4c197344 100644 --- a/src/quantem/core/ml/inr.py +++ b/src/quantem/core/ml/inr.py @@ -23,6 +23,7 @@ def __init__( hsiren: bool = False, dtype: torch.dtype = torch.float32, final_activation: str | Callable = "identity", + winner_initialization: bool | int = False, ) -> None: """Initialize Siren. @@ -59,7 +60,7 @@ def __init__( self.alpha = alpha self.hsiren = hsiren self.dtype = dtype - + self.winner_initialization = winner_initialization self.final_activation = final_activation self._build() @@ -109,6 +110,21 @@ def _build(self) -> None: net_list.append(self._final_activation) self.net = nn.Sequential(*net_list) + if self.winner_initialization: + if type(self.winner_initialization) is int: + rng = torch.Generator() + rng.manual_seed(self.winner_initialization) + else: + rng = torch.Generator() + rng.manual_seed(42) + with torch.no_grad(): + self.net[0].linear.weight += ( # type: ignore[reportAttributeAccessIssue] + torch.randn_like(self.net[0].linear.weight) * 5 / self.first_omega_0 # type:ignore + ) + self.net[1].linear.weight += ( # type: ignore[reportAttributeAccessIssue] + torch.randn_like(self.net[1].linear.weight) * 0.1 / self.hidden_omega_0 # type:ignore + ) + def forward(self, coords: torch.Tensor) -> torch.Tensor: output = self.net(coords) return output @@ -118,7 +134,10 @@ def reset_weights(self) -> None: self._build() def make_equispaced_grid( - self, bounds: tuple[tuple[float, float], ...], sampling: tuple[float, ...] + self, + bounds: tuple[tuple[float, float], ...], + sampling: tuple[float, ...] | None = None, + num_points: tuple[int, ...] | None = None, ) -> torch.Tensor: """Create an equispaced coordinate grid for the implicit neural representation. @@ -130,7 +149,10 @@ def make_equispaced_grid( sampling : tuple of float Sampling interval for each dimension (spacing_0, spacing_1, ...). Length must match in_features. - + num_points : tuple of int, optional + Number of points to sample in each dimension. If None, the number of points + is calculated from the sampling interval. If both sampling and num_points are provided, + num_points takes precedence. Returns ------- torch.Tensor @@ -145,7 +167,7 @@ def make_equispaced_grid( Examples -------- For a model with in_features=2: - >>> bounds = ((0, 1), (0, 1)) + >>> bounds = ((-1, 1), (-1, 1)) >>> sampling = (0.1, 0.1) >>> coords = siren.make_equispaced_grid(bounds, sampling) """ @@ -153,15 +175,28 @@ def make_equispaced_grid( raise ValueError( f"Bounds length ({len(bounds)}) must match in_features ({self.in_features})" ) - if len(sampling) != self.in_features: - raise ValueError( - f"Sampling length ({len(sampling)}) must match in_features ({self.in_features})" + if sampling is not None and num_points is not None: + raise ValueError("Only one of sampling or num_points can be provided") + if sampling is not None: + if len(sampling) != self.in_features: + raise ValueError( + f"Sampling length ({len(sampling)}) must match in_features ({self.in_features})" + ) + num_points = tuple( + int((bound_max - bound_min) / sample) + 1 + for (bound_min, bound_max), sample in zip(bounds, sampling) ) - + elif num_points is not None: + if len(num_points) != self.in_features: + raise ValueError( + f"Num points length ({len(num_points)}) must match in_features ({self.in_features})" + ) + else: + raise ValueError("Either sampling or num_points must be provided") grids = [] - for (bound_min, bound_max), sample in zip(bounds, sampling): - num_points = int((bound_max - bound_min) / sample) + 1 - grids.append(torch.linspace(bound_min, bound_max, num_points)) + for i, (bound_min, bound_max) in enumerate(bounds): + n = num_points[i] + grids.append(torch.linspace(bound_min, bound_max, n)) coords = torch.meshgrid(*grids, indexing="ij") coords = torch.stack(coords, dim=-1).to(self.dtype) @@ -182,6 +217,7 @@ def __init__( alpha: float = 1.0, dtype: torch.dtype = torch.float32, final_activation: str | Callable = "identity", + winner_initialization: bool | int = False, ) -> None: """Initialize HSiren. @@ -217,4 +253,5 @@ def __init__( hsiren=True, dtype=dtype, final_activation=final_activation, + winner_initialization=winner_initialization, ) diff --git a/src/quantem/core/ml/loss_functions.py b/src/quantem/core/ml/loss_functions.py index 272115ea..a81b4fb0 100644 --- a/src/quantem/core/ml/loss_functions.py +++ b/src/quantem/core/ml/loss_functions.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING, Callable +import torch.nn as nn + from quantem.core import config if TYPE_CHECKING: @@ -9,133 +11,165 @@ import torch -def get_loss_function(name: str | Callable, dtype: torch.dtype) -> Callable: - """Get a loss function by name or return callable if provided. +def get_loss_module(name: str | nn.Module | Callable, dtype: torch.dtype, **kwargs) -> nn.Module: + """Return a loss *module* by name, or wrap/return what was provided.""" + if isinstance(name, nn.Module): + return name - Parameters - ---------- - name : str or Callable - Loss function name or callable function. - dtype : torch.dtype - Data type (used to determine complex vs real loss functions). + if callable(name) and not isinstance(name, str): + # Wrap a bare callable into an nn.Module + class _CallableLoss(nn.Module): + def __init__(self, fn: Callable): + super().__init__() + self.fn = fn - Returns - ------- - Callable - Loss function. + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + return self.fn(pred, target) + + return _CallableLoss(name) + + loss_name = str(name).lower() - Raises - ------ - ValueError - If loss function name is unknown for the given dtype. - """ - if isinstance(name, Callable): - return name - else: - name = name.lower() if dtype.is_complex: - if name in ["l2", "complex_l2"]: - return complex_l2 - elif name in ["complex_cartesian_l2"]: - return complex_cartesian_l2 - elif name in ["amp_phase_l2"]: - return amp_phase_l2 - elif name in ["combined_l2"]: - return combined_l2 - else: - raise ValueError(f"Unknown loss function for complex dtype: {name}") - else: - if name in ["l2"]: - return torch.nn.functional.mse_loss - elif name in ["l1"]: - return torch.nn.functional.l1_loss - else: - raise ValueError(f"Unknown loss function for real dtype: {name}") - - -def complex_l2(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: - """Compute L2 loss for complex tensors (separate real and imaginary parts). - - Parameters - ---------- - pred : torch.Tensor - Predicted complex tensor. - target : torch.Tensor - Target complex tensor. - - Returns - ------- - torch.Tensor - L2 loss value. - """ - real_l2 = torch.mean((pred.real - target.real) ** 2) - imag_l2 = torch.mean((pred.imag - target.imag) ** 2) - return (real_l2 + imag_l2) / 2 + if loss_name in {"l2", "complex_l2"}: + return ComplexL2Loss(**kwargs) + if loss_name in {"complex_cartesian_l2"}: + return ComplexCartesianL2Loss(**kwargs) + if loss_name in {"amp_phase_l2"}: + return AmpPhaseL2Loss(**kwargs) + if loss_name in {"combined_l2"}: + return CombinedL2Loss(**kwargs) + raise ValueError(f"Unknown loss module for complex dtype: {loss_name}") + + # real dtype + if loss_name in {"l2"}: + return nn.MSELoss(**kwargs) + if loss_name in {"l1"}: + return nn.L1Loss(**kwargs) + if loss_name in {"smooth_l1"}: + return nn.SmoothL1Loss(**kwargs) + if loss_name in {"charbonnier"}: + return CharbonnierLoss(**kwargs) + if loss_name in {"llmse"}: + return LLMSELoss(**kwargs) + if loss_name in {"mse_log_mse"}: + return MSELogMSELoss(**kwargs) + + raise ValueError(f"Unknown loss module for real dtype: {loss_name}") + + +class ComplexL2Loss(nn.Module): + """L2 loss for complex tensors (separate real/imag, then average).""" + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + real_l2 = torch.mean((pred.real - target.real) ** 2) + imag_l2 = torch.mean((pred.imag - target.imag) ** 2) + return (real_l2 + imag_l2) / 2 -def complex_cartesian_l2(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: - """Compute L2 loss for complex tensors in Cartesian coordinates. - Parameters - ---------- - pred : torch.Tensor - Predicted complex tensor. - target : torch.Tensor - Target complex tensor. +class ComplexCartesianL2Loss(nn.Module): + """L2 loss for complex tensors in Cartesian form: E[(Δre^2 + Δim^2)].""" - Returns - ------- - torch.Tensor - L2 loss value. + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + real_dif = pred.real - target.real + imag_dif = pred.imag - target.imag + return torch.mean(real_dif**2 + imag_dif**2) + + +class AmpPhaseL2Loss(nn.Module): + """L2 loss on amplitude + wrapped phase.""" + + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + amp_l2 = ((target.abs() - pred.abs()) ** 2).mean() + + phase_dif = torch.abs(target.angle() - pred.angle()) + phase_dif = torch.min(phase_dif, 2 * torch.pi - phase_dif) # wrap to [0, pi] + phase_l2 = torch.mean(phase_dif**2) + + return amp_l2 + phase_l2 + + +class CombinedL2Loss(nn.Module): + """Weighted sum of AmpPhaseL2 and ComplexL2. + + loss = alpha * amp_phase + (1 - alpha) * complex_l2 """ - real_dif = pred.real - target.real - imag_dif = pred.imag - target.imag - loss = torch.mean(real_dif**2 + imag_dif**2) - return loss - - -def amp_phase_l2(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: - """Compute L2 loss for complex tensors in amplitude-phase representation. - - Parameters - ---------- - pred : torch.Tensor - Predicted complex tensor. - target : torch.Tensor - Target complex tensor. - - Returns - ------- - torch.Tensor - L2 loss value (amplitude + phase). + + def __init__(self, alpha: float = 0.7): + super().__init__() + self.alpha = float(alpha) + self.complex_l2 = ComplexL2Loss() + self.amp_phase_l2 = AmpPhaseL2Loss() + + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + comp_l2 = self.complex_l2(pred, target) + amp_ph_l2 = self.amp_phase_l2(pred, target) + return self.alpha * amp_ph_l2 + (1 - self.alpha) * comp_l2 + + +class MSELogMSELoss(nn.Module): + def __init__( + self, + eps: float = 1e-8, + reduction: str = "mean", + ): + super(MSELogMSELoss, self).__init__() + self.eps = eps + self.reduction = reduction + + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + mse = (pred - target) ** 2 + log_mse = -mse * torch.log(mse + self.eps) + if self.reduction == "mean": + return log_mse.mean() + elif self.reduction == "sum": + return log_mse.sum() + return log_mse + + +class LLMSELoss(nn.Module): """ - amp_l2 = ((target.abs() - pred.abs()) ** 2).mean() - phase_dif = torch.abs(target.angle() - pred.angle()) - phase_dif = torch.min(phase_dif, 2 * torch.pi - phase_dif) # phase wrapping - phase_l2 = torch.mean(phase_dif**2) - return amp_l2 + phase_l2 - - -def combined_l2(pred: torch.Tensor, target: torch.Tensor, alpha: float = 0.7) -> torch.Tensor: - """Combined L2 loss: weighted sum of amplitude-phase and complex L2 losses. - - Parameters - ---------- - pred : torch.Tensor - Predicted complex tensor. - target : torch.Tensor - Target complex tensor. - alpha : float, optional - Weight for amplitude-phase loss. Larger alpha gives more weight to - amp/phase, smaller alpha gives more weight to real/imag, by default 0.7 - - Returns - ------- - torch.Tensor - Combined L2 loss value. - - different alpha values can affect stability of training. + Logarithmic Linear Mean Squared Error (LLMSE) loss: + L = -log(1 - |y - y_hat| / max(|y - y_hat|)) """ - comp_l2 = complex_l2(pred, target) - amp_ph_l2 = amp_phase_l2(pred, target) - return alpha * amp_ph_l2 + (1 - alpha) * comp_l2 + + def __init__(self, eps: float = 1e-8, reduction: str = "mean"): + super().__init__() + self.eps = eps + self.reduction = reduction + + def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + # Absolute residual + abs_diff = torch.abs(pred - target) + + # Normalization by max error in batch (avoid div-by-zero) + max_diff = torch.max(abs_diff.detach()) + self.eps + norm_diff = abs_diff / max_diff + + # Apply -log(1 - normalized_error) + loss = -torch.log(1.0 - norm_diff + self.eps) + + # Reduce + if self.reduction == "mean": + return loss.mean() + elif self.reduction == "sum": + return loss.sum() + return loss + + +class CharbonnierLoss(nn.Module): + def __init__(self, epsilon=1e-12, reduction="mean"): + super(CharbonnierLoss, self).__init__() + self.epsilon = epsilon + self.reduction = reduction + + def forward(self, prediction, target): + diff = prediction - target + loss = torch.sqrt(diff * diff + self.epsilon**2) + + if self.reduction == "mean": + return torch.mean(loss) + elif self.reduction == "sum": + return torch.sum(loss) + else: # 'none' + return loss diff --git a/src/quantem/tomography/preprocess/__init__.py b/src/quantem/core/ml/models/__init__.py similarity index 100% rename from src/quantem/tomography/preprocess/__init__.py rename to src/quantem/core/ml/models/__init__.py diff --git a/src/quantem/core/ml/models/kplanes.py b/src/quantem/core/ml/models/kplanes.py new file mode 100644 index 00000000..cdf55261 --- /dev/null +++ b/src/quantem/core/ml/models/kplanes.py @@ -0,0 +1,732 @@ +""" +Tensor Decomposition Methods for INR-based reconstructions +""" + +import itertools +from typing import Callable, Optional, Sequence + +# import tinycudann as tcnn +import torch +import torch.nn.functional as F +from torch import nn + +from .model_base import PPLR, TensorDecompositionModel +from .so3params import SO3ParamQuat, SO3ParamR9SVD + +""" +K-planes utility functions +""" + + +def grid_sample_wrapper( + grid: torch.Tensor, coords: torch.Tensor, align_corners: bool = True +) -> torch.Tensor: + """ + Performs bilinear interpolation on a grid at given coordinates. + + Args: + grid: Grid tensor of shape [B, C, H, W] or [C, H, W] + coords: Coordinate tensor of shape [B, N, 2] or [N, 2] + align_corners: Whether to align corners + + Returns: + Interpolated values of shape [B, N, C] or [N, C] + """ + grid_dim = coords.shape[-1] + + if grid.dim() == grid_dim + 1: + # no batch dimension present, need to add it + grid = grid.unsqueeze(0) + if coords.dim() == 2: + coords = coords.unsqueeze(0) + + if grid_dim == 2 or grid_dim == 3: + grid_sampler = F.grid_sample + else: + raise NotImplementedError( + f"Grid-sample was called with {grid_dim}D data but is only " + f"implemented for 2 and 3D data." + ) + + coords = coords.view([coords.shape[0]] + [1] * (grid_dim - 1) + list(coords.shape[1:])) + B, feature_dim = grid.shape[:2] + n = coords.shape[-2] + interp = grid_sampler( + grid, # [B, feature_dim, reso, ...] + coords, # [B, 1, ..., n, grid_dim] + align_corners=align_corners, + mode="bilinear", + padding_mode="border", + ) + interp = interp.view(B, feature_dim, n).transpose(-1, -2) # [B, n, feature_dim] + interp = interp.squeeze() # [B?, n, feature_dim?] + return interp + + +def init_planes( + in_dim: int, + out_dim: int, + resolution: Sequence[int], + init_range: tuple = (0.1, 0.5), +) -> nn.ParameterList: + """Create the set of 2D planes for a k-plane decomposition. + + For in_dim=3 (spatial), this creates 3 planes: XY, XZ, YZ. + For in_dim=4 (spatial + time), this creates 6 planes: XY, XZ, XT, YZ, YT, ZT. + Time planes (those involving axis 3) are initialized to 1 so they start + as identity multipliers. + + Args: + in_dim: Dimensionality of the input coordinates (3 or 4). + out_dim: Number of feature channels per plane. + resolution: Resolution along each axis, e.g. [128, 128, 128]. + init_range: (a, b) for uniform initialization of spatial planes. + + Returns: + nn.ParameterList of plane parameters, each of shape [1, out_dim, res_j, res_i]. + """ + assert len(resolution) == in_dim + # All pairs of axes + axis_pairs = list(itertools.combinations(range(in_dim), 2)) + planes = nn.ParameterList() + a, b = init_range + for pair in axis_pairs: + # grid_sample expects (N, C, H, W) — so resolution is reversed + shape = [1, out_dim] + [resolution[ax] for ax in reversed(pair)] + param = nn.Parameter(torch.empty(*shape)) + # Time planes init to 1; spatial planes init uniform + if in_dim == 4 and 3 in pair: + nn.init.ones_(param) + else: + nn.init.uniform_(param, a=a, b=b) + planes.append(param) + return planes + + +def query_planes( + pts: torch.Tensor, + planes: nn.ParameterList, + in_dim: int, +) -> float: + """Query the k-plane representation at a batch of points. + + Projects each point onto every axis-pair plane, bilinearly interpolates, + and returns the element-wise product across all planes. + + Args: + pts: (B, in_dim) coordinates in [-1, 1]. + planes: The ParameterList from init_planes. + in_dim: 3 or 4. + + Returns: + (B, out_dim) features. + """ + axis_pairs = list(itertools.combinations(range(in_dim), 2)) + result = 1.0 + for plane_param, pair in zip(planes, axis_pairs): + # Extract the 2D coords for this plane + coords_2d = pts[..., list(pair)] # (B, 2) + coords_2d = coords_2d.view(1, -1, 1, 2) # (1, B, 1, 2) for grid_sample + # grid_sample: input (N,C,H,W), grid (N, H_out, W_out, 2) + sampled = F.grid_sample( + plane_param, # (1, C, H, W) + coords_2d, # (1, B, 1, 2) + align_corners=True, + mode="bilinear", + padding_mode="border", + ) # -> (1, C, B, 1) + sampled = sampled.squeeze(0).squeeze(-1).T # (B, C) + result = result * sampled + return result # pyright: ignore[reportReturnType] + + +def interpolate_ms_features( + pts: torch.Tensor, + ms_grids: nn.ParameterList, +) -> torch.Tensor: + mat_mode = [[0, 1], [0, 2], [1, 2]] + coord_plane = torch.stack( + [ + pts[:, mat_mode[0]], + pts[:, mat_mode[1]], + pts[:, mat_mode[2]], + ] + ).view(3, -1, 1, 2) + + per_scale = [] + for plane_coef in ms_grids: + C = plane_coef.shape[1] + feats = F.grid_sample( + plane_coef, coord_plane, align_corners=True, mode="bilinear", padding_mode="border" + ).reshape(3, C, -1) + fused = feats[0] * feats[1] * feats[2] + per_scale.append(fused.T) + + return torch.cat(per_scale, dim=-1) + + +class KPlanes(PPLR, TensorDecompositionModel): + """ + K-Planes model adapted from Fridovich-Keil et al., https://arxiv.org/abs/2301.10241 + """ + def __init__( + self, + # Grid parameters + grid_dimensions: int = 2, + input_coords_dims: int = 3, + M_features: int = 32, + resolution: Sequence[int] = (200, 200, 200), + multiscale_res_multipliers: Optional[Sequence[int]] = None, + concat_features: bool = True, + density_activation: Callable = lambda x: F.softplus( + x - 1 + ), # Keep playing around with this and trunc_exp + # Hybrid MLP parameters + use_hybrid_mlp: bool = False, + hybrid_hidden_dim: int = 64, + hybrid_num_layers: int = 2, + ): + """ + Assume coords are [-1, 1] in each dimension. + """ + super().__init__() + self._td_type = "kplanes" + self.grid_dimensions = grid_dimensions + self.input_coords_dims = input_coords_dims + self.M_features = M_features + self.resolution = resolution + self.multiscale_res_multipliers = multiscale_res_multipliers or [1] + self.concat_features = concat_features + self.density_activation = density_activation + + self.grids = nn.ParameterList() + self.feature_dim = 0 + for res_mult in self.multiscale_res_multipliers: + scaled_res = [int(r * res_mult) for r in self.resolution] + plane = nn.Parameter(torch.empty(3, self.M_features, scaled_res[1], scaled_res[0])) + nn.init.uniform_(plane, 0.1, 0.5) + self.grids.append(plane) + self.feature_dim += self.M_features + + # Network head + if use_hybrid_mlp: + hybrid_hidden_dim = int(hybrid_hidden_dim) + hybrid_num_layers = int(hybrid_num_layers) + if hybrid_hidden_dim <= 0: + raise ValueError(f"hybrid_hidden_dim must be >= 1, got {hybrid_hidden_dim}") + if hybrid_num_layers <= 0: + raise ValueError(f"hybrid_num_layers must be >= 1, got {hybrid_num_layers}") + + factory = {} # add dtype/device kwargs here if needed + layers = [] + in_dim = self.feature_dim + for _ in range(hybrid_num_layers): + lin = nn.Linear(in_dim, hybrid_hidden_dim, **factory) + nn.init.kaiming_uniform_(lin.weight, a=0.0, nonlinearity="relu") + nn.init.zeros_(lin.bias) + layers.append(lin) + layers.append(nn.ReLU(inplace=True)) + in_dim = hybrid_hidden_dim + + out = nn.Linear(in_dim, 1, bias=True, **factory) + nn.init.normal_(out.weight, std=0.01) + nn.init.zeros_(out.bias) + layers.append(out) + self.sigma_net = nn.Sequential(*layers) + + def get_densities(self, coords: torch.Tensor): + """Computes and returns densities""" + pts = coords.reshape(-1, 3) + features = interpolate_ms_features( + pts=pts, + ms_grids=self.grids, + ) + density_before_activation = self.sigma_net(features) + density = self.density_activation(density_before_activation) + return density + + def forward(self, pts: torch.Tensor): + return self.get_densities(pts) + + def get_params(self) -> dict[str, list[torch.nn.Parameter]]: + return { + "grids": list(self.grids.parameters()), + "sigma_net": list(self.sigma_net.parameters()), + } + + @property + def param_keys(self) -> list[str]: + return ["grids", "sigma_net"] + + @property + def td_type(self) -> str: + return self._td_type + + @td_type.setter + def td_type(self, td_type: str): + if not isinstance(td_type, str): + raise TypeError("td_type must be a string") + self._td_type = td_type + + @property + def tilted(self) -> bool: + return False + + @tilted.setter + def tilted(self, tilted: bool): + if not isinstance(tilted, bool): + raise TypeError("tilted must be a boolean") + self._tilted = tilted + + @property + def grids(self) -> torch.nn.ParameterList: + return self._grids + + @grids.setter + def grids(self, grids: torch.nn.ParameterList): + if not isinstance(grids, torch.nn.ParameterList): + raise TypeError("Grids must be a ParameterList") + self._grids = grids + + @property + def resolution(self) -> list[int]: + return self._resolution + + @resolution.setter + def resolution(self, resolution: Sequence[int]): + if not isinstance(resolution, Sequence): + raise TypeError("Resolution must be a sequence") + self._resolution = list(resolution) + + +# --------------------------------------------------------------------------- +# KPlanesTILTED +# --------------------------------------------------------------------------- + + +def interpolate_ms_features_tilted( + pts: torch.Tensor, # (B, 3) + ms_grids: nn.ParameterList, # each grid: (3*T, C, H, W) + rotation_matrices: torch.Tensor, # (T, 3, 3) +) -> torch.Tensor: + """ + Fully-vectorized multi-scale, multi-rotation K-Planes feature interpolation. + Returns features of shape (B, C * T * num_scales). + """ + T = rotation_matrices.shape[0] + B = pts.shape[0] + + # (T, B, 3) — rotate all points by all rotations at once + rotated = torch.einsum("tij,bj->tbi", rotation_matrices, pts) + + # Build (T, 3, B, 2) coords for planes XY, ZX, YZ in one shot. + # index_select is faster and cleaner than advanced indexing with python lists. + # Plane axis layout: XY=(0,1), ZX=(2,0), YZ=(1,2) + idx = torch.tensor([[0, 1], [2, 0], [1, 2]], device=pts.device) # (3, 2) + # rotated: (T, B, 3) -> gather along last dim with idx (3, 2) + # Result: (T, 3, B, 2) + coords = ( + rotated.unsqueeze(1).expand(T, 3, B, 3).gather(-1, idx.view(1, 3, 1, 2).expand(T, 3, B, 2)) + ) + + # Flatten (T, 3) -> 3*T so it matches grid's first dim, and add the H_out=1 axis + coord_tensor = coords.reshape(3 * T, B, 1, 2) # (3T, B, 1, 2) + + per_scale_features = [] + for plane_coef in ms_grids: + # plane_coef: (3T, C, H, W) + C = plane_coef.shape[1] + + sampled = F.grid_sample( + plane_coef, + coord_tensor, + align_corners=True, + mode="bilinear", + padding_mode="border", + ) # (3T, C, B, 1) + + # (3T, C, B) -> (T, 3, C, B) -> Hadamard across the "3" dim -> (T, C, B) + sampled = sampled.squeeze(-1).view(T, 3, C, B).prod(dim=1) + + # (T, C, B) -> (B, T, C) -> (B, T*C) to concatenate rotations along feature dim + per_scale_features.append(sampled.permute(2, 0, 1).reshape(B, T * C)) + + # Concatenate across scales -> (B, T * C * num_scales) + return torch.cat(per_scale_features, dim=-1) + + +class KPlanesTILTED(KPlanes): + """ + K-Planes with T learned SO(3) rotations (TILTED). Adapted from Yi et al., https://arxiv.org/abs/2308.15461 + + Inherits KPlanes for the sigma_net, density_activation, and get_params + interface. Overrides: + * __init__ – replaces the axis-aligned grids with (3*T)-plane + grids and adds SO3Param. + * get_densities – calls the TILTED interpolation instead. + * get_params – adds "so3" key so callers can set a separate lr. + * param_keys – updated list. + + Two-phase optimization + ---------------------- + Phase 1: instantiate with small `M_features` (and optionally smaller + `resolution` / fewer scales) — the bottleneck model. Train it + until τ converges, then call `extract_tau_state()`. + Phase 2: instantiate at full capacity, call `load_tau_state(M_bneck)` + to seed the rotations, then train normally. + + Parameters + ---------- + M_features : int + Feature channels *per transform per scale*. Total feature_dim will + be M_features * T * len(multiscale_res_multipliers). + T : int + Number of learned rotations (TILTED-T in the paper; 4 or 8 recommended). + Must match between phase 1 and phase 2 when doing two-phase transfer. + tau_init : str + "random" (paper default) or "identity". + Irrelevant if you're calling load_tau_state() right after __init__. + All other args are forwarded to KPlanes. + """ + + def __init__( + self, + # Grid parameters + input_coords_dims: int = 3, + M_features: int = 32, + resolution: Sequence[int] = (200, 200, 200), + multiscale_res_multipliers: Optional[Sequence[float]] = None, + density_activation: Callable = lambda x: F.softplus(x - 1), + # TILTED parameters + T: int = 4, + tau_init: str = "random", + # Hybrid MLP parameters + use_hybrid_mlp: bool = False, + hybrid_hidden_dim: int = 64, + hybrid_num_layers: int = 2, + so3_param_type: str = "r9svd", + ): + self._td_type = "tilted" + if input_coords_dims != 3: + raise NotImplementedError("KPlanesTILTED is implemented for 3D only.") + if T < 1: + raise ValueError(f"T must be >= 1, got {T}") + + multiscale_res_multipliers = list(multiscale_res_multipliers or [1]) + num_scales = len(multiscale_res_multipliers) + + # Total feature dim seen by the MLP head. + # Each scale contributes M_features * T channels. + feature_dim = M_features * T * num_scales + + # Call KPlanes.__init__ with grid_dimensions=2 so it builds sigma_net + # correctly; we immediately replace self.grids below. + super().__init__( + grid_dimensions=2, + input_coords_dims=3, + M_features=M_features, + resolution=resolution, + multiscale_res_multipliers=multiscale_res_multipliers, + concat_features=True, + density_activation=density_activation, + use_hybrid_mlp=use_hybrid_mlp, + hybrid_hidden_dim=hybrid_hidden_dim, + hybrid_num_layers=hybrid_num_layers, + ) + + self.T = T + + # ---- Rebuild grids: (3*T, M_features, H, W) per scale ---- + self.grids = nn.ParameterList() + for res_mult in multiscale_res_multipliers: + scaled_res = [int(r * res_mult) for r in resolution] + plane = nn.Parameter(torch.empty(3 * T, M_features, scaled_res[1], scaled_res[0])) + nn.init.uniform_(plane, 0.1, 0.5) + self.grids.append(plane) + + # ---- Rebuild sigma_net with the correct feature_dim ---- + # KPlanes built sigma_net with self.feature_dim (= M * num_scales), + # which is wrong for T > 1. Rebuild here. + self.feature_dim = feature_dim + self._build_sigma_net(use_hybrid_mlp, hybrid_hidden_dim, hybrid_num_layers) + + # ---- Learnable rotations ---- + self.set_so3_param_type(so3_param_type, init=tau_init) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_sigma_net( + self, + use_hybrid_mlp: bool, + hybrid_hidden_dim: int, + hybrid_num_layers: int, + ) -> None: + """Rebuild sigma_net for self.feature_dim (called after grids are set).""" + if use_hybrid_mlp: + layers = [] + in_dim = self.feature_dim + for _ in range(hybrid_num_layers): + lin = nn.Linear(in_dim, hybrid_hidden_dim) + nn.init.kaiming_uniform_(lin.weight, a=0.0, nonlinearity="relu") + nn.init.zeros_(lin.bias) + layers.append(lin) + layers.append(nn.ReLU(inplace=True)) + in_dim = hybrid_hidden_dim + out = nn.Linear(in_dim, 1, bias=True) + nn.init.normal_(out.weight, std=0.01) + nn.init.zeros_(out.bias) + layers.append(out) + self.sigma_net = nn.Sequential(*layers) + else: + # Single-linear "explicit" decoder. Small init -> density ~ 0 initially. + self.sigma_net = nn.Linear(self.feature_dim, 1, bias=True) + nn.init.normal_(self.sigma_net.weight, std=0.01) + nn.init.zeros_(self.sigma_net.bias) + + # ------------------------------------------------------------------ + # Core forward + # ------------------------------------------------------------------ + + def get_densities(self, coords: torch.Tensor) -> torch.Tensor: + pts = coords.reshape(-1, 3) + R = self.so3.as_matrix() # (T, 3, 3) + features = interpolate_ms_features_tilted( + pts=pts, + ms_grids=self.grids, + rotation_matrices=R, + ) + density_before_activation = self.sigma_net(features) + return self.density_activation(density_before_activation) + + def forward(self, pts: torch.Tensor) -> torch.Tensor: + return self.get_densities(pts) + + # ------------------------------------------------------------------ + # Parameter groups + # ------------------------------------------------------------------ + + def get_params(self) -> dict[str, list[nn.Parameter]]: + return { + "grids": list(self.grids.parameters()), + "sigma_net": list(self.sigma_net.parameters()), + "so3": list(self.so3.parameters()), + } + + @property + def param_keys(self) -> list[str]: + return ["grids", "sigma_net", "so3"] + + # ------------------------------------------------------------------ + # Two-phase transfer: extract / load learned rotations + # ------------------------------------------------------------------ + + def extract_tau_state(self) -> torch.Tensor: + """ + Returns the current raw R^9 matrices (detached copy) so they can be + used to initialise a phase-2 model via `load_tau_state`. + + Returns + ------- + torch.Tensor of shape (T, 3, 3) + """ + return self.so3.M.detach().cpu().clone() + + def load_tau_state(self, M: torch.Tensor) -> None: + """ + Load pre-trained rotation matrices (e.g. from a bottleneck phase-1 model). + + No orthogonalization is needed — SO3Param.as_matrix() projects to SO(3) + via SVD on every forward pass. + + Parameters + ---------- + M : torch.Tensor of shape (T, 3, 3) + Raw unconstrained matrices from `extract_tau_state()`. + """ + if M.shape != self.so3.M.shape: + raise ValueError( + f"Shape mismatch: got {M.shape}, expected {self.so3.M.shape}. " + f"Make sure T matches between phase 1 and phase 2." + ) + with torch.no_grad(): + self.so3.M.copy_(M.to(self.so3.M.device)) + + # ------------------------------------------------------------------ + # Pretty print + # ------------------------------------------------------------------ + + def extra_repr(self) -> str: + return ( + f"T={self.T}, " + f"M_features={self.M_features}, " + f"feature_dim={self.feature_dim}, " + f"num_scales={len(self.multiscale_res_multipliers)}" + ) + + def set_so3_param_type(self, so3_param_type: str, init: str = "rand") -> None: + """ + Set the SO3 parameterization type. + + Parameters + ---------- + so3_param_type : str + SO3 parameterization type ("quat" or "r9svd"). + """ + if so3_param_type == "r9svd": + self.so3 = SO3ParamR9SVD(self.T, init=init) + elif so3_param_type == "quat": + self.so3 = SO3ParamQuat(self.T, init=init) + else: + raise ValueError(f"Invalid SO3 parameterization type: {so3_param_type}") + + @property + def tilted(self) -> bool: + return True + + +# CP Decomp for Warmup SO3 rotations + + +def interpolate_ms_features_cp_tilted( + pts: torch.Tensor, # (B, 3) + ms_grids: nn.ParameterList, # each grid: (3*T, C, L) — 1D lines + rotation_matrices: torch.Tensor, # (T, 3, 3) +) -> torch.Tensor: + """ + CP (vector outer product) version of TILTED interpolation. + Returns features of shape (B, C * T * num_scales). + """ + T = rotation_matrices.shape[0] + B = pts.shape[0] + + # Rotate all points by all rotations: (T, B, 3) + rotated = torch.einsum("tij,bj->tbi", rotation_matrices, pts) + + per_scale_features = [] + for line_coef in ms_grids: + # line_coef: (3T, C, L) — three 1D feature lines per transform (x, y, z) + C, _ = line_coef.shape[1], line_coef.shape[2] + + # For each transform t, we need three 1D samples: at x_t, y_t, z_t. + # Lay them out as (3T, B) coords, matching line_coef's first dim. + # Axis order per transform: x, y, z. + coords_1d = rotated.reshape(T, B, 3).permute(0, 2, 1).reshape(3 * T, B) + # coords_1d: (3T, B), each row is samples along one axis for one transform + + # grid_sample wants 4D input for 2D sampling, or we can use 1D via a + # (3T, C, 1, L) reshape and pass 2D coords with y fixed at 0. + # Simpler: use F.grid_sample with a 4D trick, or just do manual linear interp. + # Here's the grid_sample way: + line_coef_4d = line_coef.unsqueeze(2) # (3T, C, 1, L) + # grid: need (3T, Hout=1, Wout=B, 2), with x = coord, y = 0 + grid = torch.stack( + [ + coords_1d, # x + torch.zeros_like(coords_1d), # y + ], + dim=-1, + ).unsqueeze(1) # (3T, 1, B, 2) + + sampled = F.grid_sample( + line_coef_4d, + grid, + align_corners=True, + mode="bilinear", + padding_mode="border", + ).squeeze(2) # (3T, C, B) + + # Hadamard across the 3 axes per transform: (T, 3, C, B) -> (T, C, B) + sampled = sampled.view(T, 3, C, B).prod(dim=1) + + # (T, C, B) -> (B, T*C) + per_scale_features.append(sampled.permute(2, 0, 1).reshape(B, T * C)) + + return torch.cat(per_scale_features, dim=-1) + + +class CPTilted(PPLR, TensorDecompositionModel): + """ + CP decomposition with TILTED rotations — the true bottleneck model for + phase 1. Rank-1-per-channel feature representation. Adapted from Yi et al., https://arxiv.org/abs/2308.15461 + + Shares the SO3Param and sigma_net design with KPlanesTILTED so you can + lift τ directly across: cp_model.extract_tau_state() -> + kplanes_model.load_tau_state(). + """ + + def __init__( + self, + C: int = 4, # channels per transform per scale + resolution: Sequence[int] = (128, 128, 128), + multiscale_res_multipliers: Optional[Sequence[int]] = None, + T: int = 4, + tau_init: str = "random", + density_activation: Callable = lambda x: F.softplus(x - 1), + so3_param_type: str = "r9svd", + ): + super().__init__() + self._td_type = "cp_tilted" + self.T = T + self.C = C + self.multiscale_res_multipliers = list(multiscale_res_multipliers or [1]) + self.density_activation = density_activation + + # 1D feature lines, one per axis per transform per scale. + # Shape per scale: (3*T, C, L). We use max(resolution) for L; if your + # scene is strongly anisotropic use 3 separate lines per axis. + self.grids = nn.ParameterList() + for mult in self.multiscale_res_multipliers: + L = int(max(resolution) * mult) + line = nn.Parameter(torch.empty(3 * T, C, L)) + nn.init.uniform_(line, 0.1, 0.5) + self.grids.append(line) + + self.feature_dim = C * T * len(self.multiscale_res_multipliers) + + # Same minimal single-linear decoder as your KPlanesTILTED default. + self.sigma_net = nn.Linear(self.feature_dim, 1, bias=True) + nn.init.normal_(self.sigma_net.weight, std=0.01) + nn.init.zeros_(self.sigma_net.bias) + + if so3_param_type == "r9svd": + self.so3 = SO3ParamR9SVD(T, init=tau_init) + elif so3_param_type == "quat": + self.so3 = SO3ParamQuat(T, init=tau_init) + else: + raise ValueError(f"Unknown SO3 param type: {so3_param_type}") + + def get_densities(self, coords: torch.Tensor) -> torch.Tensor: + pts = coords.reshape(-1, 3) + R = self.so3.as_matrix() + features = interpolate_ms_features_cp_tilted(pts, self.grids, R) + return self.density_activation(self.sigma_net(features)) + + def forward(self, pts): + return self.get_densities(pts) + + def get_params(self): + return { + "grids": list(self.grids.parameters()), + "sigma_net": list(self.sigma_net.parameters()), + "so3": list(self.so3.parameters()), + } + + @property + def param_keys(self): + return ["grids", "sigma_net", "so3"] + + @property + def td_type(self) -> str: + return self._td_type + + def extract_tau_state(self) -> torch.Tensor: + return self.so3.M.detach().clone() + + @property + def tilted(self) -> bool: + return True + + +KPlanesType = KPlanes | KPlanesTILTED | CPTilted diff --git a/src/quantem/core/ml/models/model_base.py b/src/quantem/core/ml/models/model_base.py new file mode 100644 index 00000000..60c1c4f6 --- /dev/null +++ b/src/quantem/core/ml/models/model_base.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from typing import Dict + +import torch.nn as nn + + +class PPLR(ABC): + """ + Abstract base class for models that require multi-parameter optimization. + """ + + @abstractmethod + def get_params(self) -> Dict[str, list[nn.Parameter]]: + """ + Return a dictionary of parameters grouped by key. + + For example if your nn.Module has multiple optimizable parameter groups, + you can return a dictionary with the keys "grids" and "sigma_net" + (KPlanes example). + """ + pass + + @property + @abstractmethod + def param_keys(self) -> list[str]: + """List of available parameter-group keys.""" + pass + + +class TensorDecompositionModel(nn.Module, ABC): + """ + Base class for factored tensor-decomposition models. + + Subclasses must set ``td_type`` as a normal attribute in ``__init__``. + """ + + td_type: str + + +class PlanarDecompositionModel(TensorDecompositionModel, PPLR): + """ + Planar factored-grid models: K-Planes, K-Planes-TILTED, HexPlane, tri-planes. + + Subclasses must set ``grids``, ``tilted``, and ``resolution`` as normal + attributes in ``__init__``. + """ + + grids: nn.ParameterList + tilted: bool + resolution: list[int] diff --git a/src/quantem/core/ml/models/so3params.py b/src/quantem/core/ml/models/so3params.py new file mode 100644 index 00000000..cd55d0ac --- /dev/null +++ b/src/quantem/core/ml/models/so3params.py @@ -0,0 +1,192 @@ +import math +from typing import Literal + +import torch +import torch.nn as nn +import torch.nn.functional as F + +# --- Tilted KPlanes --- + +# --------------------------------------------------------------------------- +# SO(3) quaternion parameter module +# --------------------------------------------------------------------------- + + +class SO3ParamQuat(nn.Module): + """ + Stores T unit quaternions as learnable parameters in R^4 and normalises + them on every call to `as_matrix()`. + + Quaternion convention: [x, y, z, w] (scalar-last, same as scipy). + + Initialisation + -------------- + "random" – uniform sampling over SO(3) via Shoemake's method. + "identity" – all rotations start as the identity (good for fine-tuning). + """ + + def __init__(self, T: int, init: str = "random"): + super().__init__() + if T < 1: + raise ValueError(f"T must be >= 1, got {T}") + quats = self._init_quaternions(T, init) # (T, 4) + self.quats = nn.Parameter(quats) + + @staticmethod + def quat_to_rotmat(q: torch.Tensor) -> torch.Tensor: + """Unit quaternion (..., 4) [x, y, z, w] -> rotation matrix (..., 3, 3). + Assumes q is already normalized.""" + x, y, z, w = q.unbind(dim=-1) + xx, yy, zz = x * x, y * y, z * z + xy, xz, yz = x * y, x * z, y * z + wx, wy, wz = w * x, w * y, w * z + R = torch.stack( + [ + 1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy), + 2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx), + 2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy), + ], + dim=-1, + ).reshape(*q.shape[:-1], 3, 3) + return R + + @staticmethod + def rotmat_to_quat(R: torch.Tensor) -> torch.Tensor: + """Rotation matrix (..., 3, 3) -> unit quaternion (..., 4) [x, y, z, w]. + + Shepperd's method: build the four candidate quaternions, each dividing + by a different diagonal combination, then per-element pick the branch + with the largest denominator so we never divide by a near-zero number. + The naive trace-only formula blows up when trace ~ -1 (180deg rotations). + """ + m00, m01, m02 = R[..., 0, 0], R[..., 0, 1], R[..., 0, 2] + m10, m11, m12 = R[..., 1, 0], R[..., 1, 1], R[..., 1, 2] + m20, m21, m22 = R[..., 2, 0], R[..., 2, 1], R[..., 2, 2] + + # 4 * (component^2) for w, x, y, z respectively; these sum to 4. + t = torch.stack( + [ + 1.0 + m00 + m11 + m22, # 4 w^2 + 1.0 + m00 - m11 - m22, # 4 x^2 + 1.0 - m00 + m11 - m22, # 4 y^2 + 1.0 - m00 - m11 + m22, # 4 z^2 + ], + dim=-1, + ) # (..., 4) + + eps = torch.finfo(R.dtype).eps + S = 2.0 * torch.sqrt(t.clamp_min(eps)) # S[k] = 4 * |component_k| + S0, S1, S2, S3 = S.unbind(-1) + + # each candidate in [x, y, z, w] order + cand_w = torch.stack([(m21 - m12) / S0, (m02 - m20) / S0, (m10 - m01) / S0, 0.25 * S0], dim=-1) + cand_x = torch.stack([0.25 * S1, (m01 + m10) / S1, (m02 + m20) / S1, (m21 - m12) / S1], dim=-1) + cand_y = torch.stack([(m01 + m10) / S2, 0.25 * S2, (m12 + m21) / S2, (m02 - m20) / S2], dim=-1) + cand_z = torch.stack([(m02 + m20) / S3, (m12 + m21) / S3, 0.25 * S3, (m10 - m01) / S3], dim=-1) + + cands = torch.stack([cand_w, cand_x, cand_y, cand_z], dim=-2) # (..., 4, 4) + idx = t.argmax(dim=-1) # (...,) + idx = idx[..., None, None].expand(*idx.shape, 1, 4) # (..., 1, 4) + q = cands.gather(-2, idx).squeeze(-2) # (..., 4) + return F.normalize(q, p=2, dim=-1) + + def as_matrix(self) -> torch.Tensor: + return self.quat_to_rotmat(self.normalized()) + + @classmethod + def from_matrix(cls, R: torch.Tensor) -> "SO3ParamQuat": + """Initialize a bank close to the given rotations R (T, 3, 3).""" + obj = cls(R.shape[0], init="identity") + with torch.no_grad(): + obj.quats.copy_(cls.rotmat_to_quat(R)) + return obj + + def extra_repr(self) -> str: + return f"T={self.quats.shape[0]}" + + # ------------------------------------------------------------------ + # Initialisers + # ------------------------------------------------------------------ + + @staticmethod + def _shoemake_sample(T: int) -> torch.Tensor: + """Uniform SO(3) sampling via Shoemake (1992). Returns (T, 4) [x,y,z,w].""" + u = torch.rand(T, 3) + sqrt1_u0 = torch.sqrt(1.0 - u[:, 0]) + sqrt_u0 = torch.sqrt(u[:, 0]) + two_pi = 2.0 * math.pi + x = sqrt1_u0 * torch.sin(two_pi * u[:, 1]) + y = sqrt1_u0 * torch.cos(two_pi * u[:, 1]) + z = sqrt_u0 * torch.sin(two_pi * u[:, 2]) + w = sqrt_u0 * torch.cos(two_pi * u[:, 2]) + return torch.stack([x, y, z, w], dim=-1) # (T, 4) + + @staticmethod + def _identity(T: int) -> torch.Tensor: + """All-identity rotations: [0,0,0,1] * T.""" + q = torch.zeros(T, 4) + q[:, 3] = 1.0 + return q + + @classmethod + def _init_quaternions(cls, T: int, init: str) -> torch.Tensor: + if init == "random": + return cls._shoemake_sample(T) + elif init == "identity": + return cls._identity(T) + else: + raise ValueError(f"Unknown init '{init}'; choose 'random' or 'identity'.") + + # ------------------------------------------------------------------ + # Forward helpers + # ------------------------------------------------------------------ + + def normalized(self) -> torch.Tensor: + """Returns (T, 4) unit quaternions.""" + return F.normalize(self.quats, p=2, dim=-1) + + +class SO3ParamR9SVD(nn.Module): + """ + SO(3) rotation bank using R9+SVD parameterization. + Each rotation is stored as an unconstrained 3x3 matrix M, + projected to SO(3) via SVD+(M) = U diag(1,1,det(UVt)) Vt. + + Based on Rene Geist et al., 2024: https://arxiv.org/abs/2404.11735v1 + """ + + def __init__(self, T: int, init: Literal["random", "identity"] = "random"): + super().__init__() + if init == "random": + M = torch.eye(3).unsqueeze(0).repeat(T, 1, 1) + 0.1 * torch.randn(T, 3, 3) + elif init == "identity": + M = torch.eye(3).unsqueeze(0).repeat(T, 1, 1) + else: + raise ValueError(f"Unknown init '{init}'") + self.M = nn.Parameter(M) + + @staticmethod + def rotmat_to_r9(R: torch.Tensor) -> torch.Tensor: + """Rotation matrix (..., 3, 3) -> R9. Identity embedding: any R in SO(3) + is a fixed point of the SVD projection, so this just stores R directly.""" + return R + + @staticmethod + def r9_to_rotmat(M: torch.Tensor) -> torch.Tensor: + """R9 (..., 3, 3) -> nearest SO(3) matrix via SVD+.""" + U, _, Vh = torch.linalg.svd(M) + d = torch.det(U @ Vh) + diag = torch.ones(*M.shape[:-2], 3, device=M.device, dtype=M.dtype) + diag[..., 2] = d + return U @ (diag.unsqueeze(-1) * Vh) + + def as_matrix(self) -> torch.Tensor: + return self.r9_to_rotmat(self.M) + + @classmethod + def from_matrix(cls, R: torch.Tensor) -> "SO3ParamR9SVD": + """Initialize a bank close to given rotations R (T, 3, 3).""" + obj = cls(R.shape[0], init="identity") + with torch.no_grad(): + obj.M.copy_(cls.rotmat_to_r9(R)) + return obj \ No newline at end of file diff --git a/src/quantem/core/ml/optimizer_mixin.py b/src/quantem/core/ml/optimizer_mixin.py index e2d2e89a..ece31dff 100644 --- a/src/quantem/core/ml/optimizer_mixin.py +++ b/src/quantem/core/ml/optimizer_mixin.py @@ -1,5 +1,7 @@ +import textwrap from abc import abstractmethod -from typing import TYPE_CHECKING, Generator, Iterator, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal from quantem.core import config @@ -10,6 +12,518 @@ import torch +class OptimizerParams: + """ + Container for optimizer parameter dataclasses. + + Each nested class configures a specific PyTorch optimizer and can be passed + directly to ``OptimizerMixin.set_optimizer``, or constructed from a dict via + ``OptimizerParams.parse_dict``. + + Supported optimizers + -------------------- + Adam + ``torch.optim.Adam`` — adaptive moment estimation. + AdamW + ``torch.optim.AdamW`` — Adam with decoupled weight decay. + SGD + ``torch.optim.SGD`` — stochastic gradient descent with optional momentum and Nesterov. + NoneOptimizer + Sentinel that disables / removes the optimizer. + + Examples + -------- + >>> obj.set_optimizer(OptimizerParams.Adam(lr=1e-4)) + >>> obj.set_optimizer({"name": "adam", "lr": 1e-4}) # equivalent dict form + """ + + @dataclass + class Adam: + """ + Adam optimizer (``torch.optim.Adam``). + + Parameters + ---------- + lr : float + Learning rate. Default: 1e-3. + betas : tuple[float, float] + Coefficients for computing running averages of the gradient and its square. + Default: (0.9, 0.999). + eps : float + Term added to the denominator for numerical stability. Default: 1e-8. + weight_decay : float + L2 regularisation penalty. Default: 0. + """ + + lr: float = 1e-3 + betas: tuple[float, float] = (0.9, 0.999) + eps: float = 1e-8 + weight_decay: float = 0 + _name: str = "adam" + + def params(self) -> dict: + return { + "lr": self.lr, + "betas": self.betas, + "eps": self.eps, + "weight_decay": self.weight_decay, + } + + def __str__(self) -> str: + return textwrap.dedent(f""" + OptimizerParams.Adam( + lr = {self.lr}, + betas = {self.betas}, + eps = {self.eps}, + weight_decay = {self.weight_decay}, + ) + """).strip() + + @dataclass + class AdamW: + """ + AdamW optimizer (``torch.optim.AdamW``). + + Identical to Adam but applies weight decay directly to the parameters + rather than folding it into the gradient update (decoupled weight decay). + + Parameters + ---------- + lr : float + Learning rate. Default: 1e-3. + betas : tuple[float, float] + Coefficients for computing running averages of the gradient and its square. + Default: (0.9, 0.999). + eps : float + Term added to the denominator for numerical stability. Default: 1e-8. + weight_decay : float + Decoupled L2 regularisation penalty. Default: 0. + """ + + lr: float = 1e-3 + betas: tuple[float, float] = (0.9, 0.999) + eps: float = 1e-8 + weight_decay: float = 0 + _name: str = "adamw" + + def params(self) -> dict: + return { + "lr": self.lr, + "betas": self.betas, + "eps": self.eps, + "weight_decay": self.weight_decay, + } + + def __str__(self) -> str: + return textwrap.dedent(f""" + OptimizerParams.AdamW( + lr = {self.lr}, + betas = {self.betas}, + eps = {self.eps}, + weight_decay = {self.weight_decay}, + ) + """).strip() + + @dataclass + class SGD: + """ + SGD optimizer (``torch.optim.SGD``). + + Parameters + ---------- + lr : float + Learning rate. Default: 1e-3. + momentum : float + Momentum factor. Default: 0. + dampening : float + Dampening for momentum. Default: 0. + weight_decay : float + L2 regularisation penalty. Default: 0. + nesterov : bool + Enables Nesterov momentum. Default: False. + """ + + lr: float = 1e-3 + momentum: float = 0 + dampening: float = 0 + weight_decay: float = 0 + nesterov: bool = False + _name: str = "sgd" + + def params(self) -> dict: + return { + "lr": self.lr, + "momentum": self.momentum, + "dampening": self.dampening, + "weight_decay": self.weight_decay, + "nesterov": self.nesterov, + } + + def __str__(self) -> str: + return textwrap.dedent(f""" + OptimizerParams.SGD( + lr = {self.lr}, + momentum = {self.momentum}, + dampening = {self.dampening}, + weight_decay = {self.weight_decay}, + nesterov = {self.nesterov}, + ) + """).strip() + + @dataclass + class NoneOptimizer: + """ + Sentinel optimizer that disables optimization. + + Passing this to ``set_optimizer`` will call ``remove_optimizer``, + clearing both the optimizer and scheduler. + """ + + _name: str = "none" + + def params(self) -> dict: + return {} + + def __str__(self) -> str: + return textwrap.dedent(""" + OptimizerParams.NoneOptimizer() + """).strip() + + @classmethod + def parse_dict(cls, d: dict): + """ + Parse dictionary to a optimizer params object. + Accepts either ``"name"`` or ``"type"`` as the optimizer key. + """ + d = dict(d) # avoid mutating caller's dict + name = d.pop("name", None) + type_ = d.pop("type", None) + name = name or type_ + if name is None: + raise ValueError("Must provide either 'name' or 'type' key") + if isinstance(name, type): + name = name.__name__.lower() + elif isinstance(name, str): + name = name.lower() + else: + raise ValueError(f"Unknown optimizer type: {name}") + if name == "adam": + return OptimizerParams.Adam(**d) + elif name == "adamw": + return OptimizerParams.AdamW(**d) + elif name == "sgd": + return OptimizerParams.SGD(**d) + elif name == "none": + return OptimizerParams.NoneOptimizer() + else: + raise ValueError(f"Unknown optimizer type: {name.lower()}") + + +OptimizerParamsType = ( + OptimizerParams.Adam + | OptimizerParams.AdamW + | OptimizerParams.SGD + | OptimizerParams.NoneOptimizer +) + + +class SchedulerParams: + """ + Container for learning-rate scheduler parameter dataclasses. + + Each nested class configures a specific PyTorch LR scheduler and can be passed + directly to ``OptimizerMixin.set_scheduler``, or constructed from a dict via + ``SchedulerParams.parse_dict``. + + Supported schedulers + -------------------- + Plateau + ``torch.optim.lr_scheduler.ReduceLROnPlateau`` — reduce LR when a metric stops improving. + Exponential + ``torch.optim.lr_scheduler.ExponentialLR`` — multiply LR by ``gamma`` each step. + Cyclic + ``torch.optim.lr_scheduler.CyclicLR`` — cycle LR between ``base_lr`` and ``max_lr``. + Linear + ``torch.optim.lr_scheduler.LinearLR`` — linearly interpolate LR over ``total_iters`` steps. + CosineAnnealing + ``torch.optim.lr_scheduler.CosineAnnealingLR`` — cosine-annealing LR schedule. + NoneScheduler + Sentinel that disables / removes the scheduler. + + Examples + -------- + >>> obj.set_scheduler(SchedulerParams.Plateau(factor=0.5, patience=10, cooldown=20)) + >>> obj.set_scheduler({"name": "plateau", "factor": 0.5}) # equivalent dict form + """ + + @dataclass + class Plateau: + """ + ReduceLROnPlateau scheduler (``torch.optim.lr_scheduler.ReduceLROnPlateau``). + + Reduces the learning rate when a monitored metric stops improving. + + Parameters + ---------- + mode : {'min', 'max'} + Whether the monitored metric should be minimised or maximised. Default: 'min'. + min_lr_factor : float + Sets ``min_lr = min_lr_factor * base_lr`` when ``min_lr`` is not provided. + Default: 1/20. + min_lr : float or None + Absolute lower bound on the learning rate. Overrides ``min_lr_factor`` when set. + Default: None. + factor : float + Factor by which the LR is reduced: ``new_lr = lr * factor``. Default: 0.5. + patience : int + Number of epochs with no improvement before reducing LR. Default: 10. + threshold : float + Minimum change in the monitored metric to qualify as an improvement. Default: 1e-5. + cooldown : int + Number of epochs to wait after a LR reduction before resuming normal operation. + Default: 50. + """ + + mode: Literal["min", "max"] = "min" + min_lr_factor: float = 1 / 20 + min_lr: float | None = None + factor: float = 0.5 + patience: int = 10 + threshold: float = 1e-5 + cooldown: int = 50 + _name: str = "plateau" + + def params(self, base_LR: float, num_iter: int | None = None) -> dict: + if self.min_lr is None: + self.min_lr = self.min_lr_factor * base_LR + return { + "mode": self.mode, + "factor": self.factor, + "patience": self.patience, + "threshold": self.threshold, + "min_lr": self.min_lr, + "cooldown": self.cooldown, + } + + @dataclass + class Exponential: + """ + ExponentialLR scheduler (``torch.optim.lr_scheduler.ExponentialLR``). + + Multiplies the learning rate by ``gamma`` after each step. + + Parameters + ---------- + gamma : float + Multiplicative decay factor applied each step. Default: 0.9. + factor : float or None + Reserved for future use. Default: 0.5. + num_iter : int or None + Reserved for future use. Default: None. + """ + + gamma: float = 0.9 + factor: float | None = None + num_iter: int | None = None + _name: str = "exponential" + + def params(self, base_LR: float, num_iter: int | None = None) -> dict: + effective_num_iter = self.num_iter if self.num_iter is not None else num_iter + if effective_num_iter is None: + raise ValueError("num_iter must be set if num_iter is not provided") + + self.num_iter = effective_num_iter + + if self.factor is not None: + self.gamma = self.factor ** (1.0 / effective_num_iter) + + return { + "gamma": self.gamma, + } + + @dataclass + class Cyclic: + """ + CyclicLR scheduler (``torch.optim.lr_scheduler.CyclicLR``). + + Cycles the learning rate between a lower bound (``base_lr``) and an upper + bound (``max_lr``). Bounds can be set directly or derived from the optimizer's + base LR via the factor parameters. + + Parameters + ---------- + base_lr_factor : float + Sets ``base_lr = base_lr_factor * optimizer_lr`` when ``base_lr`` is not provided. + Default: 1/4. + max_lr_factor : float + Sets ``max_lr = max_lr_factor * optimizer_lr`` when ``max_lr`` is not provided. + Default: 4. + base_lr : float or None + Absolute lower bound of the LR cycle. Overrides ``base_lr_factor`` when set. + Default: None. + max_lr : float or None + Absolute upper bound of the LR cycle. Overrides ``max_lr_factor`` when set. + Default: None. + step_size_up : int + Number of steps in the increasing half of each cycle. Default: 100. + step_size_down : int + Number of steps in the decreasing half of each cycle. Default: 100. + mode : {'triangular2', 'triangular', 'exp_range'} + Cycling policy. Default: 'triangular2'. + cycle_momentum : bool + If True, cycles momentum inversely to the learning rate. Default: False. + """ + + base_lr_factor: float = 1 / 4 + max_lr_factor: float = 4 + base_lr: float | None = None + max_lr: float | None = None + step_size_up: int = 100 + step_size_down: int = 100 + mode: Literal["triangular2", "triangular", "exp_range"] = "triangular2" + cycle_momentum: bool = False + _name: str = "cyclic" + + def params(self, base_LR: float, num_iter: int | None = None) -> dict: + if self.base_lr is None: + self.base_lr = self.base_lr_factor * base_LR + if self.max_lr is None: + self.max_lr = self.max_lr_factor * base_LR + return { + "base_lr": self.base_lr, + "max_lr": self.max_lr, + "step_size_up": self.step_size_up, + "step_size_down": self.step_size_down, + "mode": self.mode, + "cycle_momentum": self.cycle_momentum, + } + + @dataclass + class Linear: + """ + LinearLR scheduler (``torch.optim.lr_scheduler.LinearLR``). + + Linearly interpolates the learning rate from ``start_factor * base_lr`` to + ``end_factor * base_lr`` over ``total_iters`` steps. + + Parameters + ---------- + total_iters : int + Number of steps over which to interpolate the LR. Required. + start_factor : float + Multiplicative factor applied to the LR at the first step. Default: 0.1. + end_factor : float + Multiplicative factor applied to the LR at the final step. Default: 1.0. + """ + + total_iters: int | None = None + start_factor: float = 0.1 + end_factor: float = 1.0 + _name: str = "linear" + + def params(self, base_LR: float, num_iter: int | None = None) -> dict: + if num_iter is None and self.total_iters is None: + raise ValueError( + "total_iters must be set if num_iter is not provided" + ) # Should never be reached + if self.total_iters is None: + self.total_iters = num_iter + return { + "start_factor": self.start_factor, + "end_factor": self.end_factor, + "total_iters": self.total_iters, + } + + @dataclass + class CosineAnnealing: + """ + CosineAnnealingLR scheduler (``torch.optim.lr_scheduler.CosineAnnealingLR``). + + Anneals the learning rate following a cosine curve from the base LR down to + ``eta_min`` over ``T_max`` steps, then restarts. + + Parameters + ---------- + T_max : int + Maximum number of iterations (half-period of the cosine cycle). Required. + eta_min : float + Minimum learning rate at the trough of the cosine curve. Default: 1e-7. + """ + + eta_min: float = 1e-7 + T_max: int | None = None + _name: str = "cosine_annealing" + + def params(self, base_LR: float, num_iter: int | None = None) -> dict: + if num_iter is None and self.T_max is None: + raise ValueError( + "T_max must be set if num_iter is not provided" + ) # Should never be reached + if self.T_max is None: + self.T_max = num_iter + return { + "T_max": self.T_max, + "eta_min": self.eta_min, + } + + @dataclass + class NoneScheduler: + """ + Sentinel scheduler that disables LR scheduling. + + Passing this to ``set_scheduler`` clears the active scheduler without + affecting the optimizer. + """ + + _name: str = "none" + + def params(self, base_LR: float, num_iter: int | None = None) -> dict: + return {} + + @classmethod + def parse_dict(cls, d: dict): + """ + Parse dictionary to a scheduler params object. + Accepts either ``"name"`` or ``"type"`` as the scheduler key. + """ + d = dict(d) # avoid mutating caller's dict + name = d.pop("name", None) + type_ = d.pop("type", None) + name = name or type_ + if name is None: + name = "none" + if isinstance(name, type): + name = name.__name__.lower() + elif isinstance(name, str): + name = name.lower() + else: + raise ValueError(f"Unknown scheduler type: {name}") + if name == "plateau": + return SchedulerParams.Plateau(**d) + elif name == "exponential": + return SchedulerParams.Exponential(**d) + elif name == "cyclic": + return SchedulerParams.Cyclic(**d) + elif name == "linear": + return SchedulerParams.Linear(**d) + elif name == "cosine_annealing": + return SchedulerParams.CosineAnnealing(**d) + elif name == "none": + return SchedulerParams.NoneScheduler() + else: + raise ValueError(f"Unknown scheduler type: {name}") + + +SchedulerParamsType = ( + SchedulerParams.Plateau + | SchedulerParams.Exponential + | SchedulerParams.Cyclic + | SchedulerParams.Linear + | SchedulerParams.CosineAnnealing + | SchedulerParams.NoneScheduler +) + + class OptimizerMixin: """ Mixin class for handling optimizer and scheduler management. @@ -17,13 +531,16 @@ class OptimizerMixin: """ DEFAULT_OPTIMIZER_TYPE = "adamw" + DEFAULT_OPTIMIZER_KEY = "default" def __init__(self): """Initialize the optimizer mixin.""" self._optimizer = None self._scheduler = None - self._optimizer_params = {} - self._scheduler_params = {} + self._optimizer_params: dict[str, OptimizerParamsType] = { + self.DEFAULT_OPTIMIZER_KEY: OptimizerParams.NoneOptimizer() + } + self._scheduler_params: SchedulerParamsType = SchedulerParams.NoneScheduler() # Don't call super().__init__() in mixin classes to avoid MRO issues @property @@ -37,96 +554,141 @@ def scheduler(self) -> "torch.optim.lr_scheduler.LRScheduler | None": return self._scheduler @property - def optimizer_params(self) -> dict: + def optimizer_params(self) -> dict[str, OptimizerParamsType]: """Get the optimizer parameters.""" return self._optimizer_params @optimizer_params.setter - def optimizer_params(self, params: dict): - """Set the optimizer parameters.""" - self._optimizer_params = params.copy() if params else {} + def optimizer_params( + self, params: OptimizerParamsType | dict[str, OptimizerParamsType] | dict[str, Any] + ) -> None: + self._optimizer_params = self._normalize_optimizer_params(params) + + def _normalize_optimizer_params( + self, params: OptimizerParamsType | dict[str, Any] + ) -> dict[str, OptimizerParamsType]: + """Normalize input to dict[str, OptimizerParamsType]. Subclasses can override to validate keys.""" + # Single optimizer, already an OptimizerParamsType + if isinstance(params, OptimizerParamsType): + return {self.DEFAULT_OPTIMIZER_KEY: params} + if not isinstance(params, dict): + raise TypeError(f"optimizer_params must be OptimizerParamsType or dict, got {type(params)}") + # Single optimizer as dict shorthand, e.g. {"name": "adam", "lr": 1e-3} + if self._is_single_optimizer_dict(params): + return {self.DEFAULT_OPTIMIZER_KEY: OptimizerParams.parse_dict(d=params)} + # dict-of-OptimizerParamsType form (PPLR) + return { + k: v if isinstance(v, OptimizerParamsType) else OptimizerParams.parse_dict(d=v) + for k, v in params.items() + } + + @staticmethod + def _is_single_optimizer_dict(d: dict) -> bool: + return "type" in d or "name" in d @property - def scheduler_params(self) -> dict: + def scheduler_params(self) -> SchedulerParamsType: """Get the scheduler parameters.""" return self._scheduler_params @scheduler_params.setter - def scheduler_params(self, params: dict): + def scheduler_params(self, params: SchedulerParamsType | dict): """Set the scheduler parameters.""" - if params: - if params["type"].lower() not in [ - "cyclic", - "plateau", - "exp", - "gamma", - "linear", - "none", - ]: - raise ValueError( - f"Unknown scheduler type: {params['type']}, expected one of ['cyclic', 'plateau', 'exp', 'gamma', 'none']" - ) - self._scheduler_params = params.copy() - else: - self._scheduler_params = {} + if isinstance(params, dict): + params = SchedulerParams.parse_dict(d=params) + if not isinstance(params, SchedulerParamsType): + raise TypeError(f"scheduler parameters must be a SchedulerParamsType, got {type(params)}") + self._scheduler_params = params @abstractmethod def get_optimization_parameters( self, - ) -> "torch.Tensor | Sequence[torch.Tensor] | Iterator[torch.Tensor]": + ) -> "dict[str, list[torch.Tensor]]": """ - Get the parameters that should be optimized for this model. - This could be replaced with just module.parameters(), but this allows for flexibility - in the future to allow for per parameter LRs. + Get the parameters that should be optimized for this model, grouped by name. + + Returns a mapping ``{group_key: [tensors]}``. The group keys MUST match the keys of + ``optimizer_params`` (the common single-group case uses ``DEFAULT_OPTIMIZER_KEY``). + ``set_optimizer`` joins each group to its optimizer spec by key and bakes the per-group + hyperparameters (``spec.params()``) into the torch param group — implementations return + only the tensors, NOT pre-baked hyperparameters. Return ``{}`` when there is nothing + to optimize. """ raise NotImplementedError("Subclasses must implement get_optimization_parameters") - def set_optimizer(self, opt_params: dict | None = None) -> None: + def set_optimizer(self, opt_params: OptimizerParamsType | dict | None = None) -> None: """ - Set the optimizer for this model. - Currently supports single LR for all parameters, TODO allow for per parameter LRs by - updating get_optimization_parameters to return a list of parameters and their LRs. + Set the optimizer for this model, supporting per-parameter-group learning rates (PPLR). + + ``optimizer_params`` is a ``dict[str, OptimizerParamsType]`` keyed by parameter group. Each + group's spec is joined by key to the tensors returned by ``get_optimization_parameters()`` + and its hyperparameters are baked into the corresponding torch param group here. All + groups must use the same optimizer class. If every group is a ``NoneOptimizer`` (or there + are no groups), the optimizer is removed. """ if opt_params is not None: self.optimizer_params = opt_params - if not self._optimizer_params: - self._optimizer = None + # Single canonical "disable" path: drop NoneOptimizer sentinels and, if nothing is left, + # remove the optimizer. Done BEFORE get_optimization_parameters() because some models + # (e.g. the dataset model) raise / return nothing when there is nothing to optimize. + specs = { + key: spec + for key, spec in self.optimizer_params.items() + if not isinstance(spec, OptimizerParams.NoneOptimizer) + } + if not specs: + self.remove_optimizer() return - opt_params = self._optimizer_params.copy() - opt_type = opt_params.pop("type", self.DEFAULT_OPTIMIZER_TYPE) + # All groups must agree on the optimizer class. + spec_list = list(specs.values()) + for spec in spec_list[1:]: + if type(spec) is not type(spec_list[0]): + raise ValueError( + f"All parameter groups must use the same optimizer type, " + f"got {type(spec_list[0]).__name__} and {type(spec).__name__}" + ) - if opt_type == "none": - self.remove_optimizer() - return + # Join specs to param groups by key; bake each group's hyperparameters here. + groups = self.get_optimization_parameters() # dict[str, list[tensor]] + if set(groups) != set(specs): + raise ValueError( + f"optimizer_params keys {set(specs)} do not match parameter group keys " + f"{set(groups)} from {type(self).__name__}.get_optimization_parameters()" + ) - params = self.get_optimization_parameters() - if isinstance(params, torch.Tensor): - params = [params] - elif isinstance(params, Generator): - params = list(params) - - # Ensure parameters require gradients - for p in params: - p.requires_grad_(True) - - if isinstance(opt_type, type): - self._optimizer = opt_type(params, **opt_params) - elif isinstance(opt_type, str): - if opt_type.lower() == "adam": - self._optimizer = torch.optim.Adam(params, **opt_params) - elif opt_type.lower() == "adamw": - self._optimizer = torch.optim.AdamW(params, **opt_params) - elif opt_type.lower() == "sgd": - self._optimizer = torch.optim.SGD(params, **opt_params) - else: - raise NotImplementedError(f"Unknown optimizer type: {opt_type}") - else: - raise TypeError(f"optimizer type must be string or type, got {type(opt_type)}") + param_groups = [] + for key, tensors in groups.items(): + for p in tensors: + p.requires_grad_(True) + param_groups.append({"params": tensors, **specs[key].params()}) + self._optimizer = self._build_optimizer(spec_list[0], param_groups) + + def _build_optimizer(self, opt_params, param_groups) -> "torch.optim.Optimizer": + """Construct the torch optimizer for ``opt_params`` over pre-baked ``param_groups``. + + ``param_groups`` already carry their per-group hyperparameters (see ``set_optimizer``), + so each group's ``lr`` etc. overrides the optimizer-level default. ``NoneOptimizer`` must + have been filtered out by the caller. + """ + match opt_params: + case OptimizerParams.Adam(): + return torch.optim.Adam(param_groups) + case OptimizerParams.AdamW(): + return torch.optim.AdamW(param_groups) + case OptimizerParams.SGD(): + return torch.optim.SGD(param_groups) + case OptimizerParams.NoneOptimizer(): + raise ValueError( + "NoneOptimizer must be filtered out before _build_optimizer; " + "set_optimizer should have short-circuited to remove_optimizer()." + ) + case _: + raise NotImplementedError(f"Unknown optimizer type: {opt_params}") def set_scheduler( - self, scheduler_params: dict | None = None, num_iter: int | None = None + self, scheduler_params: SchedulerParamsType | dict | None = None, num_iter: int | None = None ) -> None: """Set the scheduler for this model.""" if scheduler_params is not None: @@ -136,51 +698,42 @@ def set_scheduler( self._scheduler = None return - params = self._scheduler_params - sched_type = params.get("type", "none").lower() optimizer = self._optimizer - base_LR = optimizer.param_groups[0]["lr"] - - if sched_type == "none": - self._scheduler = None - elif sched_type == "cyclic": - self._scheduler = torch.optim.lr_scheduler.CyclicLR( - optimizer, - base_lr=params.get("base_lr", base_LR / 4), - max_lr=params.get("max_lr", base_LR * 4), - step_size_up=params.get("step_size_up", 100), - step_size_down=params.get("step_size_down", params.get("step_size_up", 100)), - mode=params.get("mode", "triangular2"), - cycle_momentum=params.get("momentum", False), - ) - elif sched_type.startswith(("plat", "reducelronplat")): - self._scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( - optimizer, - mode="min", - factor=params.get("factor", 0.5), - patience=params.get("patience", 10), - threshold=params.get("threshold", 1e-3), - min_lr=params.get("min_lr", base_LR / 20), - cooldown=params.get("cooldown", 50), - ) - elif sched_type in ["exp", "gamma", "exponential"]: - if "gamma" in params: - gamma = params["gamma"] - elif num_iter is not None: - fac = params.get("factor", 0.01) - gamma = fac ** (1.0 / num_iter) - else: - gamma = 0.9 - self._scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=gamma) - elif sched_type == "linear": - self._scheduler = torch.optim.lr_scheduler.LinearLR( - optimizer, - start_factor=params.get("start_factor", 0.1), - end_factor=params.get("end_factor", 1.0), - total_iters=params.get("total_iters", num_iter), - ) - else: - raise ValueError(f"Unknown scheduler type: {sched_type}") + # Schedulers scale every torch param group proportionally off its own initial_lr; this + # scalar only seeds scheduler config (e.g. min_lr, cyclic bounds). Use the max group LR + # as the representative (collapses to group 0 in the single-group case). + base_LR = max(pg["lr"] for pg in optimizer.param_groups) + params = self._scheduler_params.params(base_LR, num_iter=num_iter) + match self.scheduler_params: + case SchedulerParams.NoneScheduler(): + self._scheduler = None + case SchedulerParams.Cyclic(): + self._scheduler = torch.optim.lr_scheduler.CyclicLR( + optimizer, + **params, + ) + case SchedulerParams.Plateau(): + self._scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, + **params, + ) + case SchedulerParams.Exponential(): + self._scheduler = torch.optim.lr_scheduler.ExponentialLR( + optimizer, + **params, + ) + case SchedulerParams.Linear(): + self._scheduler = torch.optim.lr_scheduler.LinearLR( + optimizer, + **params, + ) + case SchedulerParams.CosineAnnealing(): + self._scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, + **params, + ) + case _: + raise ValueError(f"Unknown scheduler type: {self.scheduler_params}") def step_optimizer(self) -> None: """Step the optimizer if it exists.""" @@ -214,9 +767,9 @@ def get_current_lr(self) -> float: def remove_optimizer(self) -> None: """Remove the optimizer and scheduler.""" self._optimizer = None - self._optimizer_params = {} + self._optimizer_params = {self.DEFAULT_OPTIMIZER_KEY: OptimizerParams.NoneOptimizer()} self._scheduler = None - self._scheduler_params = {} + self._scheduler_params = SchedulerParams.NoneScheduler() def reset_optimizer(self) -> None: """Reset the optimizer and scheduler.""" @@ -232,56 +785,44 @@ def reconnect_optimizer_to_parameters(self) -> None: if self._optimizer is None: return - current_params = self.get_optimization_parameters() - if isinstance(current_params, torch.Tensor): - current_params = [current_params] - elif isinstance(current_params, Generator): - current_params = list(current_params) - - optimizable_params = [ - p for p in current_params if isinstance(p, torch.Tensor) and p.is_leaf - ] - - if not optimizable_params: - print( - f"souldn't be getting here! No optimizable parameters found for {self.__class__.__name__}, removing optimizer" - ) + new_groups = self.get_optimization_parameters() + if not new_groups: + print(f"No optimizable parameters for {type(self).__name__}, removing optimizer") self.remove_optimizer() return - for p in optimizable_params: - p.requires_grad_(True) + # Ensure leaf params with grad + for tensors in new_groups.values(): + for p in tensors: + if not p.is_leaf: + raise ValueError("Non-leaf tensor in param group; build groups from leaves") + p.requires_grad_(True) - # Preserve optimizer state and param_group settings - old_state = self._optimizer.state.copy() - current_param_group = self._optimizer.param_groups[0].copy() + old_state = dict(self._optimizer.state) + old_hyperparams = [ + {k: v for k, v in pg.items() if k != "params"} for pg in self._optimizer.param_groups + ] - # Reconnect to new parameters self._optimizer.param_groups.clear() - self._optimizer.add_param_group({"params": optimizable_params}) + for tensors in new_groups.values(): + self._optimizer.add_param_group({"params": tensors}) - # Update state mapping and move tensors to correct device + # Restore per-group hyperparameters by index + for new_pg, old_pg in zip(self._optimizer.param_groups, old_hyperparams): + new_pg.update(old_pg) + + # Remap state for tensors that survived new_state = {} - device = optimizable_params[0].device - for i, old_param in enumerate(old_state.keys()): - if i < len(optimizable_params): - new_param = optimizable_params[i] - new_state[new_param] = {} - for key, value in old_state[old_param].items(): - if isinstance(value, torch.Tensor): - new_state[new_param][key] = value.to(device) - else: - new_state[new_param][key] = value + for new_pg in self._optimizer.param_groups: + for new_param in new_pg["params"]: + if new_param in old_state: + new_state[new_param] = { + k: (v.to(new_param.device) if isinstance(v, torch.Tensor) else v) + for k, v in old_state[new_param].items() + } self._optimizer.state.clear() self._optimizer.state.update(new_state) - # Restore param_group settings (LR, betas, etc.) but keep new parameters - self._optimizer.param_groups[0].update( - {k: v for k, v in current_param_group.items() if k != "params"} - ) - - # Reconnect scheduler - if self._scheduler is not None and self._optimizer is not None: + if self._scheduler is not None: self._scheduler.optimizer = self._optimizer - return diff --git a/src/quantem/core/ml/profiling.py b/src/quantem/core/ml/profiling.py new file mode 100644 index 00000000..aec6e5ed --- /dev/null +++ b/src/quantem/core/ml/profiling.py @@ -0,0 +1,14 @@ +from contextlib import contextmanager + +import torch.cuda.nvtx as nvtx + + +@contextmanager +def nvtx_range(enabled: bool, name: str): + if enabled: + nvtx.range_push(name) + try: + yield + finally: + if enabled: + nvtx.range_pop() diff --git a/src/quantem/core/utils/filter.py b/src/quantem/core/utils/filter.py index 589d84ca..3f9d13b7 100644 --- a/src/quantem/core/utils/filter.py +++ b/src/quantem/core/utils/filter.py @@ -1,5 +1,6 @@ import numpy as np import scipy.ndimage as ndi +import torch from quantem.core.utils.utils import extract_patches @@ -109,3 +110,53 @@ def otsu_threshold(img: np.ndarray, bins: int = 256) -> float: current_max = between_var threshold = bin_edges[i] return threshold + + +# --- Gaussian filters --- + + +def gaussian_kernel_1d(sigma: float, num_sigmas: float = 3.0) -> torch.Tensor: + radius = np.ceil(num_sigmas * sigma) + support = torch.arange(-radius, radius + 1, dtype=torch.float) + kernel = torch.distributions.Normal(loc=0, scale=sigma).log_prob(support).exp_() + # Ensure kernel weights sum to 1, so that image brightness is not altered + return kernel.mul_(1 / kernel.sum()) + + +def gaussian_filter_2d( + img: torch.Tensor, sigma: float, kernel_1d: torch.Tensor +) -> torch.Tensor: # Add kernel_1d as an argument + # kernel_1d = gaussian_kernel_1d(sigma) # Create 1D Gaussian kernel - Moved outside function + padding = len(kernel_1d) // 2 # Ensure that image size does not change + img = img.unsqueeze(0).unsqueeze_(0) # Make copy, make 4D for ``conv2d()`` + # Convolve along columns and rows + img = torch.nn.functional.conv2d(img, weight=kernel_1d.view(1, 1, -1, 1), padding=(padding, 0)) + img = torch.nn.functional.conv2d(img, weight=kernel_1d.view(1, 1, 1, -1), padding=(0, padding)) + return img.squeeze_(0).squeeze_(0) # Make 2D again + + +def gaussian_filter_2d_stack(stack: torch.Tensor, kernel_1d: torch.Tensor) -> torch.Tensor: + """ + Apply 2D Gaussian blur to each slice stack[:, i, :] in a vectorized way. + + Args: + stack (torch.Tensor): Tensor of shape (H, N, W) where N is num_sinograms + kernel_1d (torch.Tensor): 1D Gaussian kernel + + Returns: + torch.Tensor: Blurred stack of same shape (H, N, W) + """ + H, N, W = stack.shape + padding = len(kernel_1d) // 2 + + # Reshape to (N, 1, H, W) for conv2d + stack_reshaped = stack.permute(1, 0, 2).unsqueeze(1) # (N, 1, H, W) + + # Apply separable conv2d: vertical then horizontal + out = torch.nn.functional.conv2d( + stack_reshaped, kernel_1d.view(1, 1, -1, 1), padding=(padding, 0) + ) + out = torch.nn.functional.conv2d(out, kernel_1d.view(1, 1, 1, -1), padding=(0, padding)) + + # Restore shape to (H, N, W) + return out.squeeze(1).permute(1, 0, 2) diff --git a/src/quantem/core/utils/tomography_utils.py b/src/quantem/core/utils/tomography_utils.py new file mode 100644 index 00000000..006f8cfc --- /dev/null +++ b/src/quantem/core/utils/tomography_utils.py @@ -0,0 +1,306 @@ +from typing import Any, Optional, Tuple + +import numpy as np +import torch +import torch.nn.functional as F +from matplotlib import cm # TODO: Temporary +from numpy.typing import NDArray +from scipy.ndimage import center_of_mass, gaussian_filter, shift +from scipy.special import comb +from tqdm.auto import tqdm + +from quantem.core.utils.imaging_utils import cross_correlation_shift +from quantem.core.utils.utils import to_numpy +from quantem.core.visualization import show_2d + +ImageType = NDArray[Any] +BoolArray = NDArray[np.bool_] + + +def _bernstein_basis_1d(n: int, t: NDArray[Any]) -> NDArray[Any]: + k = np.arange(n + 1, dtype=int) + return ( + comb(n, k)[None, :] * (t[:, None] ** k[None, :]) * ((1.0 - t)[:, None] ** (n - k)[None, :]) + ) + + +def _build_basis_matrix(im_shape: Tuple[int, int], order: Tuple[int, int]) -> NDArray[Any]: + H, W = im_shape + ou, ov = int(order[0]), int(order[1]) + u = np.linspace(0.0, 1.0, H) + v = np.linspace(0.0, 1.0, W) + Bu = _bernstein_basis_1d(ou, u) + Bv = _bernstein_basis_1d(ov, v) + basis_cube = np.einsum("ik,jl->ijkl", Bu, Bv) + return basis_cube.reshape(H * W, (ou + 1) * (ov + 1)) + + +def background_subtract( + image: ImageType, + mask: Optional[BoolArray] = None, + thresh_bg: Optional[float] = None, + order: Tuple[int, int] = (1, 1), + sigma: Optional[float] = None, + num_iter: int = 10, + plot_result: bool = True, + axsize: Tuple[int, int] = (3, 3), + cmap: str = "turbo", + return_background_and_mask: bool = False, + **show_kwargs, +) -> ImageType | Tuple[ImageType, NDArray[Any], BoolArray]: + """ + Background subtraction via bivariate Bernstein polynomial fitting. + + Returns + ------- + - If `return_background_and_mask=False`: ImageType (same as input) + - If `True`: (ImageType, numpy.ndarray, numpy.ndarray[bool]) + where background and mask are always NumPy. + """ + im = to_numpy(image).astype(float, copy=True) + if im.ndim != 2: + raise ValueError("`image` must be 2D") + + mask_arr: BoolArray = ( + np.ones_like(im, dtype=bool) if mask is None else np.asarray(mask, dtype=bool) + ) + if mask_arr.shape != im.shape: + raise ValueError("`mask` must match `image` shape") + + order = (int(order[0]), int(order[1])) + A_full = _build_basis_matrix(im.shape, order) + H, W = im.shape + im_flat = im.ravel() + + im_bg = np.zeros_like(im) + thresh_val = np.median(im[mask_arr]) if thresh_bg is None else float(thresh_bg) + + resid = im - im_bg + if sigma and sigma > 0: + resid = gaussian_filter(resid, sigma=sigma, mode="nearest") + mask_bg: BoolArray = (resid < thresh_val) & mask_arr + + for _ in range(int(num_iter)): + idx = mask_bg.ravel() + if not np.any(idx): + idx = mask_arr.ravel() + coefs, *_ = np.linalg.lstsq(A_full[idx, :], im_flat[idx], rcond=None) + im_bg = (A_full @ coefs).reshape(H, W) + + resid = im - im_bg + if sigma and sigma > 0: + resid = gaussian_filter(resid, sigma=sigma, mode="nearest") + + thr = thresh_val if thresh_bg is None else float(thresh_bg) + mask_bg = (resid < thr) & mask_arr + + im_sub_np = im - im_bg + + if plot_result: + vals = im_sub_np[mask_arr] + vals = vals[np.isfinite(vals)] + if vals.size == 0: + vals = np.array([0.0]) + vmin_sub = float(np.min(vals)) + vmax_sub = float(np.max(vals)) + vrange = float(max(abs(vmin_sub), abs(vmax_sub))) or 1e-12 + + bg_disp = (im_bg - np.mean(im_bg)).copy() + bg_disp[~mask_bg] = np.nan + + cmap_base = cm.get_cmap(cmap).with_extremes(bad="black") + cmap_div = "RdBu_r" + + disp = [im - np.mean(im_bg), bg_disp, im_sub_np] + norm = [ + { + "interval_type": "manual", + "stretch_type": "linear", + "vmin": vmin_sub, + "vmax": vmax_sub, + }, + { + "interval_type": "manual", + "stretch_type": "linear", + "vmin": vmin_sub, + "vmax": vmax_sub, + }, + { + "interval_type": "centered", + "stretch_type": "linear", + "vcenter": 0.0, + "half_range": vrange, + }, + ] + + show_2d( + disp, + cmap=[cmap_base, cmap_base, cmap_div], + norm=norm, + cbar=[False, False, True], + title=["Input Image", "Background (fit region)", "Background Subtracted"], + axsize=axsize, + **show_kwargs, + ) + + # # preserve if needed + # if isinstance( + # image, + # ): + # meta = dict(origin=image.origin, sampling=image.sampling, units=image.units) + # name_base = getattr(image, "name", "image") + # im_sub: ImageType = .from_array(im_sub_np, name=f"{name_base} (bg-sub)", **meta) # type: ignore[assignment] + # else: + im_sub = im_sub_np # type: ignore[assignment] + + if return_background_and_mask: + return im_sub, im_bg, mask_bg + return im_sub + + +# --- Tilt Series Processing Utility Functions --- + + +def fourier_binning(img, crop_size): + """ + Crop the img in Fourier space to the specified size. + """ + center = np.array(img.shape) // 2 + + fft_img = np.fft.fftshift(np.fft.fft2(img)) + cropped_fft = fft_img[ + center[0] - crop_size[0] // 2 : center[0] + crop_size[0] // 2, + center[1] - crop_size[1] // 2 : center[1] + crop_size[1] // 2, + ] + cropped_img = np.fft.ifft2(np.fft.ifftshift(cropped_fft)).real + return cropped_img + + +def cross_correlation_align_stack(ref_img, stack, print_pred=False): + """ + Aligns a stack of images to a reference image using cross-correlation. + + This function assumes the stack does not contain the reference image itself. + + Stack shape should be (N, H, W) where N is the number of images. + """ + + new_images = [] + pred_shifts = [] + + prev_img = ref_img + for img in tqdm(stack): + shift_pred = cross_correlation_shift(prev_img, img) + if print_pred: + print(f"Shift prediction: {shift_pred}") + shifted_image = shift(img, shift=shift_pred, mode="constant", cval=0.0) + + pred_shifts.append(shift_pred) + new_images.append(shifted_image) + + prev_img = shifted_image + + return new_images, pred_shifts + + +def centering_com_alignment(image_stack): + """ + Aligns the image stack to the center of mass of the whole image_stack to the + image center. This is useful for aligning the tilt series to the invariant line. + """ + + aligned_stack = np.zeros_like(image_stack) + h, w = image_stack.shape[1:] + image_center = np.array([h // 2, w // 2]) + + com_reference = np.array(center_of_mass(image_stack.mean(axis=0))) + + for i, img in enumerate(image_stack): + com_img = np.array(center_of_mass(img)) + shift_vec = com_reference - com_img + aligned_stack[i] = shift(img, shift=shift_vec, mode="constant", cval=0.0) + + final_shift = image_center - com_reference + for i in range(aligned_stack.shape[0]): + aligned_stack[i] = shift(aligned_stack[i], shift=final_shift, mode="constant", cval=0.0) + + return aligned_stack + + +def differentiable_shift_2d(image, shift_x, shift_y, sampling_rate): + """ + Shifts a 2D image using grid_sample in a differentiable manner with zero-pad boundary conditions applied. + + Args: + image: Tensor of shape [H, W] + shift_x: Scalar tensor (dx) for shift in x-direction (in physical units) + shift_y: Scalar tensor (dy) for shift in y-direction (in physical units) + sampling_rate: Scalar value (physical units per pixel) to correctly normalize shifts + + Returns: + Shifted image of shape [H, W] + """ + H, W = image.shape + + # Convert physical shift to pixel shift + shift_x_pixel = shift_x + shift_y_pixel = shift_y + + # Normalize shift for grid_sample (assuming align_corners=True) + normalized_shift_x = shift_x_pixel * 2 / (W - 1) + normalized_shift_y = shift_y_pixel * 2 / (H - 1) + + # Create normalized grid + grid_y, grid_x = torch.meshgrid( + torch.linspace(-1, 1, H, device=image.device), + torch.linspace(-1, 1, W, device=image.device), + indexing="ij", + ) + + grid = torch.stack((grid_x, grid_y), dim=-1).unsqueeze(0) # [1, H, W, 2] + + # Apply shift (ensure it's differentiable) + grid[:, :, :, 0] -= normalized_shift_x + grid[:, :, :, 1] -= normalized_shift_y + + # Add batch and channel dimensions + image = image.unsqueeze(0).unsqueeze(0) # [1, 1, H, W] + + # Sample using grid_sample (fully differentiable) + shifted_image = F.grid_sample( + image, grid, mode="bicubic", padding_mode="zeros", align_corners=True + ) + + return shifted_image.squeeze(0).squeeze(0) # Back to [H, W] + + +# --- TV loss --- + + +def get_TV_loss(tensor, factor=1e-3): + tv_d = torch.pow(tensor[:, :, 1:, :, :] - tensor[:, :, :-1, :, :], 2).sum() + tv_h = torch.pow(tensor[:, :, :, 1:, :] - tensor[:, :, :, :-1, :], 2).sum() + tv_w = torch.pow(tensor[:, :, :, :, 1:] - tensor[:, :, :, :, :-1], 2).sum() + tv_loss = tv_d + tv_h + tv_w + + return tv_loss * factor / (torch.prod(torch.tensor(tensor.shape))) + + +# Circular mask + + +def torch_phase_cross_correlation(im1, im2): + f1 = torch.fft.fft2(im1) + f2 = torch.fft.fft2(im2) + cc = torch.fft.ifft2(f1 * torch.conj(f2)) + cc_abs = torch.abs(cc) + + max_idx = torch.argmax(cc_abs) + shifts = torch.tensor(np.unravel_index(max_idx.item(), im1.shape), device=im1.device).float() + + for i, dim in enumerate(im1.shape): + if shifts[i] > dim // 2: + shifts[i] -= dim + + # return shifts.flip(0) # (dx, dy) + return shifts diff --git a/src/quantem/core/utils/validators.py b/src/quantem/core/utils/validators.py index 02f8a935..50361e25 100644 --- a/src/quantem/core/utils/validators.py +++ b/src/quantem/core/utils/validators.py @@ -10,8 +10,9 @@ from quantem.core.utils import array_funcs as af if TYPE_CHECKING: - import cupy as cp + import cupy as cp # type: ignore import torch + TensorLike: TypeAlias = ArrayLike | torch.Tensor else: TensorLike: TypeAlias = ArrayLike @@ -20,9 +21,10 @@ if config.get("has_cupy"): import cupy as cp + # --- Dataset Validation Functions --- def ensure_valid_array( - array: "np.ndarray | cp.ndarray", dtype: DTypeLike = None, ndim: int | None = None + array: "np.ndarray | cp.ndarray", dtype: DTypeLike | None = None, ndim: int | None = None ) -> Union[np.ndarray, "cp.ndarray"]: """ Ensure input is a numpy array or cupy array (if available), converting if necessary. @@ -359,6 +361,17 @@ def validate_gt( return value +def validate_lt( + value: float | int, cutoff: float | int, name: str, leq: bool = False +) -> float | int: + if leq: # less than or equal to + if value > cutoff: + raise ValueError(f"{name} must be less than or equal to {cutoff}, got {value}") + elif value >= cutoff: + raise ValueError(f"{name} must be less than {cutoff}, got {value}") + return value + + def validate_int(value: int | float, name: str) -> int: try: return int(round(value)) diff --git a/src/quantem/core/visualization/__init__.py b/src/quantem/core/visualization/__init__.py index dcf7055b..125fb02e 100644 --- a/src/quantem/core/visualization/__init__.py +++ b/src/quantem/core/visualization/__init__.py @@ -2,9 +2,11 @@ CustomNormalization as CustomNormalization, NormalizationConfig as NormalizationConfig, ) +from quantem.core.visualization.show_params import ShowParams as ShowParams from quantem.core.visualization.visualization import show_2d as show_2d from quantem.core.visualization.visualization_utils import ( ScalebarConfig as ScalebarConfig, turbo_black as turbo_black, axes_with_inset as axes_with_inset, ) +from quantem.core.visualization.line_scan import linescan as linescan diff --git a/src/quantem/core/visualization/custom_normalizations.py b/src/quantem/core/visualization/custom_normalizations.py index ac3a015c..554af378 100644 --- a/src/quantem/core/visualization/custom_normalizations.py +++ b/src/quantem/core/visualization/custom_normalizations.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from typing import Literal import numpy as np from matplotlib import colors @@ -461,8 +462,8 @@ class CustomNormalization(colors.Normalize): def __init__( self, - interval_type: str = "quantile", - stretch_type: str = "linear", + interval_type: Literal["quantile", "manual", "centered"] = "quantile", + stretch_type: Literal["linear", "power", "logarithmic", "asinh"] = "linear", *, data: NDArray | None = None, lower_quantile: float = 0.02, @@ -599,8 +600,8 @@ class NormalizationConfig: Linear range for "asinh" stretch type. """ - interval_type: str = "quantile" - stretch_type: str = "linear" + interval_type: Literal["quantile", "manual", "centered"] = "quantile" + stretch_type: Literal["linear", "power", "logarithmic", "asinh"] = "linear" lower_quantile: float = 0.02 upper_quantile: float = 0.98 vmin: float | None = None @@ -673,5 +674,7 @@ def _resolve_normalization(norm, **kwargs) -> NormalizationConfig: return NORMALIZATION_PRESETS[norm]() elif isinstance(norm, NormalizationConfig): return norm + elif hasattr(norm, "to_config"): + return norm.to_config() else: - raise TypeError("norm must be None, dict, str, or NormalizationConfig") + raise TypeError("norm must be None, dict, str, NormalizationConfig, or ShowParams.Norm") diff --git a/src/quantem/core/visualization/line_scan.py b/src/quantem/core/visualization/line_scan.py index 4200902e..b154558b 100644 --- a/src/quantem/core/visualization/line_scan.py +++ b/src/quantem/core/visualization/line_scan.py @@ -9,6 +9,7 @@ # TODO update sampling to allow for 3D and to plot with appropriate units along xy/z +# TODO proper normalization for line going through edges/corner with finite linewidth def linescan( image: np.ndarray, center: tuple[int, int] | None = None, @@ -19,7 +20,7 @@ def linescan( sampling: tuple[float, float] | np.ndarray | None = None, sampling_units: str | None = None, **kwargs, -) -> tuple[np.ndarray, np.ndarray] | tuple[np.ndarray, np.ndarray, tuple[Any, Any]]: +) -> tuple[np.ndarray, np.ndarray]: """ Generate a line scan through an image. @@ -111,7 +112,7 @@ def linescan( sampling_units = "pixels" if show: - fig, axs = _show_linescan( + _fig, _axs = _show_linescan( scan_image, profile, positions, @@ -121,13 +122,8 @@ def linescan( sampling_units, **kwargs, ) - else: - fig, axs = None, None - if kwargs.get("return_fig", False) and show: - return positions, profile, (fig, axs) - else: - return positions, profile + return positions, profile def _calculate_line_endpoints( @@ -239,11 +235,13 @@ def _show_linescan( if profile.ndim == 1: ax0.plot(positions, profile, linewidth=2) + title = "Image" else: # For 3D input, show as image # im = ax0.imshow(profile, aspect="equal", origin="upper") - im = ax0.imshow(profile, aspect="auto", origin="upper") + im = ax0.matshow(profile, aspect="auto", origin="upper") plt.colorbar(im, ax=ax0) + title = "Mean of depth slices" ax0.set_xlabel(f"Position ({sampling_units})") ax0.set_ylabel("Intensity" if profile.ndim == 1 else "Depth (pixels)") @@ -264,7 +262,7 @@ def _show_linescan( ax1.set_xlim(0, image.shape[1] - 1) ax1.set_ylim(image.shape[0] - 1, 0) - ax1.set_title("Image with Line Scan") + ax1.set_title(title) plt.tight_layout() return fig, (ax0, ax1) diff --git a/src/quantem/core/visualization/show_params.py b/src/quantem/core/visualization/show_params.py new file mode 100644 index 00000000..d0399db9 --- /dev/null +++ b/src/quantem/core/visualization/show_params.py @@ -0,0 +1,277 @@ +import warnings +from dataclasses import dataclass, fields +from typing import Literal + +from quantem.core.utils.validators import validate_gt, validate_lt +from quantem.core.visualization.custom_normalizations import NormalizationConfig +from quantem.core.visualization.visualization_utils import ScalebarConfig + + +class ShowParams: + """ + Container for ``show_2d`` parameter dataclasses. + + Nested classes + -------------- + Norm + Normalization configuration (interval + stretch). + Scalebar + Scale bar overlay configuration. + + Examples + -------- + >>> show_2d(img, norm=ShowParams.Norm(power=0.5)) + >>> show_2d(img, scalebar=ShowParams.Scalebar(sampling=0.5, units="Å")) + >>> show_2d(dp, norm=ShowParams.Norm.log_auto(), cbar=True, cmap="turbo") + """ + + @dataclass + class Norm: + """ + Normalization parameters for ``show_2d``. + + Controls how pixel values are mapped to the [0, 1] display range via + an *interval* (which values to keep) and a *stretch* (non-linear + transfer function). + + If ``vmin`` or ``vmax`` is set and ``interval_type`` is left as the + default ``"quantile"``, it is automatically changed to ``"manual"``. + Likewise, setting ``vcenter`` to a non-zero value or providing + ``half_range`` auto-selects ``"centered"``. + + Parameters + ---------- + interval_type : ``"quantile"`` | ``"manual"`` | ``"centered"`` + How to determine the data range. + stretch_type : ``"linear"`` | ``"power"`` | ``"logarithmic"`` | ``"asinh"`` + Transfer function applied after interval mapping. + lower_quantile : float + Lower quantile for ``"quantile"`` interval. Default 0.02. + upper_quantile : float + Upper quantile for ``"quantile"`` interval. Default 0.98. + vmin : float or None + Explicit minimum for ``"manual"`` interval. + vmax : float or None + Explicit maximum for ``"manual"`` interval. + vcenter : float + Centre value for ``"centered"`` interval. Default 0.0. + half_range : float or None + Symmetric half-range for ``"centered"`` interval. + power : float + Exponent for ``"power"`` stretch (e.g. 0.5 = sqrt). Default 1.0. + logarithmic_index : float + Index *a* for ``"logarithmic"`` stretch: ``log(a*x+1)/log(a+1)``. + Default 1000. + asinh_linear_range : float + Transition parameter *a* for ``"asinh"`` stretch. Default 0.1. + + Examples + -------- + >>> ShowParams.Norm() # quantile + linear (default) + >>> ShowParams.Norm(power=0.5) # quantile + sqrt stretch + >>> ShowParams.Norm(vmin=0, vmax=1000) # auto → manual range + >>> ShowParams.Norm.log_auto() # quantile + log stretch + >>> ShowParams.Norm.centered(half_range=5) # centered ± 5, linear + """ + + interval_type: Literal["quantile", "manual", "centered"] | None = None + stretch_type: Literal["linear", "power", "logarithmic", "asinh"] = "linear" + lower_quantile: float = 0.02 + upper_quantile: float = 0.98 + vmin: float | None = None + vmax: float | None = None + vcenter: float = 0.0 + half_range: float | None = None + power: float = 1.0 + logarithmic_index: float = 1000.0 + asinh_linear_range: float = 0.1 + + def __post_init__(self) -> None: + manual_set = self.vmin is not None or self.vmax is not None + centered_set = self.vcenter != 0.0 or self.half_range is not None + quantile_set = self.lower_quantile != 0.02 or self.upper_quantile != 0.98 + user_chose = self.interval_type is not None + + # --- auto-infer interval_type when not explicitly provided --- + if not user_chose: + if manual_set and centered_set: + warnings.warn( + "Both vmin/vmax and vcenter/half_range were set; " + "defaulting to interval_type='manual'.", + stacklevel=2, + ) + self.interval_type = "manual" + elif manual_set: + self.interval_type = "manual" + elif centered_set: + self.interval_type = "centered" + else: + self.interval_type = "quantile" + + # --- warn about ignored interval fields --- + if self.interval_type == "manual" and quantile_set: + warnings.warn( + "lower_quantile/upper_quantile are ignored when interval_type='manual'.", + stacklevel=2, + ) + if self.interval_type == "manual" and centered_set: + warnings.warn( + "vcenter/half_range are ignored when interval_type='manual'.", + stacklevel=2, + ) + if self.interval_type == "quantile" and manual_set: + warnings.warn( + "vmin/vmax are ignored when interval_type='quantile'.", + stacklevel=2, + ) + if self.interval_type == "quantile" and centered_set: + warnings.warn( + "vcenter/half_range are ignored when interval_type='quantile'.", + stacklevel=2, + ) + if self.interval_type == "centered" and manual_set: + warnings.warn( + "vmin/vmax are ignored when interval_type='centered'.", + stacklevel=2, + ) + if self.interval_type == "centered" and quantile_set: + warnings.warn( + "lower_quantile/upper_quantile are ignored when interval_type='centered'.", + stacklevel=2, + ) + + # --- warn about ignored stretch fields --- + if self.power != 1.0 and self.stretch_type not in ("power", "linear"): + warnings.warn( + f"power={self.power} is ignored when stretch_type='{self.stretch_type}'.", + stacklevel=2, + ) + if self.logarithmic_index != 1000.0 and self.stretch_type != "logarithmic": + warnings.warn( + f"logarithmic_index={self.logarithmic_index} is ignored " + f"when stretch_type='{self.stretch_type}'.", + stacklevel=2, + ) + if self.asinh_linear_range != 0.1 and self.stretch_type != "asinh": + warnings.warn( + f"asinh_linear_range={self.asinh_linear_range} is ignored " + f"when stretch_type='{self.stretch_type}'.", + stacklevel=2, + ) + + # --- invalid value checks --- + if self.vmin is not None and self.vmax is not None: + validate_gt(self.vmax, self.vmin, "vmax", geq=False) + validate_gt(self.lower_quantile, 0, "lower_quantile", geq=True) + validate_gt(self.upper_quantile, self.lower_quantile, "upper_quantile") + validate_lt(self.upper_quantile, 1.0, "upper_quantile", leq=True) + if self.upper_quantile > 1.0: + raise ValueError(f"upper_quantile must be <= 1, got {self.upper_quantile}.") + if self.half_range is not None: + validate_gt(self.half_range, 0, "half_range", geq=True) + validate_gt(self.power, 0, "power") + validate_gt(self.logarithmic_index, 0, "logarithmic_index") + validate_gt(self.asinh_linear_range, 0, "asinh_linear_range") + + def to_config(self) -> NormalizationConfig: + """Convert to a ``NormalizationConfig``.""" + return NormalizationConfig(**{f.name: getattr(self, f.name) for f in fields(self)}) + + # ---- presets (mirror NORMALIZATION_PRESETS) ---- + + @classmethod + def linear_auto(cls, **kw) -> "ShowParams.Norm": + """Quantile interval + linear stretch (the default).""" + return cls(**kw) + + @classmethod + def minmax(cls, **kw) -> "ShowParams.Norm": + """Full min/max interval + linear stretch.""" + return cls(interval_type="manual", **kw) + + @classmethod + def centered( + cls, vcenter: float = 0.0, half_range: float | None = None, **kw + ) -> "ShowParams.Norm": + """Centered interval + linear stretch.""" + return cls(interval_type="centered", vcenter=vcenter, half_range=half_range, **kw) + + @classmethod + def log_auto(cls, **kw) -> "ShowParams.Norm": + """Quantile interval + logarithmic stretch.""" + return cls(stretch_type="logarithmic", **kw) + + @classmethod + def log_minmax(cls, **kw) -> "ShowParams.Norm": + """Full min/max interval + logarithmic stretch.""" + return cls(interval_type="manual", stretch_type="logarithmic", **kw) + + @classmethod + def power_sqrt(cls, **kw) -> "ShowParams.Norm": + """Quantile interval + square-root (power=0.5) stretch.""" + return cls(stretch_type="power", power=0.5, **kw) + + @classmethod + def power_squared(cls, **kw) -> "ShowParams.Norm": + """Quantile interval + squared (power=2.0) stretch.""" + return cls(stretch_type="power", power=2.0, **kw) + + @classmethod + def asinh_centered(cls, vcenter: float = 0.0, **kw) -> "ShowParams.Norm": + """Centered interval + asinh stretch.""" + return cls(interval_type="centered", stretch_type="asinh", vcenter=vcenter, **kw) + + @dataclass + class Scalebar: + """ + Scale bar parameters for ``show_2d``. + + Parameters + ---------- + sampling : float + Physical units per pixel. Default 1.0. + units : str + Unit label displayed on the scale bar (e.g. ``"Å"``, ``"nm"``, + ``"1/Å"``). Default ``"pixels"``. + length : float or None + Fixed scale bar length in physical units. ``None`` auto-estimates + a "nice" length. + width_px : float + Thickness of the bar in image pixels. Default 1. + pad_px : float + Padding between bar and plot edge in image pixels. Default 0.5. + color : str + Bar and label colour. Default ``"white"``. + loc : ``"lower right"`` | ``"lower left"`` | ``"upper right"`` | ``"upper left"`` + Anchor location. Default ``"lower right"``. + fontsize : int + Font size of the scale bar label in points. Default 12. + bold : bool + Whether to render the label in bold. Default True. + + Examples + -------- + >>> ShowParams.Scalebar(sampling=0.5, units="Å") + >>> ShowParams.Scalebar(sampling=0.02, units="1/Å", color="black", fontsize=16) + """ + + sampling: float = 1.0 + units: str = "pixels" + length: float | None = None + width_px: float = 1 + pad_px: float = 0.5 + color: str = "white" + loc: Literal["lower right", "lower left", "upper right", "upper left"] = "lower right" + fontsize: int = 12 + bold: bool = False + + def __post_init__(self) -> None: + validate_gt(self.sampling, 0, "sampling") + if self.length is not None: + validate_gt(self.length, 0, "length") + validate_gt(self.width_px, 0, "width_px") + validate_gt(self.fontsize, 0, "fontsize") + + def to_config(self) -> ScalebarConfig: + """Convert to a ``ScalebarConfig``.""" + return ScalebarConfig(**{f.name: getattr(self, f.name) for f in fields(self)}) diff --git a/src/quantem/core/visualization/visualization.py b/src/quantem/core/visualization/visualization.py index f35dab76..e252caa6 100644 --- a/src/quantem/core/visualization/visualization.py +++ b/src/quantem/core/visualization/visualization.py @@ -1,11 +1,14 @@ +# from __future__ import annotations + import os import warnings from collections.abc import Sequence -from typing import Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, TypeAlias, Union, cast import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np +import torch from matplotlib import colors from mpl_toolkits.axes_grid1 import make_axes_locatable from numpy.typing import NDArray @@ -17,6 +20,7 @@ NormalizationConfig, _resolve_normalization, ) +from quantem.core.visualization.show_params import ShowParams from quantem.core.visualization.visualization_utils import ( ScalebarConfig, _resolve_scalebar, @@ -24,29 +28,52 @@ add_cbar_to_ax, add_scalebar_to_ax, array_to_rgba, - list_of_arrays_to_rgba, + combine_arrays_to_rgba, ) +if TYPE_CHECKING: + from quantem.core.datastructures import Dataset2d + +ArrayLike: TypeAlias = Union[NDArray, torch.Tensor, "Dataset2d"] # union required here + +# --- show_2d / grid broadcast inputs --- +# There might be a cleaner way to do this, but better to have it here than in the functions +NormInputCell: TypeAlias = NormalizationConfig | ShowParams.Norm | dict | str +Show2dNormInput: TypeAlias = ( + NormInputCell | None | Sequence[NormInputCell] | Sequence[Sequence[NormInputCell]] +) + +ScalebarInputCell: TypeAlias = ScalebarConfig | ShowParams.Scalebar | dict | bool | None +Show2dScalebarInput: TypeAlias = ( + ScalebarConfig + | ShowParams.Scalebar + | dict + | bool + | None + | Sequence[ScalebarInputCell] + | Sequence[Sequence[ScalebarInputCell]] +) + +CmapType: TypeAlias = str | colors.Colormap + def _show_2d_array( array: NDArray, *, - norm: Optional[Union[NormalizationConfig, dict, str]] = None, - scalebar: Optional[Union[ScalebarConfig, dict, bool]] = None, - cmap: Union[str, colors.Colormap] = "gray", + norm: NormInputCell | None = None, + scalebar: ScalebarInputCell = None, + cmap: str | colors.Colormap = "gray", chroma_boost: float = 1.0, cbar: bool = False, - title: Optional[str] = None, - figax: Optional[tuple[Any, Any]] = None, + title: str | None = None, + figax: tuple[Any, Any] | None = None, figsize: tuple[int, int] = (8, 8), show_ticks: bool = False, **kwargs: Any, ) -> tuple[Any, Any]: - """Display a 2D array as an image with optional colorbar and scalebar. + """Render a single 2D array (real or complex) with optional colorbar/scalebar. - This function visualizes a 2D array, handling both real and complex data. - For complex data, it displays amplitude and phase information using a - perceptually-uniform color representation. + Complex data uses amplitude + phase in a perceptually uniform RGB encoding. Parameters ---------- @@ -122,10 +149,10 @@ def _show_2d_array( ax.imshow(rgba, interpolation=config.get("viz.interpolation")) - if show_ticks: - ax.set(title=title) - else: - ax.set(xticks=[], yticks=[], title=title) + if title is not None: + ax.set_title(title, fontsize=kwargs.get("title_fontsize", 12)) + if not show_ticks: + ax.set(xticks=[], yticks=[]) if cbar: divider = make_axes_locatable(ax) @@ -135,10 +162,10 @@ def _show_2d_array( cb_abs = add_cbar_to_ax(fig, ax_cb_abs, norm_obj, cmap_obj) if is_complex: - ax_cb_angle = divider.append_axes("right", size="5%", pad="10%") + ax_cb_angle = divider.append_axes("right", size="5%", pad="15%") add_arg_cbar_to_ax(fig, ax_cb_angle, chroma_boost=chroma_boost) cb_abs.set_label("abs", rotation=0, ha="center", va="bottom") - cb_abs.ax.yaxis.set_label_coords(0.5, -0.05) + cb_abs.ax.yaxis.set_label_coords(0.5, -0.07) if scalebar_config is not None: add_scalebar_to_ax( @@ -151,6 +178,8 @@ def _show_2d_array( scalebar_config.pad_px, scalebar_config.color, scalebar_config.loc, + scalebar_config.fontsize, + scalebar_config.bold, ) for spine in ax.spines.values(): # fixes asymmetry of bbox for some reason @@ -163,18 +192,17 @@ def _show_2d_array( def _show_2d_combined( list_of_arrays: Sequence[NDArray], *, - norm: Optional[Union[NormalizationConfig, dict, str]] = None, - scalebar: Optional[Union[ScalebarConfig, dict, bool]] = None, - cmap: Union[str, colors.Colormap] = "gray", + norm: NormInputCell | None = None, + scalebar: ScalebarInputCell = None, chroma_boost: float = 1.0, cbar: bool = False, - figax: Optional[tuple[Any, Any]] = None, + figax: tuple[Any, Any] | None = None, figsize: tuple[int, int] = (8, 8), - title: Optional[str] = None, + title: str | None = None, show_ticks: bool = False, **kwargs: Any, ) -> tuple[Any, Any]: - """Display multiple 2D arrays as a single combined image. + """Fuse multiple 2D arrays into one RGB panel (``show_2d(..., combine_images=True)``). This function takes a list of 2D arrays and creates a single visualization where each array is assigned a unique color, and their amplitudes determine @@ -225,7 +253,7 @@ def _show_2d_combined( lower_quantile=norm_config.lower_quantile, upper_quantile=norm_config.upper_quantile, vmin=norm_config.vmin, - vmax=norm_config.vmin, + vmax=norm_config.vmax, vcenter=norm_config.vcenter, half_range=norm_config.half_range, power=norm_config.power, @@ -235,7 +263,7 @@ def _show_2d_combined( # Convert Sequence to list for list_of_arrays_to_rgba list_of_arrays_list = list(list_of_arrays) - rgba = list_of_arrays_to_rgba( + rgba = combine_arrays_to_rgba( list_of_arrays_list, norm=norm_obj, chroma_boost=chroma_boost, @@ -248,10 +276,10 @@ def _show_2d_combined( ax.imshow(rgba, interpolation=config.get("viz.interpolation")) - if show_ticks: - ax.set(title=title) - else: - ax.set(xticks=[], yticks=[], title=title) + if title is not None: + ax.set_title(title, fontsize=kwargs.get("title_fontsize", 12)) + if not show_ticks: + ax.set(xticks=[], yticks=[]) if cbar: raise NotImplementedError() @@ -267,13 +295,15 @@ def _show_2d_combined( scalebar_config.pad_px, scalebar_config.color, scalebar_config.loc, + scalebar_config.fontsize, + scalebar_config.bold, ) return fig, ax def _normalize_show_input_to_grid( - arrays: Any, # Union[NDArray, Sequence[NDArray], Sequence[Sequence[NDArray]]], + arrays: Any, # NDArray | Sequence[NDArray] | Sequence[Sequence[NDArray]] ) -> list[list[NDArray]]: """Convert various input formats to a consistent grid format for visualization. @@ -389,9 +419,9 @@ def _norm_show_args( def _normalize_show_args_to_grid( shape: tuple[int, int], - norm: NormalizationConfig | dict | str | Sequence[dict | str] | None = None, - scalebar: ScalebarConfig | dict | bool | Sequence[bool | dict | None] | None = None, - cmap: str | colors.Colormap | Sequence[str] | Sequence[Sequence[str]] = "gray", + norm: Show2dNormInput = None, + scalebar: Show2dScalebarInput = None, + cmap: CmapType | Sequence[CmapType] | Sequence[Sequence[CmapType]] = "gray", cbar: bool | Sequence[bool] | Sequence[Sequence[bool]] = False, title: str | Sequence[str] | Sequence[Sequence[str]] | None = None, chroma_boost: float | Sequence[float] = 1.0, @@ -425,11 +455,11 @@ def _normalize_show_args_to_grid( def show_2d( - arrays: Union[NDArray, Sequence[NDArray], Sequence[Sequence[NDArray]]], + arrays: ArrayLike | Sequence[ArrayLike] | Sequence[Sequence[ArrayLike]], *, - norm: NormalizationConfig | dict | str | Sequence[dict | str] | None = None, - scalebar: ScalebarConfig | dict | bool | Sequence[bool | dict | None] | None = None, - cmap: str | colors.Colormap | Sequence[str] | Sequence[Sequence[str]] = "gray", + norm: Show2dNormInput = None, + scalebar: Show2dScalebarInput = None, + cmap: CmapType | Sequence[CmapType] | Sequence[Sequence[CmapType]] = "gray", cbar: bool | Sequence[bool] | Sequence[Sequence[bool]] = False, title: str | Sequence[str] | Sequence[Sequence[str]] | None = None, figax: tuple[Any, Any] | None = None, @@ -439,12 +469,8 @@ def show_2d( ) -> tuple[Any, Any]: """Display one or more 2D arrays in a grid layout. - This is the main visualization function that can display a single array, - a list of arrays, or a grid of arrays. It supports both individual and - combined visualization modes. - - The display arguments, i.e. everything except figax and axsize, can be given as single values - or as sequences that will be broadcasted to the grid shape defined by the input arrays. + ``norm``, ``scalebar``, ``cmap``, ``cbar``, and ``title`` may be scalars or nested + sequences broadcast to the panel grid. Parameters ---------- @@ -454,12 +480,15 @@ def show_2d( norm : NormalizationConfig or dict or str, optional Configuration for normalizing the data before visualization. This can be a string, dictionary, or a NormalizationConfig object. Strings for preset normalization types - include: "linear_auto" (quantile), "linear_minmax", "linear_centered", "log_auto", - "log_minmax", "power_squared", "power_sqrt", "asinh_centered" + include: ``"linear_auto"`` (quantile), ``"log_auto"``, ``"power_sqrt"``. + Dict form example: ``{"power": 0.5}``. scalebar : ScalebarConfig or dict or bool, optional Configuration for adding a scale bar to the plot. + Dict form example: ``{"sampling": 0.5, "units": "Å"}`` or + ``{"sampling": 0.02, "units": "1/Å"}``. cmap : str or Colormap, default="gray" Colormap to use for real data or amplitude of complex data. + Common choices: ``"gray"``, ``"viridis"``, ``"turbo"``, ``"inferno"``, ``"magma"``. cbar : bool, default=False Whether to add a colorbar to the plot. title : str, optional @@ -507,6 +536,36 @@ def show_2d( ValueError If combine_images is True but arrays contains multiple rows, or if figax is provided but the axes shape doesn't match the grid shape. + + Examples + -------- + Typed normalization and scale bar (same information as str/dict forms): + + >>> import numpy as np + >>> from quantem.core.visualization import show_2d, ShowParams + >>> img = np.random.rand(128, 128) + >>> fig, ax = show_2d( + ... img, + ... norm=ShowParams.Norm.log_auto(), + ... scalebar=ShowParams.Scalebar(sampling=0.5, units="Å"), + ... cbar=True, + ... ) + + Preset strings and dicts: + + >>> fig, axs = show_2d( + ... [np.random.rand(64, 64) ** 3 for _ in range(3)], + ... norm="log_auto", + ... cmap="turbo", + ... title=["a", "b", "c"], + ... scalebar={"sampling": 0.02, "units": "1/Å"}, + ... ) + + Multi-row grid with titles: + + >>> rows = [[np.random.rand(48, 48) for _ in range(3)] for _ in range(2)] + >>> labels = [["BF", "ADF", "ABF"], ["HAADF", "DPC", "SSB"]] + >>> fig, axs = show_2d(rows, title=labels, cmap="gray", scalebar={"sampling": 0.5, "units": "Å"}) """ arrays = to_cpu(arrays) grid = _normalize_show_input_to_grid(arrays) @@ -516,7 +575,22 @@ def show_2d( if kwargs.pop("combine_images", False): if nrows > 1: raise ValueError() - fig, axs = _show_2d_combined(grid[0], figax=figax, **kwargs) # TODO pass args here + if isinstance(norm, Sequence): # flatten norm + norm = norm[0] if not isinstance(norm[0], Sequence) else norm[0][0] + if isinstance(scalebar, Sequence): # flatten scalebar + scalebar = scalebar[0] if not isinstance(scalebar[0], Sequence) else scalebar[0][0] + if isinstance(title, Sequence) and not isinstance(title, str): # flatten title + title = title[0] if isinstance(title[0], str) else title[0][0] + + fig, axs = _show_2d_combined( + grid[0], + norm=norm, + scalebar=scalebar, + figax=figax, + title=title, + figsize=kwargs.pop("figsize", axsize), + **kwargs, + ) else: normalized_args = _normalize_show_args_to_grid( shape=(nrows, ncols), diff --git a/src/quantem/core/visualization/visualization_utils.py b/src/quantem/core/visualization/visualization_utils.py index afe475b1..126cb096 100644 --- a/src/quantem/core/visualization/visualization_utils.py +++ b/src/quantem/core/visualization/visualization_utils.py @@ -1,7 +1,8 @@ from dataclasses import dataclass -from typing import Any, List, Optional, Sequence, Tuple, Union, cast +from typing import Any, cast import matplotlib as mpl +import matplotlib.pyplot as plt import numpy as np from colorspacious import cspace_convert from matplotlib import cm, colors, legend, ticker @@ -18,9 +19,9 @@ def array_to_rgba( scaled_amplitude: NDArray, - scaled_angle: Optional[NDArray] = None, + scaled_angle: NDArray | None = None, *, - cmap: Union[str, colors.Colormap] = "gray", + cmap: str | colors.Colormap = "gray", chroma_boost: float = 1, ) -> NDArray: """Convert amplitude and angle arrays to an RGBA color array. @@ -72,8 +73,8 @@ def array_to_rgba( return rgba -def list_of_arrays_to_rgba( - list_of_arrays: List[NDArray], +def combine_arrays_to_rgba( + list_of_arrays: list[NDArray], *, norm: CustomNormalization = CustomNormalization(), chroma_boost: float = 1, @@ -139,15 +140,21 @@ class ScalebarConfig: loc : str or int, default="lower right" Location of the scale bar on the plot. Can be a string like "lower right" or an integer location code. + fontsize : int, default=12 + Font size of the scale bar label in points. + bold : bool, default=True + Whether to render the scale bar label in bold. """ sampling: float = 1.0 units: str = "pixels" - length: Optional[float] = None + length: float | None = None width_px: float = 1 pad_px: float = 0.5 color: str = "white" - loc: Union[str, int] = "lower right" + loc: str | int = "lower right" + fontsize: int = 12 + bold: bool = False SCALEBAR_KWARGS = [ @@ -156,7 +163,7 @@ class ScalebarConfig: ] -def _resolve_scalebar(cfg: Any, **kwargs) -> Optional[ScalebarConfig]: +def _resolve_scalebar(cfg: Any, **kwargs) -> ScalebarConfig | None: """Resolve various input types to a ScalebarConfig object. Parameters @@ -190,11 +197,15 @@ def _resolve_scalebar(cfg: Any, **kwargs) -> Optional[ScalebarConfig]: return ScalebarConfig(**cfg) elif isinstance(cfg, ScalebarConfig): return cfg + elif hasattr(cfg, "to_config"): + return cfg.to_config() else: - raise TypeError("scalebar must be None, dict, bool, or ScalebarConfig") + raise TypeError( + "scalebar must be None, dict, bool, ScalebarConfig, or ShowParams.Scalebar" + ) -def estimate_scalebar_length(length: float, sampling: float) -> Tuple[float, float]: +def estimate_scalebar_length(length: float, sampling: float) -> tuple[float, float]: """Estimate an appropriate scale bar length based on data dimensions. This function calculates a "nice" scale bar length that is a multiple of @@ -236,43 +247,106 @@ def estimate_scalebar_length(length: float, sampling: float) -> Tuple[float, flo def _normalize_length_units(length_units: float, units: str) -> tuple[float, str]: """ - pick intelligent units for the scalebar length + Pick intelligent units for the scalebar length. Handles both direct and inverse units. """ - if units in ["A", "Å", "angstrom", "Angstrom"]: - length_A = length_units - elif units in ["nm", "nanometer", "nanometre"]: - length_A = length_units * 10 - elif units in ["um", "μm", "micrometer", "micrometre"]: - length_A = length_units * 1e4 - elif units in ["mm", "millimeter", "millimetre"]: - length_A = length_units * 1e7 - elif units in ["cm", "centimeter", "centimetre"]: - length_A = length_units * 1e8 - else: - return length_units, units - - if length_A < 0.1: - return length_A * 100, "pm" - elif length_A < 10: - return length_A, "Å" - elif length_A < 3e4: - return length_A / 10, "nm" - elif length_A < 1e7: - return length_A / 1e4, "μm" - else: - return length_A / 1e7, "mm" + inverse_unit_map = { + "1/A": "$\\mathrm{Å}^{-1}$", + "1/Å": "$\\mathrm{Å}^{-1}$", + "A^-1": "$\\mathrm{Å}^{-1}$", + "1/angstrom": "$\\mathrm{Å}^{-1}$", + "1/Angstrom": "$\\mathrm{Å}^{-1}$", + "1/nm": "$\\mathrm{nm}^{-1}$", + "1/nanometer": "$\\mathrm{nm}^{-1}$", + "1/nanometre": "$\\mathrm{nm}^{-1}$", + } + direct_unit_map = { + "A": "Å", + "Å": "Å", + "angstrom": "Å", + "Angstrom": "Å", + "nm": "nm", + "nanometer": "nm", + "nanometre": "nm", + "um": "μm", + "μm": "μm", + "micrometer": "μm", + "micrometre": "μm", + "micron": "μm", + "mm": "mm", + "millimeter": "mm", + "millimetre": "mm", + "cm": "cm", + "centimeter": "cm", + "centimetre": "cm", + "m": "m", + "meter": "m", + "metre": "m", + } + + # Handle inverse units first + if units in inverse_unit_map: + # Convert everything to 1/Å then scale + units = inverse_unit_map[units] + if units == "$\\mathrm{Å}^{-1}$": + length_invA = length_units + else: # units == "$\\mathrm{nm}^{-1}$": + length_invA = length_units / 10 + + if length_invA < 0.1: + return length_invA * 10, "$\\mathrm{nm}^{-1}$" + elif length_invA < 100: + return length_invA, "$\\mathrm{Å}^{-1}$" + else: # length_invA >= 10: + return length_invA / 100, "$\\mathrm{pm}^{-1}$" + + # Handle direct metric units (distance) + if units in direct_unit_map: + # Everything to Å + if units in ["A", "Å", "angstrom", "Angstrom"]: + length_A = length_units + elif units in ["nm", "nanometer", "nanometre"]: + length_A = length_units * 10 + elif units in ["um", "μm", "micrometer", "micrometre", "micron"]: + length_A = length_units * 1e4 + elif units in ["mm", "millimeter", "millimetre"]: + length_A = length_units * 1e7 + elif units in ["cm", "centimeter", "centimetre"]: + length_A = length_units * 1e8 + elif units in ["m", "meter", "metre"]: + length_A = length_units * 1e10 + else: + # fallback, should not happen due to keys in direct_unit_map + return length_units, units + + if length_A <= 0.1: + return length_A * 100, "pm" + elif length_A < 10: + return length_A, "Å" + elif length_A < 1e4: + return length_A / 10, "nm" + elif length_A < 1e7: + return length_A / 1e4, "μm" + elif length_A < 1e10: + return length_A / 1e7, "mm" + else: + return length_A / 1e10, "m" + + # fallback: unknown unit, return as is + return length_units, units def add_scalebar_to_ax( ax: Axes, array_size: float, sampling: float, - length_units: Optional[float], + length_units: float | None, units: str, width_px: float, pad_px: float, color: str, - loc: Union[str, int], + loc: str | int, + fontsize: int = 12, + bold: bool = True, ) -> None: """Add a scale bar to a matplotlib axis. @@ -297,7 +371,13 @@ def add_scalebar_to_ax( Color of the scale bar. loc : str or int Location of the scale bar on the plot. + fontsize : int + Font size of the scale bar label in points. + bold : bool + Whether to render the scale bar label in bold. """ + from matplotlib.font_manager import FontProperties + if length_units is None: length_units, length_px = estimate_scalebar_length(array_size, sampling) else: @@ -315,6 +395,9 @@ def add_scalebar_to_ax( loc_strings = {v: k for k, v in loc_codes.items()} loc = loc_strings[loc] + fontprops = FontProperties(size=fontsize, weight="bold" if bold else "normal") + + label_top = loc[:3] == "low" bar = AnchoredSizeBar( ax.transData, length_px, @@ -323,8 +406,10 @@ def add_scalebar_to_ax( pad=pad_px, color=color, frameon=False, - label_top=loc[:3] == "low", - size_vertical=int(width_px), # Convert to int as required by AnchoredSizeBar + label_top=label_top, + size_vertical=int(width_px), + fontproperties=fontprops, + sep=2 if label_top else int(round(0.3 * fontsize)), ) ax.add_artist(bar) @@ -414,7 +499,7 @@ def add_arg_cbar_to_ax( cb_angle = fig.colorbar(sm, cax=cax) cb_angle.set_label("arg", rotation=0, ha="center", va="bottom") - cb_angle.ax.yaxis.set_label_coords(0.5, -0.05) + cb_angle.ax.yaxis.set_label_coords(0.5, -0.07) cb_angle.set_ticks([-np.pi, -np.pi / 2, 0, np.pi / 2, np.pi]) cb_angle.set_ticklabels( [r"$-\pi$", r"$-\dfrac{\pi}{2}$", "$0$", r"$\dfrac{\pi}{2}$", r"$\pi$"] @@ -423,7 +508,7 @@ def add_arg_cbar_to_ax( return cb_angle -def turbo_black(num_colors: int = 256, fade_len: Optional[int] = None) -> colors.ListedColormap: +def turbo_black(num_colors: int = 256, fade_len: int | None = None) -> colors.ListedColormap: """Create a modified version of the 'turbo' colormap that fades to black. This function creates a colormap based on the 'turbo' colormap but with @@ -462,12 +547,12 @@ def turbo_black(num_colors: int = 256, fade_len: Optional[int] = None) -> colors def bilinear_histogram_2d( - shape: Tuple[int, int], + shape: tuple[int, int], x: NDArray, y: NDArray, weight: NDArray, - origin: Tuple[float, float] = (0.0, 0.0), - sampling: Tuple[float, float] = (1.0, 1.0), + origin: tuple[float, float] = (0.0, 0.0), + sampling: tuple[float, float] = (1.0, 1.0), statistic: str = "sum", ) -> NDArray: """Create a 2D histogram with bilinear binning. @@ -503,8 +588,16 @@ def bilinear_histogram_2d( x0, y0 = origin x1, y1 = x0 + Nx * dx, y0 + Ny * dy + x = _as_histogram_vector(x, "x") + y = _as_histogram_vector(y, "y") + weight = _as_histogram_vector(weight, "weight") + if not (x.shape == y.shape == weight.shape): + raise ValueError( + f"x, y, and weight must have matching shapes after coercion, got {x.shape}, {y.shape}, {weight.shape}" + ) + # Convert shape tuple to list for binned_statistic_2d - bins: Sequence[int] = [Nx, Ny] + bins: list[int] = [Nx, Ny] hist, _, _, _ = binned_statistic_2d( x, y, @@ -517,6 +610,15 @@ def bilinear_histogram_2d( return hist # shape = (Nx, Ny), i.e., array[x, y] +def _as_histogram_vector(value: NDArray, name: str) -> NDArray: + array = np.asarray(value) + if array.ndim == 1: + return array + if array.ndim == 2 and 1 in array.shape: + return array.reshape(-1) + raise ValueError(f"{name} must be 1D or shape (N, 1)/(1, N), got {array.shape}") + + def axes_with_inset( axsize=(4, 4), ax_size_embed=None, # None -> 0.25 of main axes in each dimension (fractional) @@ -530,7 +632,7 @@ def axes_with_inset( - Fractional inset by default (relative to main axes size). - Only the inset axes background is set to black (main axes stays default). """ - fig, ax_main = mpl.pyplot.subplots(1, 1, figsize=axsize) + fig, ax_main = plt.subplots(1, 1, figsize=axsize) # lazy import here (some environments need it this way) from mpl_toolkits.axes_grid1.inset_locator import inset_axes diff --git a/src/quantem/diffractive_imaging/dataset_models.py b/src/quantem/diffractive_imaging/dataset_models.py index 2fbfa119..751c9245 100644 --- a/src/quantem/diffractive_imaging/dataset_models.py +++ b/src/quantem/diffractive_imaging/dataset_models.py @@ -1,4 +1,6 @@ +import warnings from abc import abstractmethod +from dataclasses import replace from pathlib import Path from typing import Any, Literal, Self @@ -11,7 +13,7 @@ from quantem.core.datastructures.dataset3d import Dataset3d from quantem.core.datastructures.dataset4dstem import Dataset4dstem from quantem.core.io.serialize import AutoSerialize -from quantem.core.ml.optimizer_mixin import OptimizerMixin +from quantem.core.ml.optimizer_mixin import OptimizerMixin, OptimizerParams from quantem.core.utils.utils import electron_wavelength_angstrom, tqdmnd from quantem.core.utils.validators import ( validate_array, @@ -32,7 +34,7 @@ class PtychographyDatasetBase(AutoSerialize, OptimizerMixin, torch.nn.Module): _token = object() _patch_indices: torch.Tensor - # TODO update optimizers and such to allow for different lrs for different parameters + # TODO make this a PPLR so different lrs can be used for different parameters DEFAULT_LRS = { "descan": 1e-3, "scan_positions": 1e-3, @@ -95,18 +97,47 @@ def __init__( self._constraints = {} self._probe_energy = None - def get_optimization_parameters(self): - """Get the combined descan and scan position parameters for optimization.""" - params = [] + def get_optimization_parameters(self) -> "dict[str, list[torch.Tensor]]": + """Descan and scan-position parameters as separate PPLR groups. + + Returns one group per *learnable* parameter set; ``{}`` when neither is learnable + (``set_optimizer`` then short-circuits to removing the optimizer). + """ + groups: dict[str, list[torch.Tensor]] = {} if self.learn_descan: - params.append(self._descan_shifts) + groups["descan"] = [self._descan_shifts] if self.learn_scan_positions: - params.append(self._scan_positions_px) - if len(params) == 0: - raise RuntimeError( - "No parameters to optimize for dataset: learn_descan and learn_scan_positions are both False" - ) - return params + groups["scan_positions"] = [self._scan_positions_px] + return groups + + def _normalize_optimizer_params(self, params): + """Broadcast a single optimizer spec to the learnable descan/scan_position groups. + + A single ``OptimizerParamsType`` / single-optimizer dict (normalized to the ``"default"`` key) + is fanned out to whichever groups are currently learnable, so the common single-LR caller + keeps working. An explicit PPLR dict (keyed by ``descan``/``scan_positions``) passes through. + """ + norm = super()._normalize_optimizer_params(params) + if set(norm) == {self.DEFAULT_OPTIMIZER_KEY}: + spec = norm[self.DEFAULT_OPTIMIZER_KEY] + learnable = [ + key + for key, on in ( + ("descan", self.learn_descan), + ("scan_positions", self.learn_scan_positions), + ) + if on + ] + if not learnable and not isinstance(spec, OptimizerParams.NoneOptimizer): + warnings.warn( + f"{type(self).__name__}: an optimizer was requested but nothing is " + "learnable (both learn_descan and learn_scan_positions are False); " + "the optimizer will be removed. Enable learn_descan and/or " + "learn_scan_positions to optimize.", + stacklevel=2, + ) + return {key: replace(spec) for key in learnable} if learnable else {} + return norm def to(self, *args, **kwargs): """Move all relevant tensors to a different device.""" @@ -754,7 +785,7 @@ def from_array( name : str | None, optional A descriptive name for the dataset. If None, defaults to "4D-STEM dataset" origin : np.ndarray | tuple | list | float | int | None, optional - The origin coordinates for each dimension. If None, defaults to zeros + The origin coordinates for each dimension in calibrated units. If None, defaults to zeros sampling : np.ndarray | tuple | list | float | int | None, optional The sampling rate/spacing for each dimension. If None, defaults to ones units : list[str] | tuple | list | None, optional @@ -981,7 +1012,7 @@ def preprocess( self.num_gpts, *padded_diffraction_intensities_shape, ), - in_place=True, + modify_in_place=True, ) self.intensities_4d = self.dset.array.reshape( (*self.gpts, *padded_diffraction_intensities_shape) diff --git a/src/quantem/diffractive_imaging/object_models.py b/src/quantem/diffractive_imaging/object_models.py index 0c66629e..d311d8dd 100644 --- a/src/quantem/diffractive_imaging/object_models.py +++ b/src/quantem/diffractive_imaging/object_models.py @@ -13,8 +13,12 @@ from quantem.core import config from quantem.core.io.serialize import AutoSerialize from quantem.core.ml.blocks import reset_weights -from quantem.core.ml.loss_functions import get_loss_function -from quantem.core.ml.optimizer_mixin import OptimizerMixin +from quantem.core.ml.loss_functions import get_loss_module +from quantem.core.ml.optimizer_mixin import ( + OptimizerMixin, + OptimizerParamsType, + SchedulerParamsType, +) from quantem.core.utils.rng import RNGMixin from quantem.core.utils.validators import ( validate_arr_gt, @@ -194,7 +198,7 @@ def obj(self): @property @abstractmethod - def params(self): + def params(self) -> list[nn.Parameter]: raise NotImplementedError() @abstractmethod @@ -232,16 +236,12 @@ def to(self, *args, **kwargs): def name(self) -> str: raise NotImplementedError() - def get_optimization_parameters(self): - """Get the parameters that should be optimized for this model.""" - try: - params = self.params - if params is None: - return [] - return params - except NotImplementedError: - # This happens when params is not implemented yet in abstract base - return [] + def get_optimization_parameters(self) -> "dict[str, list[torch.Tensor]]": + """Get the parameters that should be optimized for this model, keyed by group.""" + params = self.params + if params is None: + return {} + return {self.DEFAULT_OPTIMIZER_KEY: list(params)} def _propagate_array( self, array: "torch.Tensor", propagator_array: "torch.Tensor" @@ -633,9 +633,9 @@ def num_slices(self) -> int: return self._obj.shape[0] @property - def params(self): + def params(self) -> list[nn.Parameter]: """optimization parameters""" - return self._obj + return [self._obj] @property def initial_obj(self): @@ -1025,9 +1025,9 @@ def to(self, *args, **kwargs): return self @property - def params(self): + def params(self) -> list[nn.Parameter]: """optimization parameters""" - return self.model.parameters() + return list(self.model.parameters()) def reset(self): """Reset the object model to its initial or pre-trained state""" @@ -1050,8 +1050,8 @@ def pretrain( pretrain_target: torch.Tensor | None = None, reset: bool = False, num_iters: int = 100, - optimizer_params: dict | None = None, - scheduler_params: dict | None = None, + optimizer_params: dict | OptimizerParamsType | None = None, + scheduler_params: dict | SchedulerParamsType | None = None, loss_fn: Callable | str = "l2", apply_constraints: bool = False, show: bool = True, @@ -1085,7 +1085,7 @@ def pretrain( "No pretrain target set. Provide pretrain_target or set it beforehand." ) - loss_fn = get_loss_function(loss_fn, self.dtype) + loss_fn = get_loss_module(loss_fn, self.dtype) self._pretrain( num_iters=num_iters, loss_fn=loss_fn, diff --git a/src/quantem/diffractive_imaging/probe_models.py b/src/quantem/diffractive_imaging/probe_models.py index 77dbb1c1..af638b85 100644 --- a/src/quantem/diffractive_imaging/probe_models.py +++ b/src/quantem/diffractive_imaging/probe_models.py @@ -14,8 +14,12 @@ from quantem.core.datastructures import Dataset2d, Dataset4dstem from quantem.core.io.serialize import AutoSerialize from quantem.core.ml.blocks import reset_weights -from quantem.core.ml.loss_functions import get_loss_function -from quantem.core.ml.optimizer_mixin import OptimizerMixin +from quantem.core.ml.loss_functions import get_loss_module +from quantem.core.ml.optimizer_mixin import ( + OptimizerMixin, + OptimizerParamsType, + SchedulerParamsType, +) from quantem.core.utils.rng import RNGMixin from quantem.core.utils.utils import electron_wavelength_angstrom, to_numpy from quantem.core.utils.validators import ( @@ -94,16 +98,12 @@ def __init__( if roi_shape is not None: self.roi_shape = roi_shape - def get_optimization_parameters(self): - """Get the parameters that should be optimized for this model.""" - try: - params = self.params - if params is None: - return [] - return params - except NotImplementedError: - # This happens when params is not implemented yet in abstract base - return [] + def get_optimization_parameters(self) -> "dict[str, list[torch.Tensor]]": + """Get the parameters that should be optimized for this model, keyed by group.""" + params = self.params + if params is None: + return {} + return {self.DEFAULT_OPTIMIZER_KEY: list(params)} @property def learn_probe_tilt(self) -> bool: @@ -1359,8 +1359,8 @@ def pretrain( pretrain_target: torch.Tensor | None = None, reset: bool = False, num_iters: int = 100, - optimizer_params: dict | None = None, - scheduler_params: dict | None = None, + optimizer_params: dict | OptimizerParamsType | None = None, + scheduler_params: dict | SchedulerParamsType | None = None, loss_fn: Callable | str = "l2", apply_constraints: bool = False, show: bool = True, @@ -1391,7 +1391,7 @@ def pretrain( elif self.pretrain_target is None: self.pretrain_target = self._initial_probe.clone().detach() - loss_fn = get_loss_function(loss_fn, self.dtype) + loss_fn = get_loss_module(loss_fn, self.dtype) self._pretrain( num_iters=num_iters, loss_fn=loss_fn, diff --git a/src/quantem/diffractive_imaging/ptychography_base.py b/src/quantem/diffractive_imaging/ptychography_base.py index f359dbee..665819d9 100644 --- a/src/quantem/diffractive_imaging/ptychography_base.py +++ b/src/quantem/diffractive_imaging/ptychography_base.py @@ -465,6 +465,8 @@ def get_snapshot_by_iter( "No snapshots available. Use store_snapshots=True during reconstruction." ) iteration = int(iteration) + if iteration < 0: + iteration = self.num_iters + iteration if closest: closest_snapshot = min(self.snapshots, key=lambda s: abs(s["iteration"] - iteration)) snp = closest_snapshot diff --git a/src/quantem/diffractive_imaging/ptychography_lite.py b/src/quantem/diffractive_imaging/ptychography_lite.py index 4470b53a..e3837b83 100644 --- a/src/quantem/diffractive_imaging/ptychography_lite.py +++ b/src/quantem/diffractive_imaging/ptychography_lite.py @@ -180,23 +180,23 @@ def reconstruct( # type:ignore could do overloads but this is simpler... if new_optimizers or reset or self.num_iters == 0: opt_params = { "object": { - "type": "adamw", + "name": "adamw", "lr": lr_obj, }, } scheduler_params = { "object": { - "type": scheduler_type, + "name": scheduler_type, "factor": scheduler_factor, } } if learn_probe: opt_params["probe"] = { - "type": "adamw", + "name": "adamw", "lr": lr_probe, } scheduler_params["probe"] = { - "type": scheduler_type, + "name": scheduler_type, "factor": scheduler_factor, } else: @@ -314,11 +314,11 @@ def from_ptycholite( reset=True, num_iters=pretrain_iters, optimizer_params={ - "type": "adamw", + "name": "adamw", "lr": pretrain_lr, }, scheduler_params={ - "type": "plateau", + "name": "plateau", "factor": 0.5, }, apply_constraints=False, @@ -329,11 +329,11 @@ def from_ptycholite( reset=True, num_iters=pretrain_iters, optimizer_params={ - "type": "adamw", + "name": "adamw", "lr": 1e-3, }, scheduler_params={ - "type": "plateau", + "name": "plateau", "factor": 0.5, }, apply_constraints=False, @@ -373,9 +373,9 @@ def reconstruct( # type:ignore could do overloads but this is simpler... self, num_iters: int = 0, reset: bool = False, - lr_obj: float = 5e-4, + lr_obj: float = 1e-3, learn_probe: bool = True, - lr_probe: float = 5e-4, + lr_probe: float = 1e-3, batch_size: int | None = None, scheduler_type: Literal["exp", "cyclic", "plateau", "none"] = "none", scheduler_factor: float = 0.5, @@ -390,23 +390,23 @@ def reconstruct( # type:ignore could do overloads but this is simpler... if new_optimizers or reset or self.num_iters == 0: opt_params = { "object": { - "type": "adamw", + "name": "adamw", "lr": lr_obj, }, } scheduler_params = { "object": { - "type": scheduler_type, + "name": scheduler_type, "factor": scheduler_factor, } } if learn_probe: opt_params["probe"] = { - "type": "adamw", + "name": "adamw", "lr": lr_probe, } scheduler_params["probe"] = { - "type": scheduler_type, + "name": scheduler_type, "factor": scheduler_factor, } else: diff --git a/src/quantem/diffractive_imaging/ptychography_opt.py b/src/quantem/diffractive_imaging/ptychography_opt.py index f9b5aae4..ff1b3a2c 100644 --- a/src/quantem/diffractive_imaging/ptychography_opt.py +++ b/src/quantem/diffractive_imaging/ptychography_opt.py @@ -1,6 +1,13 @@ +from dataclasses import replace from typing import TYPE_CHECKING from quantem.core import config +from quantem.core.ml.optimizer_mixin import ( + OptimizerParams, + OptimizerParamsType, + SchedulerParams, + SchedulerParamsType, +) from quantem.diffractive_imaging.ptychography_base import PtychographyBase if TYPE_CHECKING: @@ -16,12 +23,10 @@ class PtychographyOpt(PtychographyBase): """ OPTIMIZABLE_VALS = ["object", "probe", "dataset"] - DEFAULT_OPTIMIZER_TYPE = "adam" + DEFAULT_OPTIMIZER_TYPE: OptimizerParamsType = OptimizerParams.Adam() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # self._optimizer_params = {} - # self._scheduler_params = {} def _get_default_lr(self, key: str) -> float: """Get default learning rate for a given optimization key.""" @@ -37,7 +42,7 @@ def _get_default_lr(self, key: str) -> float: # region --- explicit properties and setters --- @property - def optimizer_params(self) -> dict[str, dict]: + def optimizer_params(self) -> dict[str, OptimizerParamsType | dict[str, OptimizerParamsType]]: return { key: params for key, params in [ @@ -45,45 +50,40 @@ def optimizer_params(self) -> dict[str, dict]: ("probe", self.probe_model.optimizer_params), ("dataset", self.dset.optimizer_params), ] - if params + if not isinstance(params, OptimizerParams.NoneOptimizer) } @optimizer_params.setter def optimizer_params(self, d: dict) -> None: """ - Takes a dictionary: - { - "object": { - "type": "adam", - "lr": 0.001, - }, - "probe": { - "type": "adam", - "lr": 0.001, - }, - "dataset": { - "type": "adam", - "lr": 0.001, - }, - ... - } + Takes a dictionary mapping optimizable keys to either an ``OptimizerParamsType`` + dataclass or a plain dict (with optional ``"name"``/``"type"`` and ``"lr"`` + keys). Missing ``"name"`` / ``"lr"`` are filled from ``DEFAULT_OPTIMIZER_TYPE`` + and ``_get_default_lr`` respectively. + + Examples + -------- + >>> ptycho.optimizer_params = {"object": OptimizerParams.Adam(lr=5e-3)} + >>> ptycho.optimizer_params = {"object": {"name": "adam", "lr": 5e-3}} + >>> ptycho.optimizer_params = ["object", "probe"] # use all defaults """ if isinstance(d, (tuple, list)): d = {k: {} for k in d} - ## previously removed unspecified optimizers, but I think its better to keep them - ## and only remove if type is none - # for key in self.OPTIMIZABLE_VALS: - # # if not specified, remove the scheduler for that model - # if key not in d: - # d[key] = {"type": "none"} - for k, v in d.items(): - if "type" not in v.keys(): - v["type"] = self.DEFAULT_OPTIMIZER_TYPE - if "lr" not in v.keys(): - v["lr"] = self._get_default_lr(k) - # self._optimizer_params[k] = v + if isinstance(v, OptimizerParamsType): + pass # already a dataclass, pass through + elif isinstance(v, dict): + if not v: + v = replace(self.DEFAULT_OPTIMIZER_TYPE, lr=self._get_default_lr(k)) + else: + if "name" not in v and "type" not in v: + v["name"] = self.DEFAULT_OPTIMIZER_TYPE._name + if "lr" not in v: + v["lr"] = self._get_default_lr(k) + else: + raise TypeError(f"Expected OptimizerParamsType or dict for key '{k}', got {type(v)}") + if k == "object": self.obj_model.optimizer_params = v elif k == "probe": @@ -131,7 +131,7 @@ def remove_optimizer(self, key: str) -> None: self.dset.remove_optimizer() @property - def scheduler_params(self) -> dict[str, dict]: + def scheduler_params(self) -> dict[str, SchedulerParamsType]: """Returns the parameters used to set the schedulers.""" return { "object": self.obj_model.scheduler_params, @@ -142,22 +142,18 @@ def scheduler_params(self) -> dict[str, dict]: @scheduler_params.setter def scheduler_params(self, d: dict) -> None: """ - Takes a dictionary: - { - "object": { - "type": "cyclic", - "base_lr": 0.001, - }, - "probe": { - ... - }, - ... - } + Takes a dictionary mapping optimizable keys to either a ``SchedulerParamsType`` + dataclass or a plain dict. Keys not present in ``d`` are set to + ``SchedulerParams.NoneScheduler()`` (disables scheduling for that model). + + Examples + -------- + >>> ptycho.scheduler_params = {"object": SchedulerParams.Plateau(factor=0.5)} + >>> ptycho.scheduler_params = {"object": {"name": "plateau", "factor": 0.5}} """ for key in self.OPTIMIZABLE_VALS: - # if not specified, remove the scheduler for that model if key not in d: - d[key] = {} + d[key] = SchedulerParams.NoneScheduler() for k, v in d.items(): if k == "object": self.obj_model.scheduler_params = v @@ -182,11 +178,7 @@ def schedulers(self) -> dict[str, "torch.optim.lr_scheduler._LRScheduler"]: schedulers["dataset"] = self.dset.scheduler return schedulers - def set_schedulers( - self, - params: dict[str, dict], - num_iter: int | None = None, - ): + def set_schedulers(self, params: dict[str, SchedulerParamsType], num_iter: int | None = None): """Set schedulers for each model.""" for key, scheduler_params in params.items(): if key not in self.OPTIMIZABLE_VALS: diff --git a/src/quantem/diffractive_imaging/ptychography_visualizations.py b/src/quantem/diffractive_imaging/ptychography_visualizations.py index 20d5ca09..8cf2fa49 100644 --- a/src/quantem/diffractive_imaging/ptychography_visualizations.py +++ b/src/quantem/diffractive_imaging/ptychography_visualizations.py @@ -1,3 +1,4 @@ +import warnings from typing import Any, Literal import matplotlib.gridspec as gridspec @@ -128,8 +129,6 @@ def show_obj_fft( obj_iter = "Final" if obj is None: if snapshot_iter is not None: - if snapshot_iter < 0: - snapshot_iter = len(self.snapshots) + snapshot_iter snp = self.get_snapshot_by_iter(snapshot_iter, closest=True, cropped=True) obj_np = snp["obj"] obj_iter = snp["iteration"] @@ -171,19 +170,26 @@ def show_obj_fft( obj_show = obj_pad else: # complex or pure phase just show the phase obj_show = np.angle(obj_pad) - show_2d( + fig, ax = show_2d( [ obj_show, np.abs(obj_fft), ], title=[t + "Object", t + "Fourier Transform"], scalebar=[obj_scalebar, fft_scalebar], + return_fig=True, **kwargs, ) + ax[1].set_aspect(obj_np.shape[-1] / obj_np.shape[-2]) else: - show_2d( - np.abs(obj_fft), scalebar=fft_scalebar, title=t + "Fourier Transform", **kwargs + fig, ax = show_2d( + np.abs(obj_fft), + scalebar=fft_scalebar, + title=t + "Fourier Transform", + return_fig=True, + **kwargs, ) + ax.set_aspect(obj_np.shape[-1] / obj_np.shape[-2]) if return_fft: return obj_fft else: @@ -379,8 +385,8 @@ def show_obj_slices( if obj.ndim == 2: obj = obj[None, ...] - t_parts = [f"0/{len(obj)} | 0 Å"] - for i in range(1, len(obj)): + t_parts = [] + for i in range(len(obj)): t_parts.append(f"{i + 1}/{len(obj)} | {self.slice_thicknesses[i - 1]:.1f} Å") if self.obj_type == "potential": @@ -403,6 +409,10 @@ def show_obj_slices( if interval_type == "quantile": norm = {"interval_type": "quantile"} # TODO -- make this work with interval_scaling + if interval_scaling == "all": + warnings.warn( + "interval_scaling='all' is not yet supported for quantile normalization" + ) elif interval_type in ["manual", "minmax", "abs"]: norm: dict[str, Any] = {"interval_type": "manual"} if interval_scaling == "all": @@ -826,50 +836,95 @@ def _show_object_and_probe_iters( **kwargs, ) - def show_scan_positions(self, plot_radii: bool = True): - """ - Show the scan positions and the probe radius. + def show_scan_positions( + self, + plot_radii: bool = True, + num_probes: int | None = None, + axsize: tuple[int, int] = (10, 10), + edgecolors: str = "red", + linewidths: float = 0.5, + **kwargs, + ): + r"""Show scan positions, probe radii, and overlap metadata + + Visualize probe coverage to verify sufficient overlap for + ptychographic reconstruction. The probe radius is estimated as + :math:`r = 0.61 \lambda / \alpha + |\Delta f| \cdot \alpha` + where :math:`\lambda` is the electron wavelength, :math:`\alpha` is + the convergence semi-angle, and :math:`\Delta f` is the defocus. + Overlap is :math:`(d - s)/d = 1 - s/d` where :math:`d = 2r` is + the probe diameter and :math:`s` is the scan step size. + Parameters ---------- - plot_radii: bool, optional - Whether to plot the probe radius, by default True - - Returns - ------- - None + plot_radii : bool, optional + Whether to plot the probe radius. Default is True + num_probes : int | None, optional + Number of probe positions to display. Default is 3 rows + of scan positions + axsize : tuple[int, int], optional + Size of the figure axes. Default is (10, 10) + edgecolors : str, optional + Edge color for the probe circles. Default is "red" + linewidths : float, optional + Line width of the probe circles. Default is 0.5 + **kwargs + Additional keyword arguments passed to ``matplotlib.axes.Axes.scatter`` + + Examples + -------- + >>> ptycho.show_scan_positions() + Scan grid: 192 x 192 positions + FOV: 133.46 x 133.46 Å + Probe diameter (rough estimate): 3.46 Å + Step size: 0.70 Å + Probe overlap (rough estimate): 79.8% + + >>> ptycho.show_scan_positions(num_probes=10, edgecolors="b", linewidths=0.5) """ # for each scan position, sum the intensity of self.probe at that position scan_positions = self.dset.scan_positions_px.cpu().detach().numpy() - probe_params = self.probe_model.probe_params probe_radius_px = None - conv_angle = probe_params.get("semiangle_cutoff") defocus = probe_params.get("defocus", 0) energy = probe_params.get("energy") + scan_gpts = self.dset.gpts + scan_sampling = self.dset.scan_sampling + fov = scan_sampling * (np.array(scan_gpts) - 1) + print(f"Scan grid: {scan_gpts[0]} x {scan_gpts[1]} positions") + print(f"Object FOV: {fov[0]:.2f} x {fov[1]:.2f} Å") if conv_angle is not None and energy is not None: from quantem.core.utils.utils import electron_wavelength_angstrom wavelength = electron_wavelength_angstrom(energy) conv_angle_rad = conv_angle * 1e-3 - # For defocused probe: radius ≈ |defocus| * convergence_angle + diffraction_limit - diffraction_limit_angstrom = 0.61 * wavelength / conv_angle_rad - defocus_blur_angstrom = abs(defocus) * conv_angle_rad - probe_radius_angstrom = diffraction_limit_angstrom + defocus_blur_angstrom - probe_radius_px = probe_radius_angstrom / self.sampling[0] - - _fig, ax = show_2d(self._get_probe_overlap(), title="probe overlap") + diffraction_limit_A = 0.61 * wavelength / conv_angle_rad + defocus_blur_A = abs(defocus) * conv_angle_rad + probe_radius_A = diffraction_limit_A + defocus_blur_A + probe_radius_px = probe_radius_A / self.sampling[0] + probe_diameter_A = 2 * probe_radius_A + probe_step_size_A = scan_sampling[0] + overlap = max(0.0, 1 - probe_step_size_A / probe_diameter_A) + print(f"Probe diameter (rough estimate): {probe_diameter_A:.2f} Å") + print(f"Step size: {probe_step_size_A:.2f} Å") + print(f"Probe overlap (rough estimate): {overlap * 100:.1f}%") + + _fig, ax = show_2d(self._get_probe_overlap(), title="probe overlap", axsize=axsize) if probe_radius_px is not None and plot_radii: # plot a circle with the probe radius for each probe position + if num_probes is None: + num_probes = 3 * scan_gpts[1] # 3 rows worth ax.scatter( - scan_positions[:, 1], - scan_positions[:, 0], + scan_positions[:num_probes, 1], + scan_positions[:num_probes, 0], s=probe_radius_px**2, - edgecolors="red", - c="none", - linestyle="--", + facecolors="none", + edgecolors=edgecolors, + linewidths=linewidths, + **kwargs, ) plt.show() diff --git a/src/quantem/imaging/__init__.py b/src/quantem/imaging/__init__.py index 84b5d876..3637acdc 100644 --- a/src/quantem/imaging/__init__.py +++ b/src/quantem/imaging/__init__.py @@ -1 +1,3 @@ from quantem.imaging.drift import DriftCorrection as DriftCorrection +from quantem.imaging.lattice import Lattice as Lattice +from quantem.imaging.lattice_visualization import PLOT_REGISTRY as PLOT_REGISTRY diff --git a/src/quantem/imaging/drift.py b/src/quantem/imaging/drift.py index 424e18e6..ba29e417 100644 --- a/src/quantem/imaging/drift.py +++ b/src/quantem/imaging/drift.py @@ -1,9 +1,9 @@ +import warnings from collections.abc import Sequence from typing import List, Optional, Union import matplotlib.pyplot as plt import numpy as np -import warnings from numpy.typing import NDArray from scipy.interpolate import interp1d from scipy.ndimage import distance_transform_edt, gaussian_filter diff --git a/src/quantem/imaging/lattice.py b/src/quantem/imaging/lattice.py new file mode 100644 index 00000000..7253cdcd --- /dev/null +++ b/src/quantem/imaging/lattice.py @@ -0,0 +1,446 @@ +import inspect + +import numpy as np +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.io.serialize import AutoSerialize +from quantem.imaging.lattice_visualization import PLOT_REGISTRY + + +class Lattice(AutoSerialize): + """ + Atomic lattice fitting in 2D. + """ + + _token = object() + + def __init__( + self, + image: Dataset2d, + _token: object | None = None, + ): + if _token is not self._token: + raise RuntimeError("Use Lattice.from_data() to instantiate this class.") + self._image: Dataset2d = image + + ### --- Constructors --- + @classmethod + def from_data( + cls, + image: Dataset2d | NDArray, + normalize_min: bool = True, + normalize_max: bool = True, + ) -> "Lattice": + """ + Create a Lattice instance from a 2D image-like input. + + Parameters: + - image: A 2D numpy array or a Dataset2d instance representing the image. + - normalize_min: If True, shift the image so its minimum becomes 0. + - normalize_max: If True, scale the image by its maximum after min-shift + so values are in [0, 1]. If the maximum is 0 or non-finite (NaN/Inf), + scaling is skipped to avoid invalid operations. + + Notes: + - Non-2D inputs and empty arrays raise a ValueError. + - Inputs with boolean dtype are safely converted to float before normalization. + - NaN values are ignored when computing min/max (using nanmin/nanmax). If the + data is all-NaN, normalization is skipped. + """ + if isinstance(image, Dataset2d): + ds2d = image + # Ensure numeric operations are valid (e.g., for bool dtype) + ds2d.array = np.asarray(ds2d.array, dtype=float) + # Validate shape + if ds2d.array.ndim != 2: + raise ValueError("Input image must be a 2D array.") + if ds2d.array.size == 0: + raise ValueError("Input image array must not be empty.") + else: + # Validate dimensionality and emptiness before any processing + arr = np.asarray(image) + if arr.ndim != 2: + raise ValueError("Input image must be a 2D array.") + if arr.size == 0: + raise ValueError("Input image array must not be empty.") + # Convert to float for safe arithmetic (handles bool arrays) + arr = arr.astype(float, copy=False) + if hasattr(Dataset2d, "from_array") and callable(getattr(Dataset2d, "from_array")): + ds2d = Dataset2d.from_array(arr) # type: ignore[attr-defined] + else: + ds2d = Dataset2d(arr) # type: ignore[call-arg] + + # Normalization (robust to constant, NaN, and bool inputs) + if normalize_min: + # Use nanmin to ignore NaNs; if all-NaN, skip + try: + min_val = np.nanmin(ds2d.array) + if np.isfinite(min_val): + ds2d.array = ds2d.array - min_val + except ValueError: + # Raised when all values are NaN; skip + pass + + if normalize_max: + # Use nanmax to ignore NaNs; skip division if max <= 0 or not finite + try: + max_val = np.nanmax(ds2d.array) + if np.isfinite(max_val) and max_val > 0.0: + ds2d.array = ds2d.array / max_val + except ValueError: + # Raised when all values are NaN; skip + pass + + return cls(image=ds2d, _token=cls._token) + + ### --- Properties --- + @property + def image(self) -> Dataset2d: + return self._image + + @image.setter + def image(self, value: Dataset2d | NDArray): + if isinstance(value, Dataset2d): + # Ensure numeric dtype to avoid boolean arithmetic issues downstream + value.array = np.asarray(value.array, dtype=float) + # Validate shape + if value.array.ndim != 2: + raise ValueError("Input image must be a 2D array.") + if value.array.size == 0: + raise ValueError("Input image array must not be empty.") + self._image = value + else: + arr = np.asarray(value) + if arr.ndim != 2: + raise ValueError("Input image must be a 2D array.") + if arr.size == 0: + raise ValueError("Input image array must not be empty.") + arr = arr.astype(float, copy=False) + if hasattr(Dataset2d, "from_array") and callable(getattr(Dataset2d, "from_array")): + self._image = Dataset2d.from_array(arr) # type: ignore[attr-defined] + else: + self._image = Dataset2d(arr) # type: ignore[call-arg] + + ### --- Functions --- + def define_lattice_vectors( + self, + origin, + u, + v, + refine_lattice: bool = True, + block_size: int | None = None, + refine_maxiter: int = 200, + ) -> "Lattice": + """ + Define the lattice for the image using the origin and the u and v vectors starting from the origin. + The lattice is defined as r = r0 + nu + mv. + + Parameters + ---------- + origin : NDArray[2] | Sequence[float] + Start point (r0) to define the lattice. + Enter as (row, col) as a numpy array, list, or tuple. + Ideally a lattice point. + u : NDArray[2] | Sequence[float] + Basis vector u to define the lattice. + Enter as (row, col) as a numpy array, list, or tuple. + v : NDArray[2] | Sequence[float] + Basis vector v to define the lattice. + Enter as (row, col) as a numpy array, list, or tuple. + refine_lattice : bool, default=True + If True, refines the values of r0, u, and v by maximizing the bilinear intensity sum. + block_size : int | None , default=None + Fit the lattice points in steps of block_size * lattice_vectors(u, v). + For example, if block_size = 5, then the lattice points will be fit in steps of + (-5, 5)u * (-5, 5)v -> (-10, 10)u * (-10, 10)v -> ... + block_size = None means the entire image will be fit at once. + refine_maxiter : int, default=200 + Maximum number of iterations for the lattice refinement optimizer (Powell method). + + Returns + ------- + self : Lattice + Returns the same object, modified in-place. + The final values of r0, u, v are stored in self._lat. + + Side Effects + ------------ + Creates self._lat with rows corresponding to r0, u and v + Sets self.default_plot to "lattice_vectors" + """ + # Lattice + self._lat = np.vstack( + ( + np.array(origin), + np.array(u), + np.array(v), + ) + ) + if not self._lat.shape == (3, 2): + raise ValueError("origin, u, v must be in (row, col) format only.") + if not ( + 0 <= origin[0] < self.image.array.shape[0] + and 0 <= origin[1] < self.image.array.shape[1] + ): + raise ValueError("origin must be within the image bounds.") + try: + L = self._lat[1:] + _ = np.linalg.inv(L) + except np.linalg.LinAlgError: + raise ValueError("u, v must be invertible.") + + # Refine lattice coordinates + # Note that we currently assume corners are local maxima + if refine_lattice: + from scipy.optimize import minimize + + if block_size is not None and block_size < 0: + raise ValueError("block_size must be positive or None.") + + H, W = self._image.shape + im = np.asarray(self._image.array, dtype=float) + r0, u, v = (np.asarray(x, dtype=float) for x in self._lat) + + corners = np.array( + [ + [0.0, 0.0], + [float(H), 0.0], + [0.0, float(W)], + [float(H), float(W)], + ], + dtype=float, + ) + + # a,b from corners; A = [u v] in columns (2x2), rhs = (corner - r0) + A = np.column_stack((u, v)) # (2,2) + ab = np.linalg.lstsq(A, (corners - r0[None, :]).T, rcond=None)[0] # (2,4) + + # Getting the min and max values for the indices a, b from the corners + a_min, a_max = int(np.floor(ab[0].min())), int(np.ceil(ab[0].max())) + b_min, b_max = int(np.floor(ab[1].min())), int(np.ceil(ab[1].max())) + + max_ind = max(abs(a_min), a_max, abs(b_min), b_max) + if not block_size: + steps = [max_ind] + else: + steps = ( + [*np.arange(0, max_ind + 1, block_size)[1:], max_ind] + if max_ind > 0 + else [max_ind] + ) + + PENALTY = 1e10 + H_CLIP = H - 2 + W_CLIP = W - 2 + a_range = np.arange(max(a_min, -max_ind), min(a_max, max_ind) + 1, dtype=np.int32) + b_range = np.arange(max(b_min, -max_ind), min(b_max, max_ind) + 1, dtype=np.int32) + aa, bb = np.meshgrid(a_range, b_range, indexing="ij") + + # Pre-compute all masks and bases + all_masks = {} + all_bases = {} + for curr_block_size in steps: + a_min_blk = max(a_min, -curr_block_size) + a_max_blk = min(a_max, curr_block_size) + b_min_blk = max(b_min, -curr_block_size) + b_max_blk = min(b_max, curr_block_size) + mask = ( + (aa >= a_min_blk) & (aa <= a_max_blk) & (bb >= b_min_blk) & (bb <= b_max_blk) + ) + + aa_masked = aa[mask] + bb_masked = bb[mask] + + all_masks[curr_block_size] = mask + all_bases[curr_block_size] = np.column_stack( + [np.ones(aa_masked.size), aa_masked.ravel(), bb_masked.ravel()] + ) + + # Pre-allocate cache + max_points = max(basis.shape[0] for basis in all_bases.values()) + x0_cache = np.empty(max_points, dtype=np.int32) + y0_cache = np.empty(max_points, dtype=np.int32) + dx_cache = np.empty(max_points, dtype=np.float64) + dy_cache = np.empty(max_points, dtype=np.float64) + + def bilinear_sum(im_: np.ndarray, xy: np.ndarray) -> float: + """Sum of bilinearly interpolated intensities at (x,y) points.""" + + n_points = xy.shape[0] + if n_points == 0: + return 0.0 + + x, y = xy[:, 0], xy[:, 1] + + # Filter points that are within valid bounds for bilinear interpolation + # Need x in [0, H-2] and y in [0, W-2] so that x+1 and y+1 are valid + valid_mask = ( + (x >= 0) + & (x <= H_CLIP) + & (y >= 0) + & (y <= W_CLIP) + & np.isfinite(x) + & np.isfinite(y) + ) + + n_valid = np.sum(valid_mask) + if n_valid == 0: + return -PENALTY + + x_valid = x[valid_mask] + y_valid = y[valid_mask] + + # Use pre-allocated arrays + x0, y0 = x0_cache[:n_valid], y0_cache[:n_valid] + dx, dy = dx_cache[:n_valid], dy_cache[:n_valid] + + np.floor(x_valid, out=dx) + x0[:] = dx.astype(np.int32) + np.floor(y_valid, out=dy) + y0[:] = dy.astype(np.int32) + + np.subtract(x_valid, x0, out=dx) + np.subtract(y_valid, y0, out=dy) + + Ia = im_[x0, y0] + Ib = im_[x0 + 1, y0] + Ic = im_[x0, y0 + 1] + Id = im_[x0 + 1, y0 + 1] + + return np.sum( + Ia * (1 - dx) * (1 - dy) + + Ib * dx * (1 - dy) + + Ic * (1 - dx) * dy + + Id * dx * dy + ) + + current_basis = None + + def objective(theta: np.ndarray) -> float: + """Function to be minimized""" + # theta is 6-vector -> (3,2) matrix [[r0],[u],[v]] + lat = theta.reshape(3, 2) + xy = current_basis @ lat # (N,2) with columns (x,y) + # Negative: maximize intensity sum by minimizing its negative + return -bilinear_sum(im, xy) + + minimize_options = { + "maxiter": int(refine_maxiter), + "xtol": 1e-3, + "ftol": 1e-3, + "disp": False, + } + + lat_flat = self._lat.astype(np.float32).reshape(-1) + + for curr_block_size in steps: + current_basis = all_bases[curr_block_size] + + res = minimize( + objective, + lat_flat, + method="Powell", + options=minimize_options, + ) + + # Update for next iteration + lat_flat = res.x + self._lat = res.x.reshape(3, 2) + + self.default_plot = "lattice_vectors" + + return self + + ### --- Plot dispatcher --- + def plot(self, kind: str | None = None, show_docstring: bool = False, **kwargs): + """ + Dispatch to a registered visualization function. + + The function is selected by ``kind`` if given, otherwise by + ``self.default_plot``. Passing ``kind`` here does not mutate the instance. + + Parameters + ---------- + kind : str | None + Name of the plot. Registered names: + + "image" | "dataset" + Shows the image using the default show_2d with no overlays. + Default if default_plot is not set. + + "lattice_vectors" + Lattice vectors + grid lines overlaid on the image. + Call after define_lattice_vectors(). + + show_docstring : bool, default False + If True, return formatted signature and docstring of the plotting + function instead of calling it. If False, call the function and + return its result. + + **kwargs + Forwarded verbatim to the selected plotting function. + + Returns + ------- + str or function return + If ``show_docstring`` is True, returns a formatted string with the + function signature and docstring. Otherwise, returns whatever the + selected function returns (typically ``(fig, ax)``). + + Raises + ------ + ValueError + If ``kind`` or ``self.default_plot`` is not in PLOT_REGISTRY. + + Examples + -------- + :: + + lat.plot(kind="lattice_vectors") + lat.plot(kind="lattice_vectors", show_docstring=True) + """ + if not hasattr(self, "default_plot") or kind in ["image", "dataset"]: + from quantem.core.visualization import show_2d + + return show_2d(self.image, **kwargs) # type:ignore + + plot_name = kind if kind is not None else self.default_plot + if plot_name not in PLOT_REGISTRY: + raise ValueError( + f"Unknown plot kind {plot_name!r}. Available: {sorted(PLOT_REGISTRY)}" + ) + + plot_func = PLOT_REGISTRY[plot_name] + if show_docstring: + plot_func = PLOT_REGISTRY[plot_name] + sig = inspect.signature(plot_func) + doc = inspect.getdoc(plot_func) + + if kind is None: + print(f"Current default plot: {self.default_plot}") + + print("\nSignature:") + # Format signature across multiple lines + params = [] + for param_name, param in sig.parameters.items(): + params.append(f" {param}") + + param_str = ",\n".join(params) + return_annotation = ( + f" -> {sig.return_annotation}" + if sig.return_annotation != inspect.Signature.empty + else "" + ) + + print(f"def {plot_func.__name__}(\n{param_str}\n){return_annotation}:") + + print("\nDocstring:") + if doc: + print(doc) + else: + print("[No docstring]") + + return None + + return plot_func(self, **kwargs) diff --git a/src/quantem/imaging/lattice_visualization.py b/src/quantem/imaging/lattice_visualization.py new file mode 100644 index 00000000..a7d31363 --- /dev/null +++ b/src/quantem/imaging/lattice_visualization.py @@ -0,0 +1,151 @@ +""" +lattice_visualization.py +-------------------------------- +All visualization helpers for the Lattice class. Nothing here modifies +Lattice state — functions only read from the instance they receive. + +Registered plot names (callable via ``lattice.plot(kind=...)``) +--------------------------------------------------------------- + "lattice_vectors" - image + lattice vectors/grid (after define_lattice_vectors) +""" + +import numpy as np + +from quantem.core.visualization import show_2d + +# --------------------------------------------------------------------------- +# Registry used by Lattice.plot() +# --------------------------------------------------------------------------- +PLOT_REGISTRY: dict[str, callable] = {} # type:ignore + + +def _register(name: str): + def decorator(fn): + PLOT_REGISTRY[name] = fn + return fn + + return decorator + + +### --- Plotting Functions --- +@_register("lattice_vectors") +def plot_lattice_vectors( + lattice, *, returnfig: bool = False, bound_num_vectors: int | None = None, **kwargs +) -> None | tuple: + """ + Overlay fitted lattice vectors and grid lines on the image. + Call after define_lattice_vectors(). + + Parameters + ---------- + bound_num_vectors : int | None + Number of lattice vectors to draw in each direction. + None clips lines to image edges. + **kwargs forwarded to show_2d (e.g. cmap, title). + """ + # Check if lattice vectors have been defined + if not hasattr(lattice, "_lat"): + raise ValueError( + "Must define lattice vectors first. Call `Lattice.define_lattice_vectors()`" + ) + # Adding a defualt figsize + if "figsize" not in kwargs: + kwargs["figsize"] = (10, 10) + fig, ax = show_2d(lattice._image.array, returnfig=True, **kwargs) + if ax.images: + ax.images[-1].set_zorder(0) + H, W = lattice._image.shape + r0, u, v = (np.asarray(x, dtype=float) for x in lattice._lat) + + ax.scatter( + r0[1], r0[0], s=60, edgecolor=(0, 0, 0), facecolor=(0, 0.5, 0), marker="s", zorder=5 + ) + + n_vec = int(np.ceil(bound_num_vectors)) if bound_num_vectors is not None else 1 + for k in range(1, n_vec + 1): + tip = r0 + k * u + ax.arrow( + r0[1], + r0[0], + (tip - r0)[1], + (tip - r0)[0], + length_includes_head=True, + head_width=4.0, + head_length=6.0, + linewidth=2.0, + color="red", + zorder=4, + ) + for k in range(1, n_vec + 1): + tip = r0 + k * v + ax.arrow( + r0[1], + r0[0], + (tip - r0)[1], + (tip - r0)[0], + length_includes_head=True, + head_width=4.0, + head_length=6.0, + linewidth=2.0, + color=(0.0, 0.7, 1.0), + zorder=4, + ) + + if bound_num_vectors is None: + corners = np.array([[0.0, 0.0], [float(H), 0.0], [0.0, float(W)], [float(H), float(W)]]) + x_lo, x_hi, y_lo, y_hi = 0.0, float(H), 0.0, float(W) + else: + n = float(bound_num_vectors) + corners = np.array([r0 - n * u, r0 - n * v, r0 + n * u, r0 + n * v], dtype=float) + x_lo = float(np.min(corners[:, 0])) + x_hi = float(np.max(corners[:, 0])) + y_lo = float(np.min(corners[:, 1])) + y_hi = float(np.max(corners[:, 1])) + + A = np.column_stack((u, v)) + ab = np.linalg.lstsq(A, (corners - r0[None, :]).T, rcond=None)[0] + a_min, a_max = int(np.floor(np.min(ab[0]))), int(np.ceil(np.max(ab[0]))) + b_min, b_max = int(np.floor(np.min(ab[1]))), int(np.ceil(np.max(ab[1]))) + + def clipped_segment(base, direction): + x0, y0 = base + dx, dy = direction + t0, t1 = -np.inf, np.inf + eps = 1e-12 + if abs(dx) < eps: + if not (x_lo <= x0 <= x_hi): + return None + else: + ts = sorted([(x_lo - x0) / dx, (x_hi - x0) / dx]) + t0, t1 = max(t0, ts[0]), min(t1, ts[1]) + if abs(dy) < eps: + if not (y_lo <= y0 <= y_hi): + return None + else: + ts = sorted([(y_lo - y0) / dy, (y_hi - y0) / dy]) + t0, t1 = max(t0, ts[0]), min(t1, ts[1]) + if t0 > t1: + return None + return base + t0 * direction, base + t1 * direction + + for a in range(a_min, a_max + 1): + seg = clipped_segment(r0 + a * u, v) + if seg: + ax.plot( + [seg[0][1], seg[1][1]], + [seg[0][0], seg[1][0]], + color=(0.0, 0.7, 1.0), + lw=1, + zorder=10, + ) + for b in range(b_min, b_max + 1): + seg = clipped_segment(r0 + b * v, u) + if seg: + ax.plot([seg[0][1], seg[1][1]], [seg[0][0], seg[1][0]], color="red", lw=1, zorder=10) + + ax.set_xlim(y_lo, y_hi) + ax.set_ylim(x_hi, x_lo) + if returnfig: + return fig, ax + else: + return None diff --git a/src/quantem/tomography/dataset_models.py b/src/quantem/tomography/dataset_models.py new file mode 100644 index 00000000..a1a51bad --- /dev/null +++ b/src/quantem/tomography/dataset_models.py @@ -0,0 +1,728 @@ +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any + +import torch +import torch.nn as nn +from numpy.typing import NDArray +from torch.utils.data import Dataset + +from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.io.serialize import AutoSerialize +from quantem.core.ml.constraints import BaseConstraints, Constraints +from quantem.core.ml.optimizer_mixin import OptimizerMixin +from quantem.tomography.utils import tv_loss_1d + +# --- Constraints --- + + +class DatasetConstraintParams: + """ + Namespace class for dataset constraint parameter dataclasses and parsing utilities. + + Contains constraint definitions for different tomography dataset types and a + factory method for instantiating the appropriate constraint class from a dict. + + Supported constraint types + -------------------------- + BaseTomographyDatasetConstraints + Base soft constraints for z-position and lateral shift regularization. + ThroughFocalDatasetConstraints + Inherits base constraints; not yet implemented. + + Examples + -------- + >>> DatasetConstraintParams.parse_dict({"name": "base_tomography_dataset", "tv_zs": 0.1}) + BaseTomographyDatasetConstraints(tv_zs=0.1, tv_shifts=0.0) + >>> DatasetConstraintParams.parse_dict({"type": "base_tomography_dataset"}) + BaseTomographyDatasetConstraints(tv_zs=0.0, tv_shifts=0.0) + """ + + @dataclass + class BaseTomographyDatasetConstraints(Constraints): + """ + Soft constraints for a base tomography dataset. + + Attributes + ---------- + tv_zs : float + Total variation regularization weight for Z1 and Z3 Euler angles. + tv_shifts : float + Total variation regularization weight for X and Y shifts. + soft_constraint_keys : list[str] + Constraint fields penalized softly during optimization. + hard_constraint_keys : list[str] + Constraint fields enforced strictly (none for this class). + """ + + tv_zs: float = 0.0 + tv_shifts: float = 0.0 + _name: str = "base_tomography_dataset" + + soft_constraint_keys = ["tv_zs", "tv_shifts"] + hard_constraint_keys = [] + + @dataclass + class ThroughFocalDatasetConstraints(BaseTomographyDatasetConstraints): + """ + Constraints for a through-focal tomography dataset. + + Inherits all constraints from ``BaseTomographyDatasetConstraints``. + Currently not implemented — instantiation will raise ``NotImplementedError``. + """ + + pass + + @classmethod + def parse_dict( + cls, d: dict + ) -> "DatasetConstraintParams.BaseTomographyDatasetConstraints | DatasetConstraintParams.ThroughFocalDatasetConstraints": + """ + Instantiate a dataset constraint dataclass from a configuration dictionary. + + The dictionary must contain a ``'name'`` or ``'type'`` key identifying + which constraint class to construct. All remaining keys are forwarded as + keyword arguments to the selected dataclass. + + Parameters + ---------- + d : dict + Configuration dictionary. Must include ``'name'`` or ``'type'`` + with one of the following values (case-insensitive): + + - ``'base_tomography_dataset'`` → :class:`BaseTomographyDatasetConstraints` + - ``'through_focal_dataset'`` → :class:`ThroughFocalDatasetConstraints` + *(not yet implemented)* + + The value may also be a class ``type`` object, in which case its + ``__name__`` is used after lower-casing. + + Returns + ------- + BaseTomographyDatasetConstraints or ThroughFocalDatasetConstraints + An instance of the appropriate constraint dataclass. + + Raises + ------ + ValueError + If neither ``'name'`` nor ``'type'`` is present, if the value is not + a string or type, or if the name does not match any known dataset + constraint type. + NotImplementedError + If ``'through_focal_dataset'`` is requested, as it is not yet implemented. + """ + d = dict(d) + name = d.pop("name", None) + type_ = d.pop("type", None) + name = name or type_ + if name is None: + raise ValueError("Must provide either 'name' or 'type' key") + if isinstance(name, type): + name = name.__name__.lower() + elif isinstance(name, str): + name = name.lower() + else: + raise ValueError(f"Unknown dataset constraint type: {name}") + if name == "base_tomography_dataset": + return DatasetConstraintParams.BaseTomographyDatasetConstraints(**d) + elif name == "through_focal_dataset": + raise NotImplementedError("Through focal dataset constraints are not implemented yet.") + else: + raise ValueError(f"Unknown dataset constraint type: {name.lower()}") + + +DatasetConstraintsType = ( + DatasetConstraintParams.BaseTomographyDatasetConstraints + | DatasetConstraintParams.ThroughFocalDatasetConstraints +) + + +@dataclass +class DatasetValue: + """ + Class for storing the forward call for both PixDataset and INRDataset. + """ + + target: torch.Tensor + tilt_angle: int | float + pixel_loc: tuple[int, int] | None = None # Only for INRDataset + projection_idx: int | None = None # Only for INRDataset + pose: tuple[torch.nn.Parameter, torch.nn.Parameter, torch.nn.Parameter] | None = ( + None # If there is pose optimization. # Pose is tuple (shifts, z1, z3) + ) + + +class TomographyDatasetBase(AutoSerialize, OptimizerMixin, nn.Module): + """ + Base tomography dataset class for all tomography datasets to inherit from. + """ + + _token = object() + + DEFAULT_LRS = { + "pose_lr": 5e-2, + } + + def __init__( + self, + tilt_stack: Dataset3d | NDArray | torch.Tensor, + tilt_angles: NDArray | torch.Tensor, + learn_shift: bool = True, + learn_tilt_axis: bool = True, + norm_quantile: bool = True, + _token: object | None = None, + ): + AutoSerialize.__init__(self) + OptimizerMixin.__init__(self) + nn.Module.__init__(self) + if _token is not self._token: + raise RuntimeError("Use TomographyPixDataset.from_* to instantiate this class.") + + if not ( + tilt_stack.shape[0] == tilt_angles.shape[0] + ): + raise ValueError( + "The number of tilt projections should be in the first dimension of the dataset." + ) + + if type(tilt_stack) is not torch.Tensor: + tilt_stack = torch.from_numpy(tilt_stack) + if type(tilt_angles) is not torch.Tensor: + tilt_angles = torch.from_numpy(tilt_angles) + if norm_quantile: + max_val = torch.quantile(tilt_stack, 0.95) + else: + max_val = torch.max(tilt_stack) + + # Tilt stack normalization + tilt_stack = tilt_stack / max_val + + self.tilt_stack = tilt_stack + self.tilt_angles = tilt_angles + self.learn_shift = learn_shift + self.learn_tilt_axis = learn_tilt_axis + + # The reference tilt angle is the one with the smallest absolute tilt angle. + # I.e, the pose will not be optimized for the reference tilt angle. + self._reference_tilt_angle_idx = torch.argmin(torch.abs(self.tilt_angles)) + # TODO: Implement AuxParams from old tomography_dataset.py here. + + # TODO: The parameters won't be initialized unless .to(device) is called. + self._z1_angles = torch.zeros(self.learnable_tilts) + self._z3_angles = torch.zeros(self.learnable_tilts) + self._shifts = torch.zeros(self.learnable_tilts, 2) + + # Fixed zeros for reference tilt + self._z1_ref = torch.zeros(1) + self._z3_ref = torch.zeros(1) + self._shifts_ref = torch.zeros(1, 2) + + # --- Class methods --- + @classmethod + def from_data( + cls, + tilt_stack: Dataset3d | NDArray | torch.Tensor, + tilt_angles: NDArray | torch.Tensor, + learn_shift: bool = True, + learn_tilt_axis: bool = True, + norm_quantile: bool = True, + ): + return cls( + tilt_stack=tilt_stack, + tilt_angles=tilt_angles, + learn_shift=learn_shift, + learn_tilt_axis=learn_tilt_axis, + norm_quantile=norm_quantile, + _token=cls._token, + ) + + # --- Optimization Parameters --- + + def get_optimization_parameters(self) -> dict[str, list[torch.Tensor]]: + """Single param group keyed by DEFAULT_OPTIMIZER_KEY. + + Hyperparameters are baked by ``set_optimizer``, not here — return only the tensors, + matching the ``dict[str, list[tensor]]`` contract the object models use. + """ + return {self.DEFAULT_OPTIMIZER_KEY: list(self.parameters())} + + # --- Forward pass --- + @abstractmethod + def forward( + self, + dummy_input: Any = None, # Note all nn.Modules require some input. + ): + """ + Forward pass should be implemented in subclasses. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + # --- Properties --- + @property + def tilt_stack(self) -> torch.Tensor: + return self._tilt_stack + + @tilt_stack.setter + def tilt_stack(self, tilt_stack: torch.Tensor): + if type(tilt_stack) is not torch.Tensor: + print("Converting tilt stack to torch.Tensor") + tilt_stack = torch.from_numpy(tilt_stack) + + self._tilt_stack = tilt_stack + + @property + def tilt_angles(self) -> torch.Tensor: + return self._tilt_angles + + @tilt_angles.setter + def tilt_angles(self, tilt_angles: torch.Tensor): + if type(tilt_angles) is not torch.Tensor: + print("Converting tilt angles to torch.Tensor") + tilt_angles = torch.from_numpy(tilt_angles) + + self._tilt_angles = tilt_angles + + @property + def learn_shift(self) -> bool: + return self._learn_shift + + @learn_shift.setter + def learn_shift(self, learn_shift: bool): + self._learn_shift = learn_shift + + @property + def learn_tilt_axis(self) -> bool: + return self._learn_tilt_axis + + @learn_tilt_axis.setter + def learn_tilt_axis(self, learn_tilt_axis: bool): + self._learn_tilt_axis = learn_tilt_axis + + @property + def reference_tilt_idx(self) -> int: + return int(self._reference_tilt_angle_idx) + + @reference_tilt_idx.setter + def reference_tilt_idx(self, reference_tilt_idx: int): + self._reference_tilt_angle_idx = reference_tilt_idx + + @property + def learnable_tilts(self) -> int: + return self.tilt_angles.shape[0] - 1 + + @learnable_tilts.setter + def learnable_tilts(self, learnable_tilts: int): + self._learnable_tilts = learnable_tilts + + @property + def z1_params(self) -> torch.nn.Parameter: + return self._z1_params + + @z1_params.setter + def z1_params(self, z1_angles: torch.Tensor, device: str): + self._z1_params = nn.Parameter(z1_angles.to(device)) + + @property + def z3_params(self) -> torch.nn.Parameter: + return self._z3_params + + @z3_params.setter + def z3_params(self, z3_angles: torch.Tensor, device: str): + self._z3_params = nn.Parameter(z3_angles.to(device)) + + @property + def shifts_params(self) -> torch.nn.Parameter: + return self._shifts_params + + @shifts_params.setter + def shifts_params(self, shifts: torch.Tensor, device: str): + self._shifts_params = nn.Parameter(shifts.to(device)) + + @property + def device(self) -> torch.device: + return self._device + + @device.setter + def device(self, device: torch.device | str): + if isinstance(device, str): + device = torch.device(device) + self._device = device + + # --- Helper Functions --- + @abstractmethod + def to(self, device: torch.device | str): # type: ignore + """ + Moves the dataset to the device, and also insantiates the aux params to the device. + """ + + raise NotImplementedError("This method should be implemented in subclasses.") + + +class TomographyDatasetConstraints(BaseConstraints, TomographyDatasetBase): + DEFAULT_CONSTRAINTS = DatasetConstraintParams.BaseTomographyDatasetConstraints() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.constraints: DatasetConstraintParams.BaseTomographyDatasetConstraints = ( + self.DEFAULT_CONSTRAINTS.copy() + ) + + def apply_soft_constraints(self) -> torch.Tensor: + soft_loss = torch.tensor(0.0, device=self.z1_params.device) + if self.constraints.tv_zs > 0: + tv_loss_zs = tv_loss_1d(self.z1_params) + tv_loss_zs += tv_loss_1d(self.z3_params) + tv_loss_zs = self.constraints.tv_zs * tv_loss_zs + soft_loss += tv_loss_zs + + if self.constraints.tv_shifts > 0: + # Shift params is of shape (N, 2) + tv_loss_shifts = tv_loss_1d(self.shifts_params[:, 0]) + tv_loss_shifts += tv_loss_1d(self.shifts_params[:, 1]) + tv_loss_shifts = self.constraints.tv_shifts * tv_loss_shifts + soft_loss += tv_loss_shifts + return soft_loss + + def apply_hard_constraints(self) -> torch.Tensor: + """ + No hard constraints have been implemented yet. + """ + return torch.tensor(0.0) + + +class TomographyPixDataset(TomographyDatasetConstraints): + """ + Dataset class for pixel-based tomography, i.e AD, SIRT, WBP, etc... + + These algorithms only require the tilt image in the forward call. + """ + + def __init__( + self, + tilt_stack: Dataset3d | NDArray | torch.Tensor, + tilt_angles: NDArray | torch.Tensor, + learn_shift: bool = True, + learn_tilt_axis: bool = True, + norm_quantile: bool = True, + _token: object | None = None, + ): + super().__init__( + tilt_stack=tilt_stack, + tilt_angles=-tilt_angles, # TODO: Flip the tilt angles to be negative to match the convention of INR. + learn_shift=learn_shift, + learn_tilt_axis=learn_tilt_axis, + norm_quantile=norm_quantile, + _token=_token, + ) + + def forward( # type:ignore + self, + proj_idx: int, + ) -> DatasetValue: + """ + Forward pass for pixel-based tomography. + Returns the full tilt image for the given projection index, and the tilt angle. + """ + + return DatasetValue( + target=self.tilt_stack[proj_idx], + tilt_angle=self.tilt_angles[proj_idx].item(), + pixel_loc=None, + ) + + def to(self, device: str | torch.device): + """ + Moves the tilt stack and tilt_angles to the device, along with other nn.Parameters to the device. + """ + self.tilt_stack = self.tilt_stack.to(device) + self.tilt_angles = self.tilt_angles.to(device) + + self._z1_params = nn.Parameter(self._z1_angles.to(device)) + self._z3_params = nn.Parameter(self._z3_angles.to(device)) + self._shifts_params = nn.Parameter(self._shifts.to(device)) + + self._z1_ref = self._z1_ref.to(device) + self._z3_ref = self._z3_ref.to(device) + self._shifts_ref = self._shifts_ref.to(device) + + self.device = device + + +class TomographyINRDataset(TomographyDatasetConstraints, Dataset): + """ + Dataset class for INR-based tomography. + + The two main methods here are that the `forward` call will return the relative pose parameters, + while `__getitem__` will actually return the pixel values of the tilt stack. + + TODO: I think TomographyINRDataset shouldn't handle the train/val split and will be handled later? Yea this is handled in setup_dataloader in DDP + """ + + def __init__( + self, + tilt_stack: Dataset3d | NDArray | torch.Tensor, + tilt_angles: NDArray | torch.Tensor, + learn_shift: bool = True, + learn_tilt_axis: bool = True, + norm_quantile: bool = True, + seed: int = 42, + _token: object | None = None, + ): + super().__init__( + tilt_stack, + tilt_angles, + learn_shift, + learn_tilt_axis, + norm_quantile, + _token=_token, + ) + + # --- Forward Pass w/ Params Method for OptimizerMixin --- + def forward(self, dummy_input: Any = None): + """ + Forward pass for INR-based tomography. In the forward pass, the only parameters that + are passed will be the shifts, z1 and z3 Euler angles. + """ + + first_half_shifts = self.shifts_params[: self.reference_tilt_idx] + second_half_shifts = self.shifts_params[self.reference_tilt_idx :] + shifts = torch.cat([first_half_shifts, self._shifts_ref, second_half_shifts], dim=0) + + first_half_z1 = self.z1_params[: self.reference_tilt_idx] + second_half_z1 = self.z1_params[self.reference_tilt_idx :] + z1 = torch.cat([first_half_z1, self._z1_ref, second_half_z1], dim=0) + + first_half_z3 = self.z3_params[: self.reference_tilt_idx] + second_half_z3 = self.z3_params[self.reference_tilt_idx :] + z3 = torch.cat([first_half_z3, self._z3_ref, second_half_z3], dim=0) + + if self.learn_shift and self.learn_tilt_axis: + return shifts, z1, z3 + elif self.learn_shift: + return shifts, torch.zeros_like(z1), torch.zeros_like(z3) + elif self.learn_tilt_axis: + return torch.zeros_like(shifts), z1, z3 + else: + return torch.zeros_like(shifts), torch.zeros_like(z1), torch.zeros_like(z3) + + def get_coords( + self, batch: dict[str, torch.Tensor], N: int, num_samples_per_ray: int + ) -> torch.Tensor: + pixel_i = batch["pixel_i"].float().to(self.device, non_blocking=True) + pixel_j = batch["pixel_j"].float().to(self.device, non_blocking=True) + # target_values = batch["target_value"].to(self.device, non_blocking=True) + phis = batch["phi"].to(self.device, non_blocking=True) + projection_indices = batch["projection_idx"].to(self.device, non_blocking=True) + with torch.no_grad(): + batch_ray_coords = self.create_batch_rays(pixel_i, pixel_j, N, num_samples_per_ray) + + shifts, z1_params, z3_params = self.forward(None) + batch_shifts = torch.index_select(shifts, 0, projection_indices) + batch_z1 = torch.index_select(z1_params, 0, projection_indices) + batch_z3 = torch.index_select(z3_params, 0, projection_indices) + + transformed_rays = self.transform_batch_rays( + batch_ray_coords, + z1=batch_z1, + x=phis, + z3=batch_z3, + shifts=batch_shifts, + N=N, + sampling_rate=1.0, + ) + all_coords = transformed_rays.view(-1, 3) + + all_coords = all_coords.to(self.device, dtype=torch.float32, non_blocking=True) + return all_coords + + @staticmethod + def create_batch_rays( + pixel_i: torch.Tensor, pixel_j: torch.Tensor, N: int, num_samples_per_ray: int + ) -> torch.Tensor: + batch_size = len(pixel_i) + x_coords = (pixel_j / (N - 1)) * 2 - 1 + y_coords = (pixel_i / (N - 1)) * 2 - 1 + z_coords = torch.linspace(-1, 1, num_samples_per_ray, device=pixel_i.device) + + rays = torch.zeros(batch_size, num_samples_per_ray, 3, device=pixel_i.device) + + rays[:, :, 0] = x_coords.unsqueeze(1) + rays[:, :, 1] = y_coords.unsqueeze(1) + rays[:, :, 2] = z_coords.unsqueeze(0) + + return rays + + @staticmethod + def transform_batch_rays( + rays: torch.Tensor, + z1: torch.Tensor, + x: torch.Tensor, + z3: torch.Tensor, + shifts: torch.Tensor, + N: int, + sampling_rate: float, + ) -> torch.Tensor: + shift_x_norm = (shifts[:, 0:1] * sampling_rate * 2) / (N - 1) + shift_y_norm = (shifts[:, 1:2] * sampling_rate * 2) / (N - 1) + + rays_x = rays[:, :, 0] - shift_x_norm + rays_y = rays[:, :, 1] - shift_y_norm + rays_z = rays[:, :, 2] + + theta = torch.deg2rad(-z3).view(-1, 1) + cos_t = torch.cos(theta) + sin_t = torch.sin(theta) + + rays_x_rot1 = cos_t * rays_x - sin_t * rays_y + rays_y_rot1 = sin_t * rays_x + cos_t * rays_y + rays_z_rot1 = rays_z + + theta = torch.deg2rad(x).view(-1, 1) + cos_t = torch.cos(theta) + sin_t = torch.sin(theta) + + rays_x_rot2 = rays_x_rot1 + rays_y_rot2 = cos_t * rays_y_rot1 - sin_t * rays_z_rot1 + rays_z_rot2 = sin_t * rays_y_rot1 + cos_t * rays_z_rot1 + + theta = torch.deg2rad(-z1).view(-1, 1) + cos_t = torch.cos(theta) + sin_t = torch.sin(theta) + + rays_x_final = cos_t * rays_x_rot2 - sin_t * rays_y_rot2 + rays_y_final = sin_t * rays_x_rot2 + cos_t * rays_y_rot2 + rays_z_final = rays_z_rot2 + + transformed_rays = torch.stack([rays_x_final, rays_y_final, rays_z_final], dim=2) + + return transformed_rays + + @staticmethod + def integrate_rays( + rays: torch.Tensor, num_samples_per_ray: int, target_values_len: int + ) -> torch.Tensor: + ray_densities = rays.view( + target_values_len, + num_samples_per_ray, + ) + step_size = 2.0 / (num_samples_per_ray - 1) + + predicted_values = ray_densities.sum(dim=1) * step_size + + return predicted_values + + # --- Torch Dataset Methods --- + def __getitem__( + self, + idx: int, + ) -> dict: + """ + Gets the item for INR i.e, the project index, pixel value at (i, j), and the tilt angle. + """ + + actual_idx = idx + + projection_idx = actual_idx // (self.tilt_stack.shape[1] * self.tilt_stack.shape[2]) + remaining = actual_idx % (self.tilt_stack.shape[1] * self.tilt_stack.shape[2]) + + pixel_i = remaining // self.tilt_stack.shape[1] + pixel_j = remaining % self.tilt_stack.shape[1] + + return { + "projection_idx": torch.tensor(projection_idx), + "pixel_i": torch.tensor(pixel_i), + "pixel_j": torch.tensor(pixel_j), + "phi": self.tilt_angles[projection_idx], # tensor + "target_value": self.tilt_stack[projection_idx, pixel_i, pixel_j], # tensor + } + + def __len__( + self, + ): + """ + Returns the number of pixels in the tilt stack. + """ + N = max(self.tilt_stack.shape) + return self.tilt_stack.shape[0] * N * N + + def to(self, device: torch.device | str): + self._z1_params = nn.Parameter(self._z1_angles.to(device)) + self._z3_params = nn.Parameter(self._z3_angles.to(device)) + self._shifts_params = nn.Parameter(self._shifts.to(device)) + + self._z1_ref = self._z1_ref.to(device) + self._z3_ref = self._z3_ref.to(device) + self._shifts_ref = self._shifts_ref.to(device) + + self.device = device + self.reconnect_optimizer_to_parameters() + + # --- Save learned parameters --- + + def save_parameters(self, path: str): + """ + Saves the learned parameters to a file. + """ + torch.save( + { + "z1": self._z1_params.detach().cpu(), + "z3": self._z3_params.detach().cpu(), + "shifts": self._shifts_params.detach().cpu(), + }, + path, + ) + + def load_parameters(self, path: str): + """ + Loads the learned parameters from a file. + """ + data = torch.load(path) + self._z1_params = nn.Parameter(data["z1"]).to(self.device) + self._z3_params = nn.Parameter(data["z3"]).to(self.device) + self._shifts_params = nn.Parameter(data["shifts"]).to(self.device) + if self.optimizer is not None: + self.reconnect_optimizer_to_parameters() + + +class TomographyINRPretrainDataset(Dataset): + """ + Dataset class for pretraining INR models. + """ + + def __init__( + self, + pretrain_target: torch.Tensor, + ): + data = pretrain_target.float() + + total_elements = data.numel() + if total_elements > 1e6: + sample_size = min(int(1e6), total_elements) + flat_data = data.flatten() + indices = torch.randperm(total_elements)[:sample_size] + sampled_data = flat_data[indices] + data_quantile = torch.quantile(sampled_data, 0.95) + else: + data_quantile = torch.quantile(data, 0.95) + + data = data / data_quantile + data = torch.permute(data, (0, 3, 2, 1)) + # data = torch.flip(data, dims=(2,)) + + self.volume = data.cpu() + self.N = pretrain_target.shape[1] # Assumes cubic volume. + self.total_samples = pretrain_target.shape[1] ** 3 + + coords_1d = torch.linspace(-1, 1, self.N) + x, y, z = torch.meshgrid(coords_1d, coords_1d, coords_1d, indexing="ij") + self.coords = torch.stack([x, y, z], dim=-1).reshape(-1, 3).cpu() + self.targets = self.volume.reshape(-1).cpu() + + def __len__(self): + return self.total_samples + + def __getitem__(self, idx): + return {"coords": self.coords[idx], "target": self.targets[idx]} + + +DatasetModelType = TomographyINRDataset | TomographyPixDataset diff --git a/src/quantem/tomography/logger_tomography.py b/src/quantem/tomography/logger_tomography.py new file mode 100644 index 00000000..e07072f7 --- /dev/null +++ b/src/quantem/tomography/logger_tomography.py @@ -0,0 +1,91 @@ +import matplotlib.pyplot as plt +import numpy as np +import torch + +from quantem.core.ml.logger import LoggerBase +from quantem.tomography.dataset_models import DatasetModelType +from quantem.tomography.object_models import ObjectModelType + + +class LoggerTomography(LoggerBase): + """ + Logger for ML-based tomography reconstructions. + """ + + def __init__( + self, + log_dir: str, + run_prefix: str, + run_suffix: str = "", + log_images_every: int = 10, + ): + super().__init__(log_dir, run_prefix, run_suffix, log_images_every) + + def log_epoch(self, epoch: int, loss: float, tilt_series_loss: float, soft_loss: float): + self.log_scalar("loss/total", loss, epoch) + self.log_scalar("loss/tilt_series", tilt_series_loss, epoch) + self.log_scalar("loss/soft", soft_loss, epoch) + + def log_iter( + self, + object_model: ObjectModelType, + iter: int, + consistency_loss: float, + total_loss: float, + learning_rates: dict[str, float], + num_samples_per_ray: int, + val_loss: float | None = None, + ): + self.log_scalar("loss/consistency", consistency_loss, iter) + self.log_scalar("loss/total", total_loss, iter) + self.log_scalar("loss/soft", object_model._soft_constraint_losses[-1], iter) + self.log_scalar("num_samples_per_ray", num_samples_per_ray, iter) + for param_name, lr_value in learning_rates.items(): + self.log_scalar(f"learning_rate/{param_name}", float(lr_value), iter) + if val_loss is not None: + self.log_scalar("loss/val", val_loss, iter) + + def log_iter_images( + self, + pred_volume: np.ndarray, + dataset_model: DatasetModelType, + iter: int, + logger_cmap: str = "turbo", + ): + with torch.no_grad(): + z1_vals = dataset_model.z1_params.detach().cpu().numpy() + z3_vals = dataset_model.z3_params.detach().cpu().numpy() + shifts_vals = dataset_model.shifts_params.detach().cpu().numpy() + + for channel in range(pred_volume.shape[0]): + self.log_image( + f"volume/sum_z_{channel}", pred_volume[channel].sum(axis=0), iter, logger_cmap + ) + self.log_image( + f"volume/sum_y_{channel}", pred_volume[channel].sum(axis=1), iter, logger_cmap + ) + self.log_image( + f"volume/sum_x_{channel}", pred_volume[channel].sum(axis=2), iter, logger_cmap + ) + + # Plotting z1 and z3 vals + fig, ax = plt.subplots() + ax.plot(z1_vals, label="Z1") + ax.plot(z3_vals, label="Z3") + ax.legend() + ax.set_title("Z1 and Z3 Angles") + ax.set_xlabel("Tilt Image") + ax.set_ylabel("Degree") + self.log_figure("z1_z3_angles", fig, iter) + plt.close(fig) + + # Plotting shifts + fig, ax = plt.subplots() + ax.plot(shifts_vals[:, 0], label="Shifts X") + ax.plot(shifts_vals[:, 1], label="Shifts Y") + ax.legend() + ax.set_title("Shifts") + ax.set_xlabel("Tilt Image") + ax.set_ylabel("Pixel") + self.log_figure("shifts", fig, iter) + plt.close(fig) diff --git a/src/quantem/tomography/object_models.py b/src/quantem/tomography/object_models.py index d103087d..817e7ed5 100644 --- a/src/quantem/tomography/object_models.py +++ b/src/quantem/tomography/object_models.py @@ -1,540 +1,1059 @@ from abc import abstractmethod from copy import deepcopy -from typing import Any, Callable, Union +from dataclasses import dataclass +from typing import Any, Callable, Generator, Optional, cast import numpy as np import torch +import torch.distributed as dist +import torch.nn as nn from tqdm.auto import tqdm from quantem.core.io.serialize import AutoSerialize -from quantem.core.ml.blocks import reset_weights -from quantem.core.utils.validators import validate_gt, validate_tensor -from quantem.tomography.utils import get_TV_loss +from quantem.core.ml.constraints import BaseConstraints, Constraints +from quantem.core.ml.ddp import DDPMixin +from quantem.core.ml.loss_functions import get_loss_module +from quantem.core.ml.models.model_base import PlanarDecompositionModel +from quantem.core.ml.optimizer_mixin import OptimizerMixin +from quantem.core.utils.rng import RNGMixin +from quantem.tomography.dataset_models import TomographyINRPretrainDataset +from quantem.tomography.tomography_context import ReconstructionContext -class ObjectBase(AutoSerialize): +class ObjConstraintParams: """ - Base class for all ObjectModels to inherit from. + Namespace class for object reconstruction constraint dataclasses and parsing utilities. + + Contains constraint definitions for pixelated and implicit neural representation + (INR) object types, along with a factory method for instantiating the appropriate + class from a configuration dictionary. + + Supported constraint types + -------------------------- + ObjPixelatedConstraints + Constraints for a voxel-grid (pixelated) object representation. + ObjINRConstraints + Constraints for a network-parameterized (INR) object representation; + adds a sparsity term not present in the pixelated variant. + + Examples + -------- + >>> ObjConstraintParams.parse_dict({"name": "obj_pixelated", "tv_vol": 0.01}) + ObjPixelatedConstraints(positivity=True, shrinkage=0.0, tv_vol=0.01) + >>> ObjConstraintParams.parse_dict({"type": "obj_inr", "sparsity": 0.05}) + ObjINRConstraints(positivity=True, shrinkage=0.0, tv_vol=0.0, sparsity=0.05) """ - def __init__( - self, - volume_shape: tuple[int, int, int], - device: str, - offset_obj: float = 1e-5, - ): - self._shape = volume_shape + @dataclass + class ObjPixelatedConstraints(Constraints): + """ + Constraints for a pixelated (voxel-grid) object representation. + + Attributes + ---------- + positivity : bool + If ``True``, enforces non-negative values in the reconstruction. + shrinkage : float + Shrinkage regularization strength; pushes values toward zero. + tv_vol : float + Total variation regularization weight for the 3-D volume. + soft_constraint_keys : list[str] + Constraint fields penalized softly during optimization. + hard_constraint_keys : list[str] + Constraint fields enforced strictly during optimization. + """ - self._obj = torch.zeros(self._shape, device=device, dtype=torch.float32) + offset_obj - self._offset_obj = offset_obj - self._device = device - self._hard_constraints = {} - self._soft_constraints = {} # One big dicitonary + positivity: bool = True + shrinkage: float = 0.0 + tv_vol: float = 0.0 + _name: str = "obj_pixelated" - @property - def shape(self) -> tuple[int, int, int]: - return self._shape + soft_constraint_keys = ["tv_vol"] + hard_constraint_keys = ["positivity", "shrinkage"] - @shape.setter - def shape(self, shape: tuple[int, int, int]): - self._shape = shape + @dataclass + class ObjINRConstraints(Constraints): + """ + Constraints for an implicit neural representation (INR) object. + + Extends pixelated constraints with an additional sparsity term suited + to the continuous, network-parameterized object representation. + + Attributes + ---------- + positivity : bool + If ``True``, enforces non-negative values in the reconstruction. + shrinkage : float + Shrinkage regularization strength; pushes values toward zero. + tv_vol : float + Total variation regularization weight for the 3-D volume. + sparsity : float + Sparsity regularization weight; encourages near-zero activations. + soft_constraint_keys : list[str] + Constraint fields penalized softly during optimization. + hard_constraint_keys : list[str] + Constraint fields enforced strictly during optimization. + """ - @property - def offset_obj(self) -> float: - return self._offset_obj + positivity: bool = True + shrinkage: float = 0.0 + tv_vol: float = 0.0 + sparsity: float = 0.0 + _name: str = "obj_inr" - @offset_obj.setter - def offset_obj(self, offset_obj: float): - self._offset_obj = offset_obj + soft_constraint_keys = ["tv_vol", "sparsity"] + hard_constraint_keys = ["positivity", "shrinkage"] - @property - def obj(self) -> torch.Tensor: - pass + @dataclass + class ObjTensorDecompConstraints(Constraints): + """ + Constraints for a tensor decomposition object representation. + + Attributes + ---------- + positivity : bool + If ``True``, enforces non-negative values in the reconstruction. + shrinkage : float + Shrinkage regularization strength; pushes values toward zero. + tv_vol : float + Total variation regularization weight for the 3-D volume. + soft_constraint_keys : list[str] + Constraint fields penalized softly during optimization. + hard_constraint_keys : list[str] + Constraint fields enforced strictly during optimization. + """ - @obj.setter - def obj(self, obj: torch.Tensor): - self._obj = obj + positivity: bool = True + shrinkage: float = 0.0 + tv_vol: float = 0.0 + tv_plane: float = 0.0 + sparsity: float = 0.0 + _name: str = "obj_tensor_decomp" - @property - def device(self) -> str: - return self._device + soft_constraint_keys = ["tv_vol", "tv_plane", "sparsity"] + hard_constraint_keys = ["positivity", "shrinkage"] - @device.setter - def device(self, device: str): - self._device = device + @classmethod + def parse_dict( + cls, d: dict + ) -> "ObjConstraintParams.ObjPixelatedConstraints | ObjConstraintParams.ObjINRConstraints | ObjConstraintParams.ObjTensorDecompConstraints": + """ + Instantiate an object constraint dataclass from a configuration dictionary. + + The dictionary must contain a ``'name'`` or ``'type'`` key identifying + which constraint class to construct. All remaining keys are forwarded as + keyword arguments to the selected dataclass. + + Parameters + ---------- + d : dict + Configuration dictionary. Must include ``'name'`` or ``'type'`` + with one of the following values (case-insensitive): + + - ``'obj_pixelated'`` → :class:`ObjPixelatedConstraints` + - ``'obj_inr'`` → :class:`ObjINRConstraints` + + The value may also be a class ``type`` object, in which case its + ``__name__`` is used after lower-casing. + + Returns + ------- + ObjPixelatedConstraints or ObjINRConstraints + An instance of the appropriate object constraint dataclass. + + Raises + ------ + ValueError + If neither ``'name'`` nor ``'type'`` is present, if the value is not + a string or type, or if the name does not match any known object + constraint type. + """ + d = dict(d) + name = d.pop("name", None) + type_ = d.pop("type", None) + name = name or type_ + if name is None: + raise ValueError("Must provide either 'name' or 'type' key") + if isinstance(name, type): + name = name.__name__.lower() + elif isinstance(name, str): + name = name.lower() + else: + raise ValueError(f"Unknown object constraint type: {name}") + if name == "obj_pixelated": + return ObjConstraintParams.ObjPixelatedConstraints(**d) + elif name == "obj_inr": + return ObjConstraintParams.ObjINRConstraints(**d) + elif name == "obj_tensor_decomp": + return ObjConstraintParams.ObjTensorDecompConstraints(**d) + else: + raise ValueError(f"Unknown object constraint type: {name.lower()}") - @abstractmethod - def forward( - self, z1: torch.Tensor, z3: torch.Tensor, shift_x: torch.Tensor, shift_y: torch.Tensor - ): - pass - @abstractmethod - def obj(self): - pass +ObjConstraintsType = ( + ObjConstraintParams.ObjPixelatedConstraints + | ObjConstraintParams.ObjINRConstraints + | ObjConstraintParams.ObjTensorDecompConstraints +) - @abstractmethod - def reset(self): - pass - @abstractmethod - def to(self, device: str): - pass +def _unwrap(model: nn.Module | nn.parallel.DistributedDataParallel) -> PlanarDecompositionModel: + """Unwrap a DistributedDataParallel model to get the underlying module ONLY for tensor decomposition models.""" + if isinstance(model, nn.parallel.DistributedDataParallel): + return cast(PlanarDecompositionModel, model.module) + return cast(PlanarDecompositionModel, model) - @abstractmethod - def name(self) -> str: - pass - @abstractmethod - def params(self) -> torch.Tensor: - pass +class ObjectBase(AutoSerialize, nn.Module, RNGMixin, OptimizerMixin): + DEFAULT_LRS = { + "object": 8e-6, + } + _token = object() + """ + Base class for all ObjectModels to inherit from. + """ + + def __init__( + self, + shape: tuple[int, int, int], # pyright: ignore[reportRedeclaration] + device: str = "cpu", + rng: np.random.Generator | int | None = None, + _token: object | None = None, + ): + if _token is not self._token: + raise RuntimeError("Use a factory method to instantiate this class.") + self._shape = shape -class ObjectConstraints(ObjectBase): - DEFAULT_HARD_CONSTRAINTS = { - "fourier_filter": False, - "positivity": False, - "shrinkage": False, - "circular_mask": False, - } + # Initialize dependencies + nn.Module.__init__(self) + RNGMixin.__init__(self, rng=rng, device=device) + OptimizerMixin.__init__(self) - DEFAULT_SOFT_CONSTRAINTS = { - "tv_vol": 0, - } + # --- Instantiation ---- + # --- Properties --- @property - def hard_constraints(self) -> dict[str, Any]: - return self._hard_constraints + def shape(self) -> tuple[int, int, int]: + """ + Shape of the object (x, y, z). + """ + return self._shape - @hard_constraints.setter - def hard_constraints(self, hard_constraints: dict[str, Any]): - gkeys = self.DEFAULT_HARD_CONSTRAINTS.keys() - for key, value in hard_constraints.items(): - if key not in gkeys: # This might be redundant since add_constraint is checking. - raise KeyError(f"Invalid object constraint key '{key}', allowed keys are {gkeys}") - self._hard_constraints[key] = value + @shape.setter + def shape(self, new_shape: tuple[int, int, int]): + self._shape = new_shape @property - def soft_constraints(self) -> dict[str, Any]: - return self._soft_constraints - - @soft_constraints.setter - def soft_constraints(self, soft_constraints: dict[str, Any]): - gkeys = self.DEFAULT_SOFT_CONSTRAINTS.keys() - for key, value in soft_constraints.items(): - if key not in gkeys: - raise KeyError(f"Invalid object constraint key '{key}', allowed keys are {gkeys}") - self._soft_constraints[key] = value - - def add_hard_constraint(self, constraint: str, value: Any): - """Add constraints to the object model.""" - gkeys = self.DEFAULT_HARD_CONSTRAINTS.keys() - if constraint not in gkeys: - raise KeyError( - f"Invalid object constraint key '{constraint}', allowed keys are {gkeys}" - ) - self._hard_constraints[constraint] = value - - def add_soft_constraint(self, constraint: str, value: Any): - """Add constraints to the object model.""" - gkeys = self.DEFAULT_SOFT_CONSTRAINTS.keys() - if constraint not in gkeys: - raise KeyError( - f"Invalid object constraint key '{constraint}', allowed keys are {gkeys}" - ) - self._soft_constraints[constraint] = value + def obj(self) -> torch.Tensor: + """ + Returns the object, should be implemented in subclasses. + """ + raise NotImplementedError - def apply_hard_constraints( - self, - obj: torch.Tensor, - ) -> torch.Tensor: + @property + def model(self) -> nn.Module: """ - Apply constraints to the object model. + Returns the model, should be implemented in subclasses. """ - obj2 = obj.clone() - if self.hard_constraints["positivity"]: - obj2 = torch.clamp(obj, min=0.0, max=None) - if self.hard_constraints["shrinkage"]: - obj2 = torch.max(obj2 - self.hard_constraints["shrinkage"], torch.zeros_like(obj2)) + raise NotImplementedError - return obj2 + @property + def dtype(self) -> torch.dtype: + """ + Returns the dtype of the object. + """ + raise NotImplementedError - def apply_soft_constraints( - self, - obj: torch.Tensor, - ) -> torch.Tensor: + @abstractmethod + def forward(self, coords: Optional[torch.Tensor] = None) -> torch.Tensor: """ - 'Applies' soft constraints to the object model. This will return additional loss terms. + Forward pass, should be implemented in subclasses. Note for any nn.Module this is + a required method. """ - soft_loss = torch.tensor(0.0, device=obj.device, dtype=obj.dtype, requires_grad=True) - if self.soft_constraints["tv_vol"] > 0: - tv_loss = get_TV_loss( - obj.unsqueeze(0).unsqueeze(0), factor=self.soft_constraints["tv_vol"] - ) + raise NotImplementedError - soft_loss += tv_loss + @abstractmethod + def reset(self) -> None: + """ + Reset the object, should be implemented in subclasses. + """ + raise NotImplementedError - return soft_loss + @property + def params(self) -> Generator[torch.nn.Parameter, None, None]: + """ + Get the parameters that should be optimized for this model. + + Should be implemented in subclasses. + """ + raise NotImplementedError + + # --- Helper Functions --- + def get_optimization_parameters(self) -> "dict[str, list[torch.Tensor]]": + """Default: a single param group keyed by DEFAULT_OPTIMIZER_KEY. + + Hyperparameters are baked by ``set_optimizer``, not here — return only the tensors. + """ + return {self.DEFAULT_OPTIMIZER_KEY: list(self.params)} + + @abstractmethod # Each subclass should implement this. + def to(self, device: str | torch.device): + """ + Move the object to a device + """ + + raise NotImplementedError -class ObjectVoxelwise(ObjectConstraints): +class ObjectConstraints(BaseConstraints, ObjectBase): # TODO: Ask Arthur why we still need this + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @abstractmethod + def get_tv_loss(self, ctx: ReconstructionContext) -> torch.Tensor: + """ + Get the TV loss for the object model. Must be implemented in each subclass. + """ + raise NotImplementedError + + +class ObjectPixelated(ObjectConstraints): """ - Object model for voxelwise objects. + Object model for pixelated objects. + + Supports: Conventional algorithms (SIRT, FBP), and AD-based reconstructions. """ + DEFAULT_CONSTRAINTS = ObjConstraintParams.ObjPixelatedConstraints() + def __init__( self, - volume_shape: tuple[int, int, int], - device: str, - initial_volume: torch.Tensor | None = None, + shape: tuple[int, int, int], + device: str = "cpu", + rng: np.random.Generator | int | None = None, ): super().__init__( - volume_shape=volume_shape, + shape=shape, device=device, + rng=rng, + _token=self._token, ) - self.hard_constraints = self.DEFAULT_HARD_CONSTRAINTS.copy() - self.soft_constraints = self.DEFAULT_SOFT_CONSTRAINTS.copy() + self.constraints: ObjConstraintsType = self.DEFAULT_CONSTRAINTS.copy() - if initial_volume is not None: - self._initial_obj = initial_volume + # --- Instantiation ---- + @classmethod + def from_uniform( + cls, + shape: tuple[int, int, int], + device: str = "cpu", + rng: np.random.Generator | int | None = None, + ): + # Initialize a torch.zeros volume with the given shape + obj = torch.zeros(shape, device=device, dtype=torch.float32) + obj_model = cls(shape=shape, device=device, rng=rng) + obj_model._obj = obj + return obj_model + + @classmethod + def from_array( + cls, + initial_obj: torch.Tensor | np.ndarray, + device: str = "cpu", + rng: np.random.Generator | int | None = None, + ): + obj_model = cls(shape=initial_obj.shape, device=device, rng=rng) + if isinstance(initial_obj, np.ndarray): + initial_obj = torch.tensor(initial_obj, dtype=torch.float32) else: - self.initial_obj = ( - torch.zeros(self._shape, device=self._device, dtype=torch.float32) - + self.offset_obj - ) + initial_obj = initial_obj.clone() + obj_model._obj = initial_obj.to(device) + return obj_model + # --- Properties ---- @property - def obj(self): - return self.apply_hard_constraints(self._obj) + def obj(self) -> torch.Tensor: + return self.apply_hard_constraints( + self._obj + ) # TODO: Normalization factor to ensure object agrees with INR. @obj.setter def obj(self, obj: torch.Tensor): self._obj = obj @property - def initial_obj(self): - return self._initial_obj + def obj_view(self) -> np.ndarray: + return self.obj.cpu().unsqueeze(0).numpy() - @initial_obj.setter - def initial_obj(self, initial_obj: torch.Tensor): - if not isinstance(initial_obj, torch.Tensor): - raise ValueError("initial_obj must be a torch.Tensor") - - self._initial_obj = initial_obj - - def forward(self): - return self.obj - - def reset(self): - self._obj = ( - torch.zeros(self._shape, device=self._device, dtype=torch.float32) + self.offset_obj - ) - - def to(self, device: str): - self._device = device - self._obj = self._obj.to(self._device) + # @property + # def soft_loss(self) -> torch.Tensor: + # return self.apply_soft_constraints(self._obj) @property def name(self) -> str: - return "ObjectVoxelwise" + return "obj_pixelated" @property - def params(self) -> torch.Tensor: - return self._obj + def obj_type(self) -> str: + return "pixelated" @property - def soft_loss(self) -> torch.Tensor: - return self.apply_soft_constraints(self._obj) + def dtype(self) -> torch.dtype: + return self._obj.dtype + def apply_hard_constraints( + self, + pred: torch.Tensor, + ) -> torch.Tensor: + """ + Apply hard constraints to the object model. -class ObjectDIP(ObjectConstraints): - """ - Object model for DIP objects. - """ + Only hard constraint here is the positivity and shrinkage. TODO: Add the other hard constraints. + """ + obj2 = pred.clone() + if self.constraints.positivity: + obj2 = torch.clamp(obj2, min=0.0, max=None) + if self.constraints.shrinkage: + obj2 = torch.max(obj2 - self.constraints.shrinkage, torch.zeros_like(obj2)) + + # TODO: Need to implement the other hard constraints: Fourier Filter and Circular Mask. + return obj2 + + def apply_soft_constraints(self, ctx: ReconstructionContext) -> torch.Tensor: + assert ctx.obj is not None, "ObjectPixelated requires ctx.obj to be set" + soft_loss = torch.tensor( + 0.0, device=ctx.obj.device, dtype=ctx.obj.dtype, requires_grad=True + ) + if self.constraints.tv_vol > 0: + tv_loss = self.get_tv_loss(ctx) + soft_loss += tv_loss + return soft_loss + + # --- Forward method --- + def forward(self, coords=None) -> torch.Tensor: + return self.obj + + # --- Defining the TV loss --- + def get_tv_loss(self, ctx: ReconstructionContext) -> torch.Tensor: + assert ctx.obj is not None, "ObjectPixelated requires ctx.obj to be set" + # TV over the three trailing spatial dims, leaving any leading channel/batch axes + # intact. Works for a 3-D volume, obj_view's [1, D, H, W], and a multimodal + # [C, D, H, W] (channels = elemental compositions), matching the INR / tensor-decomp + # convention where the object carries a leading channel dimension. + tv_d = torch.pow(ctx.obj[..., 1:, :, :] - ctx.obj[..., :-1, :, :], 2).sum() + tv_h = torch.pow(ctx.obj[..., :, 1:, :] - ctx.obj[..., :, :-1, :], 2).sum() + tv_w = torch.pow(ctx.obj[..., :, :, 1:] - ctx.obj[..., :, :, :-1], 2).sum() + tv_loss = tv_d + tv_h + tv_w + + return tv_loss * self.constraints.tv_vol / ctx.obj.numel() + + # --- Helper Functions --- + def to(self, device: str | torch.device): + if isinstance(device, str): + device = torch.device(device) + self._device = device + self._obj = self._obj.to(device) + self.reconnect_optimizer_to_parameters() + return self + + +class ObjectINR(ObjectConstraints, DDPMixin): + DEFAULT_CONSTRAINTS = ObjConstraintParams.ObjINRConstraints() def __init__( self, - model: torch.nn.Module, - volume_shape: tuple[int, int, int], - model_input: torch.Tensor - | None = None, # Determines output size, model input pretraining target - input_noise_std: float = 0.0, + shape: tuple[int, int, int], device: str = "cpu", + rng: np.random.Generator | int | None = None, + model: nn.Module | None = None, + _token: object | None = None, ): super().__init__( - volume_shape=volume_shape, + shape=shape, device=device, + rng=rng, + _token=self._token, + ) + self._pretrain_losses = [] + self._pretrain_lrs = [] + self.constraints: ObjConstraintParams.ObjINRConstraints = self.DEFAULT_CONSTRAINTS.copy() + # Register the network submodule (important: real nn.Module attribute) + if model is not None: + self.setup_distributed(device=device) + self._model = self.distribute_model(model) + + @classmethod + def from_model( + cls, + model: nn.Module, + shape: tuple[int, int, int], + device: str = "cpu", + rng: np.random.Generator | int | None = None, + ): + obj_model = cls( + shape=shape, + device=device, + rng=rng, + model=model, # ✅ build/register in __init__ ) - self.hard_constraints = self.DEFAULT_HARD_CONSTRAINTS.copy() - self.soft_constraints = self.DEFAULT_SOFT_CONSTRAINTS.copy() - if model_input is None: - self.model_input = torch.randn(1, 1, volume_shape[0], volume_shape[1], volume_shape[2]) - else: - self.model_input = model_input.clone().detach() + obj_model.setup_distributed(device=device) + obj_model.to(device) + return obj_model - self.pretrain_target = model_input.clone().detach() + # --- Properties --- - self._model = model - self._optimizer = None - self._scheduler = None - self._pretrain_losses = [] - self._pretrain_lrs = [] - self._model_input_noise_std = input_noise_std + @property + def model(self) -> nn.Module | nn.parallel.DistributedDataParallel: + """ + Returns the INR model. + """ + return self._model + + # @model.setter + # def model(self, model: "nn.Module"): + # """ + # This doesn't work -- can't have setters for torch sub modules + # https://github.com/pytorch/pytorch/issues/52664 + + # For now, upon initialization private variable `._model` is set to the built model. + # """ + # raise RuntimeError("\n\n\nsetting model, this shouldn't be reachable???\n\n\n") @property - def name(self) -> str: - return "ObjectDIP" + def obj(self) -> torch.Tensor: + return self._obj + + @obj.setter + def obj(self, obj: torch.Tensor): + self._obj = obj @property - def model(self) -> torch.nn.Module: - return self._model + def obj_view(self) -> np.ndarray: + """ + Returns the object as a view of the x, y, z axes. - @model.setter - def model(self, model: torch.nn.Module): - if not isinstance(model, torch.nn.Module): - raise TypeError(f"Model must be a torch.nn.Module, got {type(model)}") - self._model = model.to(self._device) - self.set_pretrained_weights(self._model) + Matches the axes of conventionally reconstructed objects, this is the object that will be saved. + """ + self.create_volume() + return self._obj.cpu().numpy().transpose(0, 1, 3, 2) + def apply_soft_constraints( + self, + ctx: ReconstructionContext, + ) -> torch.Tensor: + soft_loss = torch.tensor(0.0, device=ctx.coords.device) + if self.constraints.tv_vol > 0: + assert ctx.coords is not None, ( + "coords must be provided for INR object model to compute the TV loss" + ) + soft_loss += self.get_tv_loss(ctx) + + if ( + isinstance(self.constraints, ObjConstraintParams.ObjINRConstraints) + and self.constraints.sparsity > 0 + ): # NOTE: For the linter, I must make this :) + assert ctx.pred is not None, ( + "pred must be provided for INR object model to compute the sparsity loss" + ) + sparsity_loss = self.constraints.sparsity * torch.norm(ctx.pred, p=1) + soft_loss += sparsity_loss + + return soft_loss + + def apply_hard_constraints(self, pred: torch.Tensor) -> torch.Tensor: + """ + Apply hard constraints to the predicted values of the INR model. + """ + + if self.constraints.positivity: + pred = torch.clamp(pred, min=0.0, max=None) + if self.constraints.shrinkage: + pred = torch.max(pred - self.constraints.shrinkage, torch.zeros_like(pred)) + + return pred + + # --- Define get_tv_loss --- + + def get_tv_loss(self, ctx: ReconstructionContext) -> torch.Tensor: + """ + Compute the total variation loss for the INR model. + """ + assert ctx.coords is not None, "coords must be provided for INR object model" + num_tv_samples = min(10_000, ctx.coords.shape[0]) + tv_indices = torch.randperm(ctx.coords.shape[0], device=ctx.coords.device)[:num_tv_samples] + + tv_coords = ctx.coords[tv_indices].detach().requires_grad_(True) + tv_densities_recomputed = self.model(tv_coords) + if isinstance(tv_densities_recomputed, tuple): + tv_densities_recomputed = tv_densities_recomputed[0] + + # Ensure shape is [num_samples, num_channels] + if tv_densities_recomputed.dim() == 1: + tv_densities_recomputed = tv_densities_recomputed.unsqueeze(-1) + + # Compute gradients for each channel + grad_outputs = torch.autograd.grad( + outputs=tv_densities_recomputed, + inputs=tv_coords, + grad_outputs=torch.ones_like(tv_densities_recomputed), + create_graph=True, + )[0] # Shape: [num_samples, coord_dim] + + # Compute TV loss - gradient magnitude per sample + grad_norm = torch.norm(grad_outputs, dim=1) # Shape: [num_samples] + return self.constraints.tv_vol * grad_norm.mean() + + # --- Optimization Parameters --- + @property + def params(self) -> Generator[torch.nn.Parameter, None, None]: + return self.model.parameters() # type: ignore[attr-defined] + + # Pretraining @property def pretrained_weights(self) -> dict[str, torch.Tensor]: + """get the pretrained weights of the INR model""" return self._pretrained_weights - def set_pretrained_weights(self, model: torch.nn.Module): + def _set_pretrained_weights(self, model: "torch.nn.Module"): + """set the pretrained weights of the INR model""" if not isinstance(model, torch.nn.Module): raise TypeError(f"Pretrained model must be a torch.nn.Module, got {type(model)}") self._pretrained_weights = deepcopy(model.state_dict()) @property - def model_input(self) -> torch.Tensor: - return self._model_input - - @model_input.setter - def model_input(self, input_tensor: torch.Tensor): - inp = validate_tensor( - input_tensor, - name="model_input", - dtype=torch.float32, - ndim=5, - expand_dims=True, - ) - self._model_input = inp.to(self._device) - - @property - def pretrain_target(self) -> torch.Tensor: + def pretrain_target(self) -> TomographyINRPretrainDataset: + """get the pretrain target""" return self._pretrain_target @pretrain_target.setter - def pretrain_target(self, target: torch.Tensor): - if target.ndim == 5: - target = target.squeeze(0).squeeze(0) - - target = validate_tensor( - target, - name="pretrain_target", - ndim=3, - dtype=torch.float32, - expand_dims=True, - ) - if target.shape[-3:] != self.model_input.shape[-3:]: - raise ValueError( - f"Pretrain target shape {target.shape} does not match model input shape {self.model_input.shape}" - ) - self._pretrain_target = target.to(self._device) + def pretrain_target(self, target: TomographyINRPretrainDataset): + """set the pretrain target""" + self._pretrain_target = target @property - def _model_input_noise_std(self) -> float: - """standard deviation of the gaussian noise added to the model input each forward call""" - return self._input_noise_std - - @_model_input_noise_std.setter - def _model_input_noise_std(self, std: float): - validate_gt(std, 0.0, "input_noise_std", geq=True) - self._input_noise_std = std + def dtype(self) -> torch.dtype: + """ + Returns the dtype of the object. + """ + # TODO: This is a temporary solution to get the dtype of the object. + return torch.float32 - @property - def optimizer(self) -> torch.optim.Optimizer: - """get the optimizer for the DIP model""" - if self._optimizer is None: - raise ValueError("Optimizer is not set. Use set_optimizer() to set it.") - return self._optimizer - - def set_optimizer(self, opt_params: dict): - opt_type = opt_params.pop("type") - if isinstance(opt_type, torch.optim.Optimizer): - self._optimizer = opt_type - elif isinstance(opt_type, type): - self._optimizer = opt_type(self.model.parameters(), **opt_params) - elif opt_type == "adam": - self._optimizer = torch.optim.Adam(self.model.parameters(), **opt_params) - elif opt_type == "adamw": - self._optimizer = torch.optim.AdamW(self.model.parameters(), **opt_params) - elif opt_type == "sgd": - self._optimizer = torch.optim.SGD(self.model.parameters(), **opt_params) - else: - raise NotImplementedError(f"Unknown optimizer type: {opt_params['type']}") + # --- Helper Functions --- + def rebuild_model(self): + self._model = self.distribute_model(self._model) - @property - def scheduler( - self, - ) -> ( - torch.optim.lr_scheduler._LRScheduler - | torch.optim.lr_scheduler.CyclicLR - | torch.optim.lr_scheduler.ReduceLROnPlateau - | torch.optim.lr_scheduler.ExponentialLR - | None - ): - return self._scheduler + # Reset method that goes back to the pretrained weights. + def reset(self): + """reset the model to the pretrained weights""" + self.model.load_state_dict(self._pretrained_weights.copy()) + self._model = self.distribute_model( + self.model + ) # Maybe add a check to see if distributed or not, but not very computationally expensive to do this. - def set_scheduler(self, params: dict, num_iter: int | None = None) -> None: - sched_type: str = params["type"].lower() - optimizer = self.optimizer - base_LR = optimizer.param_groups[0]["lr"] - if sched_type == "none": - scheduler = None - elif sched_type == "cyclic": - scheduler = torch.optim.lr_scheduler.CyclicLR( - optimizer, - base_lr=params.get("base_lr", base_LR / 4), - max_lr=params.get("max_lr", base_LR * 4), - step_size_up=params.get("step_size_up", 100), - mode=params.get("mode", "triangular2"), - cycle_momentum=params.get("momentum", False), - ) - elif sched_type.startswith(("plat", "reducelronplat")): - scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( - optimizer, - mode="min", - factor=params.get("factor", 0.5), - patience=params.get("patience", 10), - threshold=params.get("threshold", 1e-3), - min_lr=params.get("min_lr", base_LR / 20), - cooldown=params.get("cooldown", 20), - ) - elif sched_type in ["exp", "gamma", "exponential"]: - if "gamma" in params.keys(): - gamma = params["gamma"] - elif num_iter is not None: - fac = params.get("factor", 0.01) - gamma = fac ** (1.0 / num_iter) - else: - gamma = 0.999 - scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=gamma) - else: - raise ValueError(f"Unknown scheduler type: {sched_type}") - self._scheduler = scheduler + # --- Forward Method --- - @property - def pretrain_losses(self) -> np.ndarray: - return np.array(self._pretrain_losses) + def forward(self, coords: Optional[torch.Tensor] = None) -> torch.Tensor: + """forward pass for the INR model""" + assert coords is not None, "ObjectINR.forward requires coords" - @property - def pretrain_lrs(self) -> np.ndarray: - return np.array(self._pretrain_lrs) + all_densities = self.model(coords) - @property - def obj(self): - obj = self.model(self._model_input)[0] - return self.apply_hard_constraints(obj) + if all_densities.dim() > 1: + all_densities = all_densities.squeeze(-1) + valid_mask = ( + (coords[:, 0] >= -1) & (coords[:, 0] <= 1) & (coords[:, 1] >= -1) & (coords[:, 1] <= 1) + ).float() - def forward(self): - return self.model(self._model_input) + if all_densities.dim() > 1: + valid_mask = valid_mask.unsqueeze(-1) + # Multi-dimensional mask + all_densities = all_densities * valid_mask - def to(self, device: str): - self.device = device - self._model = self._model.to(self.device) - self._model_input = self._model_input.to(self.device) - self._pretrain_target = self._pretrain_target.to(self.device) + all_densities = self.apply_hard_constraints(all_densities) - @property - def params(self): - return self._model.parameters() + return all_densities - def reset(self): - self.model.load_state_dict(self.pretrained_weights.copy()) + # Pretrain Loop def pretrain( self, - model_input: torch.Tensor, - pretrain_target: torch.Tensor, - reset: bool = True, - num_epochs: int = 100, + pretrain_dataset: TomographyINRPretrainDataset, + batch_size: int, + reset: bool = False, + num_iters: int = 10, + num_workers: int = 0, optimizer_params: dict | None = None, scheduler_params: dict | None = None, - loss_fn: Callable | str = "l2", - apply_constraints: bool = False, - show: bool = True, + loss_fn: Callable | str = "l1", + verbose: bool = True, ): - model_input.to(self.device) - pretrain_target.to(self.device) + """ + Pretrain the INR model to fit target volume. + """ + + if ( + pretrain_dataset is not None + ): # Need to make a check if there's already a pretrain dataset to not go through with the setup again. + self.pretrain_dataset = pretrain_dataset + ( + self.pretraining_dataloader, + self.pretraining_sampler, + self.pretraining_val_dataloader, + self.pretraining_val_sampler, + ) = self.setup_dataloader(pretrain_dataset, batch_size, num_workers=num_workers) if optimizer_params is not None: self.set_optimizer(optimizer_params) - if scheduler_params is not None: - self.set_scheduler(scheduler_params, num_epochs) + self.set_scheduler(scheduler_params, num_iters) if reset: - self._model.apply(reset_weights) - self._pretrain_losses = [] - self._pretrain_lrs = [] - - if model_input is not None: - self.model_input = model_input - - if pretrain_target.shape[-3:] != self.model_input.shape[-3:]: - raise ValueError( - f"Pretrain target shape {pretrain_target.shape} does not match model input shape {self.model_input.shape}" - ) - self.pretrain_target = pretrain_target.clone().detach().to(self.device) + self.reset() - loss_fn = torch.nn.functional.mse_loss + loss_fn = get_loss_module(loss_fn, self.dtype) self._pretrain( - num_epochs=num_epochs, + num_iters=num_iters, loss_fn=loss_fn, - apply_constraints=apply_constraints, - show=show, + verbose=verbose, ) - self.set_pretrained_weights(self.model) def _pretrain( self, - num_epochs: int, + num_iters: int, loss_fn: Callable, - apply_constraints: bool = False, - show: bool = False, + verbose: bool, ): - if not hasattr(self, "pretrain_target"): - raise ValueError("Pretrain target is not set. Use pretrain_target to set it.") + if self.optimizer is None: + raise RuntimeError("Optimizer not set. Call set_optimizer() first.") + if self.scheduler is None: + raise RuntimeError("Scheduler not set. Call set_scheduler() first.") self.model.train() optimizer = self.optimizer - sch = self.scheduler - pbar = tqdm(range(num_epochs)) - output = self.obj + scheduler = self.scheduler + pbar = tqdm(range(num_iters), desc="Pretraining", disable=not verbose) for a0 in pbar: - if apply_constraints: - output = self.obj - else: - output = self.model(self.model_input).squeeze(0).squeeze(0) + epoch_loss = 0 + for batch_idx, batch in enumerate[Any](self.pretraining_dataloader): + coords = batch["coords"].to(self.device, non_blocking=True) + target = batch["target"].to(self.device, non_blocking=True) + + with torch.autocast( + device_type=self.device.type, dtype=torch.bfloat16, enabled=True + ): + outputs = self.forward(coords) + loss = loss_fn(outputs, target) + + loss.backward() + epoch_loss += loss.item() - loss = loss_fn(output, self.pretrain_target) - loss.backward() - optimizer.step() - optimizer.zero_grad() + # Clip gradients + torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0) - if sch is not None: - if isinstance(sch, torch.optim.lr_scheduler.ReduceLROnPlateau): - sch.step(loss.item()) + optimizer.step() + optimizer.zero_grad() + + if scheduler is not None: + if isinstance(scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau): + scheduler.step(epoch_loss) else: - sch.step() + scheduler.step() - self._pretrain_losses.append(loss.item()) + self._pretrain_losses.append(epoch_loss / len(self.pretraining_dataloader)) + print( + f"Epoch {a0 + 1}/{num_iters}, Pretrain Loss: {epoch_loss / len(self.pretraining_dataloader):.4f}" + ) self._pretrain_lrs.append(optimizer.param_groups[0]["lr"]) - pbar.set_description(f"Epoch {a0 + 1}/{num_epochs}, Loss: {loss.item():.4f}, ") + + def create_volume(self, return_vol: bool = False): + N = max(self._shape) + with torch.no_grad(): + coords_1d = torch.linspace(-1, 1, N) + x, y, z = torch.meshgrid(coords_1d, coords_1d, coords_1d, indexing="ij") + inputs = torch.stack([x, y, z], dim=-1).reshape(-1, 3) + model = self.model.module if isinstance(self.model, nn.DataParallel) else self.model + + inference_batch_size = 5 * N * N + total_samples = N**3 + samples_per_gpu = total_samples // self.world_size + remainder = total_samples % self.world_size + + if self.global_rank < remainder: + start_idx = self.global_rank * (samples_per_gpu + 1) + end_idx = start_idx + samples_per_gpu + 1 + else: + start_idx = self.global_rank * samples_per_gpu + remainder + end_idx = start_idx + samples_per_gpu + + inputs_subset = inputs[start_idx:end_idx] + num_samples = inputs_subset.shape[0] + + outputs_list = [] + for batch_start in range(0, num_samples, inference_batch_size): + batch_end = min(batch_start + inference_batch_size, num_samples) + batch_coords = inputs_subset[batch_start:batch_end].to( + self.device, non_blocking=True + ) + + batch_outputs = model(batch_coords) # (B, C) or (B,) etc. + + if isinstance(batch_outputs, tuple): + batch_outputs = batch_outputs[0] + batch_outputs = self.apply_hard_constraints(batch_outputs) + + # Ensure shape is (B, C) + if batch_outputs.dim() == 1: + batch_outputs = batch_outputs.unsqueeze(-1) # (B, 1) + + outputs_list.append(batch_outputs.cpu()) + + outputs = torch.cat(outputs_list, dim=0) # (local_B, C) + C = outputs.shape[-1] # e.g. 5 + + if self.world_size > 1: + # gather variable-sized first dimension (local_B) while keeping channels + local_B = outputs.shape[0] + output_size = torch.tensor(local_B, device=self.device, dtype=torch.long) + all_sizes = [ + torch.zeros(1, device=self.device, dtype=torch.long) + for _ in range(self.world_size) + ] + dist.all_gather(all_sizes, output_size) + max_size = max(size.item() for size in all_sizes) + + outputs_dev = outputs.to(self.device) # (local_B, C) + if local_B < max_size: + pad = torch.zeros( + (max_size - local_B, C), # type: ignore + device=self.device, + dtype=outputs_dev.dtype, + ) + outputs_padded = torch.cat([outputs_dev, pad], dim=0) # (max_size, C) + else: + outputs_padded = outputs_dev + + gathered_outputs = [ + torch.empty((max_size, C), device=self.device, dtype=outputs_dev.dtype) # type: ignore + for _ in range(self.world_size) + ] + dist.all_gather(gathered_outputs, outputs_padded.contiguous()) + + trimmed_outputs = [] + for rank, size in enumerate(all_sizes): + trimmed_outputs.append(gathered_outputs[rank][: size.item(), :]) + + pred_full = torch.cat(trimmed_outputs, dim=0).reshape(C, N, N, N).float() + else: + pred_full = outputs.reshape(C, N, N, N).float() + + if return_vol: + return pred_full.detach().cpu() + + self._obj = pred_full.detach().cpu() + + def to(self, device: str | torch.device): # pyright: ignore[reportIncompatibleMethodOverride] -> better to do this device change + if isinstance(device, str): + device = torch.device(device) + self._device = device + if self.world_size == 1: + self._model = self._model.to(device) + elif not isinstance(self._model, torch.nn.parallel.DistributedDataParallel): + self.distribute_model(self._model) + self.reconnect_optimizer_to_parameters() + + +class ObjectTensorDecomp(ObjectINR): + DEFAULT_CONSTRAINTS = ObjConstraintParams.ObjTensorDecompConstraints() + + def __init__( + self, + shape: tuple[int, int, int], + device: str = "cpu", + rng: np.random.Generator | int | None = None, + model: nn.Module | None = None, + _token: object | None = None, + ): + super().__init__( + shape=shape, + device=device, + rng=rng, + _token=self._token, + ) + self._pretrain_losses = [] + self._pretrain_lrs = [] + self.constraints: ObjConstraintParams.ObjTensorDecompConstraints = ( + self.DEFAULT_CONSTRAINTS.copy() + ) + # Register the network submodule (important: real nn.Module attribute) + if model is not None: + self.setup_distributed(device=device) + self._model = self.distribute_model(model) + + @classmethod + def from_model( + cls, + model: nn.Module, + shape: tuple[int, int, int], + device: str = "cpu", + rng: np.random.Generator | int | None = None, + ): + obj_model = cls( + shape=shape, + device=device, + rng=rng, + model=model, # ✅ build/register in __init__ + ) + + obj_model.setup_distributed(device=device) + obj_model.to(device) + return obj_model + + # --- Constraints --- + + def apply_soft_constraints(self, ctx: ReconstructionContext) -> torch.Tensor: + soft_loss = torch.tensor( + 0.0, device=ctx.pred.device if ctx.pred is not None else self.device + ) + if self.constraints.tv_vol > 0: + assert ctx.coords is not None, "Coordinates must be provided for TV loss" + assert ctx.pred is not None, "Prediction must be provided for TV loss" + soft_loss += self.get_tv_loss(ctx) + + if self.constraints.sparsity > 0: # NOTE: For the linter, I must make this :) + assert ctx.all_densities is not None, ( + "All densities must be provided for sparsity loss" + ) + sparsity_loss = self.constraints.sparsity * ctx.all_densities.abs().mean() + soft_loss += sparsity_loss + + return soft_loss + + # TV Losses + + def get_tv_loss(self, ctx: ReconstructionContext) -> torch.Tensor: + """ + Gets the summed total variational loss for the tensor decomposition model. + + _get_plane_tv_loss: Total-variation across the planes. + _get_volume_tv_loss: Isotropic volume TV + """ + assert ctx.coords is not None, "Coordinates must be provided for TV loss" + assert ctx.pred is not None, "Prediction must be provided for TV loss" + tv_loss = torch.tensor(0.0, device=ctx.pred.device) + tv_loss += self._get_plane_tv_loss() + tv_loss += self.get_volume_tv_loss(ctx.coords) + return tv_loss + + def _get_plane_tv_loss(self) -> torch.Tensor: + """ + Gets the total-variation across the planes. + """ + is_tilted = self.model.tilted + per_level = [] + + model = _unwrap(self.model) + for p in model.grids: + # p: (3*T, C, H, W) for TILTED, (3, C, H, W) for KPlanes + dh = (p[:, :, 1:, :] - p[:, :, :-1, :]).pow(2).mean(dim=(1, 2, 3)) + dw = (p[:, :, :, 1:] - p[:, :, :, :-1]).pow(2).mean(dim=(1, 2, 3)) + per_plane = dh + dw # (3*T,) or (3,) + + if is_tilted: + T = self.model.T + per_rotation = per_plane.view(T, 3).sum(dim=1) # sum 3 planes per rotation + level_tv = per_rotation.mean() # avg across rotations + else: + level_tv = per_plane.sum() + + per_level.append(level_tv) + + return self.constraints.tv_plane * torch.stack(per_level).sum() + + def get_volume_tv_loss(self, coords: torch.Tensor) -> torch.Tensor: + """ + Isotropic volume TV via finite differences. Same form as the autograd + version (L1 of gradient L2-norm) but avoids double-backward, so it + works for KPlanesTILTED, CPTilted, and anything else. + """ + num_tv_samples = min(10_000, coords.shape[0]) + tv_indices = torch.randperm(coords.shape[0], device=coords.device)[:num_tv_samples] + tv_coords = coords[tv_indices] # (N, 3) + + model = _unwrap(self.model) + h = 2.0 / min(model.resolution) + + pred = model(tv_coords) + if isinstance(pred, tuple): + pred = pred[0] + if pred.dim() == 1: + pred = pred.unsqueeze(-1) # (N, 1) + + grads = [] + for axis in range(3): + offset = torch.zeros(3, device=tv_coords.device) + offset[axis] = h + shifted_pred = self.model(tv_coords + offset) + if isinstance(shifted_pred, tuple): + shifted_pred = shifted_pred[0] + if shifted_pred.dim() == 1: + shifted_pred = shifted_pred.unsqueeze(-1) + grads.append((shifted_pred - pred) / h) # (N, 1) + + grad_stack = torch.stack(grads, dim=-1) # (N, C, 3) + grad_norm = torch.norm(grad_stack, dim=-1) # (N, C) + + return self.constraints.tv_vol * grad_norm.mean() + + def apply_hard_constraints(self, pred: torch.Tensor) -> torch.Tensor: + """ + Apply hard constraints to the predicted values of the INR model. + """ + + if self.constraints.positivity: + pred = torch.clamp(pred, min=0.0, max=None) + if self.constraints.shrinkage: + pred = torch.max(pred - self.constraints.shrinkage, torch.zeros_like(pred)) + + return pred + + # --- Optimization Parameters --- + @property + def params(self) -> Generator[torch.nn.Parameter, None, None]: + """ + Returns the optimization parameters, here we also check if PPLR is used and return the appropriate parameters. + """ + + return self.model.parameters() # type: ignore[attr-defined] + + def get_optimization_parameters(self) -> "dict[str, list[torch.Tensor]]": + """PPLR: per-key param groups (hyperparameters are baked by set_optimizer).""" + model = _unwrap(self.model) + return {key: list(model.get_params()[key]) for key in model.param_keys} + + def _normalize_optimizer_params(self, params): + """ObjectTensorDecomp requires a dict matching model.param_keys.""" + if not isinstance(params, dict) or self._is_single_optimizer_dict(params): + raise TypeError( + f"ObjectTensorDecomp requires dict[str, OptimizerParamsType] keyed by " + f"param_keys; got {type(params)}" + ) + model = _unwrap(self.model) + expected = set(model.param_keys) + got = set(params.keys()) + if got != expected: + raise ValueError( + f"optimizer_params keys must match model.param_keys: " + f"got {got}, expected {expected}" + ) + return super()._normalize_optimizer_params(params) + + def pretrain(self) -> None: + raise NotImplementedError( + "Tensor decomposition pretraining is not usually required, and for TILTED there is a two-phase warmup approach." + ) -ObjectModelType = Union[ObjectVoxelwise] # | ObjectDIP | ObjectImplicit (ObjectFFN?) +ObjectModelType = ObjectPixelated | ObjectINR | ObjectTensorDecomp diff --git a/src/quantem/tomography/preprocess/drift.py b/src/quantem/tomography/preprocess/drift.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/quantem/tomography/radon/radon.py b/src/quantem/tomography/radon/radon.py index d6d408e3..ca133db3 100644 --- a/src/quantem/tomography/radon/radon.py +++ b/src/quantem/tomography/radon/radon.py @@ -9,37 +9,25 @@ def radon_torch(images, theta=None, device=None): - """ - Batched Radon transform implemented in PyTorch. - images: torch.Tensor of shape [B, H, W] - Returns: torch.Tensor of shape [B, N_angles, N_pixels] - """ if images.ndim == 2: - images = images.unsqueeze(0) # [1, H, W] + images = images.unsqueeze(0) B, H, W = images.shape + device = device or images.device + theta = theta if theta is not None else torch.arange(180, device=device) + A = len(theta) - if device is None: - device = images.device - - if theta is None: - theta = torch.arange(180, device=device) - - N_angles = len(theta) shape_min = min(H, W) radius = shape_min // 2 - center = torch.tensor([H // 2, W // 2], device=device) + center_h, center_w = H // 2, W // 2 Y, X = torch.meshgrid( torch.arange(H, device=device), torch.arange(W, device=device), indexing="ij", ) - dist2 = (X - center[1]) ** 2 + (Y - center[0]) ** 2 - mask = dist2 <= radius**2 - images = images.clone() - images *= mask # broadcasting over batch + mask = (X - center_w) ** 2 + (Y - center_h) ** 2 <= radius**2 + images = images.clone() * mask - # Crop to square excess = torch.tensor([H, W], device=device) - shape_min slices = tuple( slice(int((e.item() + 1) // 2), int((e.item() + 1) // 2 + shape_min)) @@ -51,112 +39,105 @@ def radon_torch(images, theta=None, device=None): N = images.shape[-1] center = N // 2 - radon_images = torch.zeros((B, N_angles, N), dtype=images.dtype, device=device) + # Build all rotation matrices at once: [A, 2, 2] + angles_rad = torch.deg2rad(theta.float()) + cos_a = torch.cos(angles_rad) + sin_a = torch.sin(angles_rad) + rot = torch.stack( + [ + torch.stack([cos_a, -sin_a], dim=1), + torch.stack([-sin_a, -cos_a], dim=1), + ], + dim=1, + ) # [A, 2, 2] + # Pixel coords: [N*N, 2] grid_y, grid_x = torch.meshgrid( torch.arange(N, dtype=torch.float32, device=device), torch.arange(N, dtype=torch.float32, device=device), indexing="ij", ) - coords = torch.stack((grid_x - center, grid_y - center), dim=-1) # (N, N, 2) - coords = coords.view(1, N, N, 2).expand(B, -1, -1, -1) # [B, N, N, 2] - - for i, angle in enumerate(theta): - angle_rad = torch.deg2rad(angle) - rot = torch.tensor( - [ - [torch.cos(angle_rad), -torch.sin(angle_rad)], - [-torch.sin(angle_rad), -torch.cos(angle_rad)], - ], - device=device, - dtype=torch.float32, - ) + coords = torch.stack((grid_x - center, grid_y - center), dim=-1).view(-1, 2) # [N*N, 2] - rot = rot.unsqueeze(0).expand(B, -1, -1) # [B, 2, 2] - coords_rot = torch.matmul(coords.view(B, -1, 2), rot.transpose(1, 2)).view(B, N, N, 2) - coords_rot += center + # Rotate all angles at once: [A, N*N, 2] + coords_rot = (coords @ rot.transpose(1, 2)) + center # [A, N*N, 2] - # Normalize to [-1, 1] - grid = 2 * coords_rot / (N - 1) - 1 # [B, N, N, 2] + # Normalize to [-1, 1] for grid_sample + grid = (2 * coords_rot / (N - 1) - 1).view(A, N, N, 2) # [A, N, N, 2] - # grid = grid.unsqueeze(1) # [B, 1, N, N, 2] - imgs = images.unsqueeze(1) # [B, 1, N, N] + # Expand images: [B, 1, N, N] -> sample with [B*A, 1, N, N] style + # Tile images over angles, tile grid over batch + images_exp = images.unsqueeze(1).expand(-1, A, -1, -1).reshape(B * A, 1, N, N) + grid_exp = grid.unsqueeze(0).expand(B, -1, -1, -1, -1).reshape(B * A, N, N, 2) - sampled = F.grid_sample( - imgs, grid, mode="bilinear", padding_mode="zeros", align_corners=True - ) - projection = sampled.squeeze(1).sum(dim=1) # [B, N] - radon_images[:, i, :] = projection + sampled = F.grid_sample( + images_exp, grid_exp, mode="bilinear", padding_mode="zeros", align_corners=True + ) + # sampled: [B*A, 1, N, N] -> sum over rows -> [B, A, N] + radon_images = sampled.squeeze(1).sum(dim=1).view(B, A, N) - return radon_images.squeeze(0) if radon_images.shape[0] == 1 else radon_images + return radon_images.squeeze(0) if B == 1 else radon_images def iradon_torch( sinograms, theta=None, output_size=None, - filter_name="ramp", + filter_name: str | None = "ramp", circle=True, device=None, ): - """ - Batched inverse Radon transform (filtered backprojection). - sinograms: [B, N_angles, N_pixels] or [N_angles, N_pixels] (automatically batched) - Returns: [B, output_size, output_size] or [output_size, output_size] - """ if sinograms.ndim == 2: - sinograms = sinograms.unsqueeze(0) # [1, A, P] + sinograms = sinograms.unsqueeze(0) B, A, N = sinograms.shape - - device = sinograms.device if device is None else device - theta = theta if theta is not None else torch.linspace(0, 180, steps=A, device=device) - - if theta.shape[0] != A: - raise ValueError("theta does not match number of projections") + device = device or sinograms.device + # Match radon_torch / scikit-image: A angles evenly spanning [0, 180) (endpoint excluded). + theta = theta if theta is not None else torch.linspace(0, 180, steps=A + 1, device=device)[:-1] if output_size is None: - output_size = N if circle else int(torch.floor(torch.sqrt(torch.tensor(N**2 / 2.0)))) + output_size = N if circle else int((N**2 / 2) ** 0.5) - # Padding for FFT - padded_size = max( - 64, int(2 ** torch.ceil(torch.log2(torch.tensor(2 * N, dtype=torch.float32)))) - ) + # Filter + padded_size = max(64, int(2 ** torch.ceil(torch.log2(torch.tensor(2.0 * N))))) pad_y = padded_size - N - sinograms_padded = F.pad(sinograms, (0, pad_y)) # [B, A, padded] - - f_filter = get_fourier_filter_torch(padded_size, filter_name, device=device) # [1, padded] - spectrum = torch.fft.fft(sinograms_padded, dim=2) - filtered = torch.real(torch.fft.ifft(spectrum * f_filter, dim=2))[:, :, :N] + sinograms_padded = F.pad(sinograms, (0, pad_y)) + f_filter = get_fourier_filter_torch(padded_size, filter_name, device=device) + filtered = torch.real( + torch.fft.ifft(torch.fft.fft(sinograms_padded, dim=2) * f_filter, dim=2) + )[:, :, :N] # [B, A, N] - # Backprojection - recon = torch.zeros((B, output_size, output_size), device=device) radius = output_size // 2 - y, x = torch.meshgrid( torch.arange(output_size, device=device) - radius, torch.arange(output_size, device=device) - radius, indexing="ij", ) - x = x.flatten() - y = y.flatten() + x = x.float().flatten() # [H*W] + y = y.float().flatten() - for i, angle in enumerate(torch.deg2rad(theta)): - t = (x * torch.cos(angle) - y * torch.sin(angle)).reshape(1, output_size, output_size) - t_idx = t + (N // 2) + # Compute t for all angles at once: [A, H*W] + angles_rad = torch.deg2rad(theta.float()) + t = x.unsqueeze(0) * torch.cos(angles_rad).unsqueeze(1) - y.unsqueeze(0) * torch.sin( + angles_rad + ).unsqueeze(1) # [A, H*W] + t_idx = t + (N // 2) # [A, H*W] - t0 = torch.floor(t_idx).long().clamp(0, N - 2) # [1, H, W] - t1 = t0 + 1 - w = t_idx - t0.float() + t0 = t_idx.long().clamp(0, N - 2) # [A, H*W] + t1 = t0 + 1 + w = t_idx - t0.float() # [A, H*W] - t0 = t0.expand(B, -1, -1) # [B, H, W] - t1 = t1.expand(B, -1, -1) + # Gather from filtered: [B, A, N] -> gather at [A, H*W] indices + # Expand: [B, A, H*W] + # HW = output_size * output_size + t0_exp = t0.unsqueeze(0).expand(B, -1, -1) # [B, A, H*W] + t1_exp = t1.unsqueeze(0).expand(B, -1, -1) + w_exp = w.unsqueeze(0).expand(B, -1, -1) - filtered_i = filtered[:, i, :] # [B, N] - val0 = torch.gather(filtered_i, 1, t0.view(B, -1)).view(B, output_size, output_size) - val1 = torch.gather(filtered_i, 1, t1.view(B, -1)).view(B, output_size, output_size) + val0 = torch.gather(filtered, 2, t0_exp) # [B, A, H*W] + val1 = torch.gather(filtered, 2, t1_exp) + proj = ((1 - w_exp) * val0 + w_exp * val1).sum(dim=1) # [B, H*W] - proj = (1 - w) * val0 + w * val1 - recon += proj + recon = proj.view(B, output_size, output_size) if circle: mask = ( @@ -166,10 +147,12 @@ def iradon_torch( recon[:, mask] = 0.0 recon *= torch.pi / (2 * A) - return recon.squeeze(0) if recon.shape[0] == 1 else recon + return recon.squeeze(0) if B == 1 else recon -def get_fourier_filter_torch(size, filter_name="ramp", device=None, dtype=torch.float32): +def get_fourier_filter_torch( + size, filter_name: str | None = "ramp", device=None, dtype=torch.float32 +): """ Construct the Fourier filter in PyTorch. """ diff --git a/src/quantem/tomography/tilt_series_dataset.py b/src/quantem/tomography/tilt_series_dataset.py deleted file mode 100644 index f6bff836..00000000 --- a/src/quantem/tomography/tilt_series_dataset.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Any, List, Self - -import numpy as np -import torch -from numpy.typing import NDArray - -from quantem.core.datastructures.dataset3d import Dataset2d, Dataset3d -from quantem.core.utils.validators import ensure_valid_array - -# from quantem.tomography.alignment import tilt_series_cross_cor_align, compute_com_tilt_series - - -# DEPRECATED: Use TomographyDataset instead. -class TiltSeries(Dataset3d): - def __init__( - self, - array: NDArray | Any, # Assumes a input tilt series [phis, x, y] - name: str, - origin: NDArray | tuple | list | float | int, - sampling: NDArray | tuple | list | float | int, - units: list[str] | tuple | list, - tilt_angles: list | NDArray, - z1_angles: list | NDArray, - z3_angles: list | NDArray, - shifts: list[tuple[float, float]] | NDArray, - signal_units: str = "arb. units", - _token: object | None = None, - ): - super().__init__( - array=array, - name=name, - origin=origin, - sampling=sampling, - units=units, - signal_units=signal_units, - _token=_token, - ) - self._tilt_angles = tilt_angles - self._z1_angles = z1_angles - self._z3_angles = z3_angles - self._shifts = shifts - - @classmethod - def from_array( - cls, - array: NDArray | List[Dataset2d] | Any, - tilt_angles: list | NDArray = None, - z1_angles: list | NDArray = None, - z3_angles: list | NDArray = None, - shifts: list[tuple[float, float]] | NDArray = None, - name: str | None = None, - origin: NDArray | tuple | list | float | int | None = None, - sampling: NDArray | tuple | list | float | int | None = None, - units: list[str] | tuple | list | None = None, - signal_units: str = "arb. units", - ) -> Self: - if tilt_angles is not None: - validated_tilt_angles = ensure_valid_array(tilt_angles, ndim=1) - else: - validated_tilt_angles = None - - # array = np.transpose(array, axes=(2, 0, 1)) - - if z1_angles is not None: - validated_z1_angles = ensure_valid_array(z1_angles, ndim=1) - else: - validated_z1_angles = torch.zeros(len(validated_tilt_angles)) - - if z3_angles is not None: - validated_z3_angles = ensure_valid_array(z3_angles, ndim=1) - else: - validated_z3_angles = torch.zeros(len(validated_tilt_angles)) - - if shifts is not None: - validated_shifts = ensure_valid_array(shifts, ndim=2) - else: - validated_shifts = torch.zeros((len(validated_tilt_angles), 2)) - - array = torch.from_numpy(array) - - return cls( - array=array, - tilt_angles=validated_tilt_angles - if validated_tilt_angles is not None - else ["duck" for _ in range(array.shape[0])], - z1_angles=validated_z1_angles, - z3_angles=validated_z3_angles, - shifts=validated_shifts, - name=name if name is not None else "Tilt Series Dataset", - origin=origin if origin is not None else np.zeros(3), - sampling=sampling if sampling is not None else np.ones(3), - units=units if units is not None else ["index", "pixels", "pixels"], - signal_units=signal_units, - _token=cls._token, - ) - - # --- Properties --- - - @property - def tilt_angles(self) -> NDArray: - """Get the tilt angles of the dataset.""" - return self._tilt_angles - - @property - def tilt_angles_rad(self) -> NDArray: - """Get the tilt angles of the dataset in radians.""" - return np.deg2rad(self._tilt_angles) - - @tilt_angles.setter - def tilt_angles(self, angles: NDArray | list) -> None: - """Set the tilt angles of the dataset.""" - if len(angles) != self.shape[0]: - raise ValueError("Tilt angles must match the number of projections.") - - # Convert to numpy array if not already - if isinstance(self._tilt_angles, NDArray): - self._tilt_angles = np.array(angles) - else: - self._tilt_angles = angles - - @property - def z1_angles(self) -> NDArray: - """Get the z1 angles of the dataset.""" - return self._z1_angles - - @z1_angles.setter - def z1_angles(self, angles: NDArray | list) -> None: - """Set the z1 angles of the dataset.""" - if len(angles) != self.shape[0]: - raise ValueError("Z1 angles must match the number of projections.") - self._z1_angles = angles - - @property - def z3_angles(self) -> NDArray: - """Get the z3 angles of the dataset.""" - return self._z3_angles - - @z3_angles.setter - def z3_angles(self, angles: NDArray | list) -> None: - """Set the z3 angles of the dataset.""" - if len(angles) != self.shape[0]: - raise ValueError("Z3 angles must match the number of projections.") - self._z3_angles = angles - - @property - def shifts(self) -> NDArray: - """Get the shifts of the dataset.""" - return self._shifts - - @shifts.setter - def shifts(self, shifts: list[tuple[float, float]] | NDArray) -> None: - """Set the shifts of the dataset.""" - self._shifts = shifts diff --git a/src/quantem/tomography/tomography.py b/src/quantem/tomography/tomography.py index ef70d5f2..f86095ad 100644 --- a/src/quantem/tomography/tomography.py +++ b/src/quantem/tomography/tomography.py @@ -1,237 +1,637 @@ -import torch +import os +from pathlib import Path +from typing import Literal, Self, Sequence -# from torch_radon.radon import ParallelBeam as Radon +import matplotlib.pyplot as plt +import numpy as np +import torch +import torch.distributed as dist from tqdm.auto import tqdm -from quantem.tomography.object_models import ObjectVoxelwise +from quantem.core.io.serialize import load as autoserialize_load +from quantem.core.ml.loss_functions import get_loss_module +from quantem.core.ml.models.kplanes import CPTilted +from quantem.core.utils.filter import gaussian_filter_2d_stack, gaussian_kernel_1d +from quantem.core.utils.tomography_utils import torch_phase_cross_correlation +from quantem.tomography.dataset_models import ( + DatasetConstraintParams, + DatasetConstraintsType, + DatasetModelType, + TomographyINRDataset, + TomographyPixDataset, +) +from quantem.tomography.logger_tomography import LoggerTomography +from quantem.tomography.object_models import ( + ObjConstraintParams, + ObjConstraintsType, + ObjectINR, + ObjectPixelated, + ObjectTensorDecomp, +) +from quantem.tomography.radon.radon import iradon_torch, radon_torch from quantem.tomography.tomography_base import TomographyBase -from quantem.tomography.tomography_conv import TomographyConv -from quantem.tomography.tomography_ml import TomographyML -from quantem.tomography.utils import differentiable_shift_2d, gaussian_kernel_1d, rot_ZXZ +from quantem.tomography.tomography_context import ReconstructionContext +from quantem.tomography.tomography_opt import TomographyOpt -class Tomography(TomographyConv, TomographyML, TomographyBase): +class Tomography(TomographyOpt, TomographyBase): """ - Top level class for either using conventional or ML-based reconstruction methods - for tomography. + Class for handling all ML tomography reconstruction methods. + Automatic handling between AD and INR-based tomography. """ - def __init__( + @classmethod + def from_models( + cls, + dset: DatasetModelType, + obj_model: ObjectINR | ObjectTensorDecomp, + logger: LoggerTomography | None = None, + device: str = "cuda", + verbose: int | bool = True, + rng: np.random.Generator | int | None = None, + ) -> Self: + return cls( + dset=dset, + obj_model=obj_model, + logger=logger, + device=device, + rng=rng, + verbose=verbose, + _token=cls._token, + ) + + def reconstruct( self, - dataset, - volume_obj, - device, - _token, + num_iter: int = 10, + batch_size: int = 1024, + num_workers: int = 32, + reset: bool = False, + optimizer_params: dict | None = None, + scheduler_params: dict | None = None, + obj_constraints: dict | ObjConstraintsType | None = None, + dset_constraints: dict | DatasetConstraintsType | None = None, + num_samples_per_ray: int | list[tuple[int, int]] | None = None, + profiling_mode: bool = False, + val_fraction: float = 0.0, + loss_type: Literal[ + "l2", + "l1", + "smooth_l1", + "charbonnier", + "llmse", + "mse_log_mse", + ] = "l2", + loss_func_kwargs: dict = {}, + reset_dset: DatasetModelType | None = None, + show_metrics: bool = False, ): - super().__init__(dataset, volume_obj, device, _token) + """ + This function should be able to handle both AD and INR-based tomography reconstruction methods. + I.e, auto-detection through the obj model type, while both share the same pose optimization. + """ - # --- Reconstruction Method --- + # Check device consistency + self.obj_model.to(self.device) - def sirt_recon( - self, - num_iterations: int = 10, - inline_alignment: bool = False, - enforce_positivity: bool = True, - volume_shape: tuple = None, - reset: bool = True, - smoothing_sigma: float = None, - shrinkage: float = None, - filter_name: str = "hamming", - circle: bool = True, - plot_loss: bool = False, - ): - num_angles, num_rows, num_cols = self.dataset.tilt_series.shape - sirt_tilt_series = self.dataset.tilt_series.clone() - sirt_tilt_series = sirt_tilt_series.permute(2, 0, 1) - - hard_constraints = { - "positivity": enforce_positivity, - "shrinkage": shrinkage, - } - self.volume_obj.hard_constraints = hard_constraints - - if volume_shape is None: - volume_shape = (num_rows, num_rows, num_rows) - else: - D, H, W = volume_shape + # Saving batch size, num workers, and val fraction for reloading + self.batch_size = batch_size + self.num_workers = num_workers + self.val_fraction = val_fraction + + if profiling_mode: + if self.global_rank == 0: + print("Profiling mode enabled.") if reset: - self.volume_obj.reset() - self.loss = [] + raise NotImplementedError("Reset is not implemented yet.") - proj_forward = torch.zeros_like(self.dataset.tilt_series) + new_scheduler = reset + if optimizer_params is not None: + self.optimizer_params = optimizer_params + self.set_optimizers() + new_scheduler = True - pbar = tqdm(range(num_iterations), desc="SIRT Reconstruction") + if scheduler_params is not None: + self.scheduler_params = scheduler_params + new_scheduler = True - if smoothing_sigma is not None: - gaussian_kernel = gaussian_kernel_1d(smoothing_sigma).to(self.device) - else: - gaussian_kernel = None + if new_scheduler: + self.set_schedulers(self.scheduler_params, num_iter=num_iter) - for iter in pbar: - proj_forward, loss = self._sirt_run_epoch( - tilt_series=sirt_tilt_series, - proj_forward=proj_forward, - angles=self.dataset.tilt_angles, - inline_alignment=iter > 0 and inline_alignment, - filter_name=filter_name, - gaussian_kernel=gaussian_kernel, - circle=circle, + if obj_constraints is not None: + if isinstance(obj_constraints, dict): + obj_constraints = ObjConstraintParams.parse_dict(obj_constraints) + + self.obj_model.constraints = obj_constraints + + if dset_constraints is not None: + if isinstance(dset_constraints, dict): + dset_constraints = DatasetConstraintParams.parse_dict(dset_constraints) + + self.dset.constraints = dset_constraints + # Setting up DDP + if not hasattr(self, "dataloader") or reset_dset is not None: + if reset_dset is not None: + print("Resetting Dataloader") + print("Putting in params from previous dataset.") + + self.dset = reset_dset + self.dset.to(self.device) + + if optimizer_params is not None: + self.optimizer_params = optimizer_params + self.set_optimizers() + if scheduler_params is not None: + self.scheduler_params = scheduler_params + self.set_schedulers(self.scheduler_params, num_iter=num_iter) + + self.dataloader, self.sampler, self.val_dataloader, self.val_sampler = ( + self.setup_dataloader( + self.dset, + batch_size, + num_workers=num_workers, + val_fraction=val_fraction, + ) ) - pbar.set_description(f"SIRT Reconstruction | Loss: {loss.item():.4f}") + # Type check for INR-based reconstruction + if not isinstance(self.dset, TomographyINRDataset): + raise NotImplementedError( + "Only TomographyINRDataset is supported for this reconstruction method." + ) - self.loss.append(loss.item()) + N = max(self.obj_model.shape) - self.sirt_recon_vol = self.volume_obj + if num_samples_per_ray is None: + num_samples_per_ray = max(self.obj_model.shape) + else: + if isinstance(num_samples_per_ray, int): + num_samples_per_ray = num_samples_per_ray + else: + if len(num_samples_per_ray) != num_iter: + raise ValueError( + "num_samples_per_ray schedule must have the same length as num_iter" + ) + if self.global_rank == 0: + print("num_samples_per_ray schedule provided.") - # Permutation due to sinogram ordering. - self.sirt_recon_vol.obj = self.sirt_recon_vol.obj.permute(1, 2, 0) + loss_func = get_loss_module(name=loss_type, dtype=self.obj_model.dtype, **loss_func_kwargs) - if plot_loss: - self.plot_loss() + pbar = tqdm(range(num_iter), disable=not self.verbose) + for a0 in pbar: + consistency_loss = torch.tensor(0.0, device=self.device) + total_loss = torch.tensor(0.0, device=self.device) + epoch_soft_constraint_loss = torch.tensor(0.0, device=self.device) + if isinstance(self.obj_model, ObjectINR) or isinstance( + self.obj_model, ObjectTensorDecomp + ): + self.obj_model.model.train() + else: + raise NotImplementedError( + "AD Pixelated reconstruction is not yet implemented. Use ObjectINR instead." + ) + self.dset.train() + # self._reset_iter_constraints() + + if self.sampler is not None: + self.sampler.set_epoch(a0) + + if isinstance(num_samples_per_ray, list): + curr_num_samples_per_ray = num_samples_per_ray[a0][1] + else: + curr_num_samples_per_ray = num_samples_per_ray + + for batch_idx, batch in enumerate(self.dataloader): + self.zero_grad_all() + with torch.autocast( + device_type=self.device.type, + dtype=torch.bfloat16, + enabled=False, + ): + all_coords = self.dset.get_coords(batch, N, curr_num_samples_per_ray) + + all_densities = self.obj_model.forward(all_coords) + + integrated_densities = self.dset.integrate_rays( + all_densities, + curr_num_samples_per_ray, + len(batch["target_value"]), + ) - def ad_recon( - self, - optimizer_params: dict, - num_iter: int = 0, - reset: bool = False, - scheduler_params: dict | None = None, - hard_constraints: dict | None = None, - soft_constraints: dict | None = None, - # store_iterations: bool | None = None, - # store_iterations_every: int | None = None, - # autograd: bool = True, - ): - if reset: - self.reset_recon() + pred = integrated_densities.float() - self.hard_constraints = hard_constraints - self.soft_constraints = soft_constraints + soft_constraints_loss = self.obj_model.apply_soft_constraints( + ctx=ReconstructionContext( + coords=all_coords, + pred=pred, + all_densities=all_densities, + ) + ) - # Make sure everything is in the correct device, might be redundant/cleaner way to do this - self.dataset.to(self.device) - self.volume_obj.to(self.device) + target = batch["target_value"].to(self.device, non_blocking=True).float() + + batch_consistency_loss = loss_func(pred, target) + + soft_constraints_loss += self.dset.apply_soft_constraints() + + epoch_soft_constraint_loss += soft_constraints_loss.detach() + + batch_loss = batch_consistency_loss.float() + soft_constraints_loss.float() + + batch_loss.backward() + # Clip gradients + torch.nn.utils.clip_grad_norm_(self.obj_model.model.parameters(), max_norm=1.0) + self.step_optimizers() + total_loss += batch_loss.detach() + consistency_loss += batch_consistency_loss.detach() + + if isinstance(self.obj_model.model, CPTilted): + if a0 == 0: + prev_R = self.obj_model.model.so3.as_matrix().detach().clone() + elif (a0 + 1) % 20 == 0: + R_now = self.obj_model.model.so3.as_matrix().detach() + # Cumulative angular change per rotation over the last 20 iters. + # trace(R_prev^T R_now) = 1 + 2*cos(theta), so theta = acos((trace - 1) / 2). + rel_trace = torch.einsum("tij,tij->t", prev_R, R_now) + angle = torch.acos(((rel_trace - 1) / 2).clamp(-1, 1)) # (T,) radians + angle_deg = torch.rad2deg(angle) + per_tau_str = ", ".join(f"{a:.2f}°" for a in angle_deg.tolist()) + print( + f"iter {a0}: 20-iter τ change " + f"max={angle_deg.max().item():.2f}°, " + f"mean={angle_deg.mean().item():.2f}°, " + f"per-τ=[{per_tau_str}]" + ) + prev_R = R_now.clone() + + if self.world_size > 1: + dist.all_reduce(total_loss, dist.ReduceOp.AVG) + dist.all_reduce(consistency_loss, dist.ReduceOp.AVG) + dist.all_reduce(epoch_soft_constraint_loss, dist.ReduceOp.AVG) + + total_loss = total_loss.item() / len(self.dataloader) + consistency_loss = consistency_loss.item() / len(self.dataloader) + epoch_soft_constraint_loss = epoch_soft_constraint_loss.item() / len(self.dataloader) + + self.step_schedulers(loss=total_loss) + # TODO: Maybe reorganize the losses so that the order makes sense lol. + + avg_val_loss = None + if self.val_dataloader is not None: + print("Validating...") + self.obj_model.model.eval() + self.dset.eval() + with torch.no_grad(): + val_loss = torch.tensor(0.0, device=self.device) + + for batch in self.val_dataloader: + with torch.autocast( + device_type=self.device.type, + dtype=torch.bfloat16, + enabled=True, + ): + all_coords = self.dset.get_coords(batch, N, curr_num_samples_per_ray) + + all_densities = self.obj_model.forward(all_coords) + + integrated_densities = self.dset.integrate_rays( + all_densities, + curr_num_samples_per_ray, + len(batch["target_value"]), + ) + + target = ( + batch["target_value"].to(self.device, non_blocking=True).float() + ) + + batch_val_loss = torch.nn.functional.mse_loss( + integrated_densities, target + ) + + val_loss += batch_val_loss.detach() + + avg_val_loss = val_loss.item() / len(self.val_dataloader) + + metrics = torch.tensor( + [total_loss, consistency_loss, epoch_soft_constraint_loss], device=self.device + ) + + if self.world_size > 1: + dist.all_reduce(metrics, dist.ReduceOp.AVG) + + total_loss, consistency_loss, epoch_soft_constraint_loss = metrics.tolist() + + pbar.set_description( + f"Reconstruction | Loss: {total_loss:.5e}, Consistency Loss: {consistency_loss:.5e}, Soft Constraint Loss: {epoch_soft_constraint_loss:.5e}" + ) + + self._epoch_losses.append(total_loss) + self._consistency_losses.append(consistency_loss) + self.append_learning_rates(self.get_current_lrs()) + self.obj_model._soft_constraint_losses.append(epoch_soft_constraint_loss) + if avg_val_loss is not None: + self._val_losses.append(avg_val_loss) - # Making optimizable parameters into leaf tensors. - self.dataset.shifts = self.dataset.shifts.detach().to(self.device).requires_grad_(True) - self.dataset.z1_angles = ( - self.dataset.z1_angles.detach().to(self.device).requires_grad_(True) + if self.logger is not None: + if ( + self.logger.log_images_every > 0 + and self.num_epochs % self.logger.log_images_every == 0 + ): + pred_full = self.obj_model.obj_view + + if self.global_rank == 0: + self.logger.log_iter_images( + pred_volume=pred_full, + dataset_model=self.dset, + iter=self.num_epochs, + ) + pbar.set_description( + f"Reconstruction | Loss: {total_loss:.5e}, Consistency Loss: {consistency_loss:.5e}, Soft Constraint Loss: {epoch_soft_constraint_loss:.5e} | Images Logged" + ) + + if self.global_rank == 0: + self.logger.log_iter( + object_model=self.obj_model, + iter=self.num_epochs, + consistency_loss=consistency_loss, + total_loss=total_loss, + learning_rates=self.get_current_lrs(), + num_samples_per_ray=curr_num_samples_per_ray, + val_loss=avg_val_loss if self.val_dataloader is not None else None, + ) + + self.logger.flush() + if not self.verbose: + if self.global_rank == 0: + print( + f"Reconstruction Epoch {self.num_epochs} | Loss: {total_loss:.5e}, Consistency Loss: {consistency_loss:.5e}, Soft Constraint Loss: {epoch_soft_constraint_loss:.5e}" + ) + if show_metrics and self.world_size == 1: + self.plot_losses() + + # --- Helper Functions --- + + def save_volume(self, path: str = "recon_volume.npz", overwrite: bool = False): + """ + Saves volume to a numpy array file. Does not save the full Tomography object. + """ + if self.global_rank == 0: + if not overwrite and os.path.exists(path): + raise FileExistsError( + f"File {path} already exists. Use overwrite=True to overwrite." + ) + print(f"Saving volume to {path}") + np.savez(path, volume=self.obj_model.obj_view) + + if torch.distributed.is_initialized(): + print("Barrier") + torch.distributed.barrier() + + # Loading and Saving + @classmethod + def _recursive_load_from_path(cls, path: str): + return autoserialize_load(path) + + @classmethod + def from_file( + cls, + path: str, + device: str = "cpu", + ) -> Self: + tomography = cls._recursive_load_from_path(path) + tomography.to(device) + tomography._rebuild_dataloader( + batch_size=tomography.batch_size, + num_workers=tomography.num_workers, + val_fraction=tomography.val_fraction, ) - self.dataset.z3_angles = ( - self.dataset.z3_angles.detach().to(self.device).requires_grad_(True) + return tomography + + def _rebuild_dataloader(self, batch_size: int, num_workers: int, val_fraction: float): + """ + Rebuilds the dataloader due to persistent workers error when reloading the object. + """ + self.dataloader, self.sampler, self.val_dataloader, self.val_sampler = ( + self.setup_dataloader( + self.dset, + batch_size, + num_workers=num_workers, + val_fraction=val_fraction, + ) ) - if optimizer_params is not None: - self.optimizer_params = optimizer_params - self.set_optimizers() + def save( + self, + path: str | Path, + mode: Literal["w", "o"] = "w", + store: Literal["auto", "zip", "dir"] = "auto", + skip: str | type | Sequence[str | type] = ["dataloader"], + compression_level: int | None = 4, + ) -> None: + super(Tomography, self).save( + path=path, + mode=mode, + store=store, + skip=skip, + compression_level=compression_level, + ) - if scheduler_params is not None: - self.scheduler_params = scheduler_params - self.set_schedulers(self.scheduler_params, num_iter=num_iter) + def plot_losses(self): + fig, ax = plt.subplots(figsize=(10, 4), ncols=2) - if hard_constraints is not None: - self.volume_obj.hard_constraints = hard_constraints - if soft_constraints is not None: - self.volume_obj.soft_constraints = soft_constraints + ax[0].plot(self._epoch_losses, label="Total Training Loss") + if len(self._val_losses) > 0: + ax[0].plot(self._val_losses, label="Validation Loss") + ax[0].legend() - pbar = tqdm(range(num_iter), desc="AD Reconstruction") + for key, value in self._lrs.items(): + ax[1].plot(value, label=key) - for a0 in pbar: - total_loss = 0.0 - tilt_series_loss = 0.0 - - pred_volume = self.volume_obj.forward() - - for i in range(len(self.dataset.tilt_series)): - forward_projection = self.projection_operator( - vol=pred_volume, - z1=self.dataset.z1_angles[i], - x=self.dataset.tilt_angles[i], - z3=self.dataset.z3_angles[i], - shift_x=self.dataset.shifts[i, 0], - shift_y=self.dataset.shifts[i, 1], - device=self.device, - ) + ax[1].legend() + ax[0].legend() + ax[0].set_yscale("log") + ax[1].set_yscale("log") + ax[0].set_xlabel("Epoch") + ax[1].set_xlabel("Epoch") + ax[0].set_ylabel("Loss") + ax[1].set_ylabel("Learning Rate") - tilt_series_loss += torch.nn.functional.mse_loss( - forward_projection, self.dataset.tilt_series[i] - ) - tilt_series_loss /= len(self.dataset.tilt_series) - total_loss = tilt_series_loss + self.volume_obj.soft_loss - self.loss.append(total_loss.item()) +class TomographyConventional(TomographyBase): + """ + Class for handling all conventional tomography reconstruction methods. + Will also handle choosing the appropriate dataset model to use. + """ - total_loss.backward() + @classmethod + def from_models( + cls, + dset: TomographyPixDataset, + obj_model: ObjectPixelated, + logger: LoggerTomography | None = None, + device: str = "cuda", + verbose: int | bool = True, + rng: np.random.Generator | int | None = None, + ) -> Self: + return cls( + dset=dset, + obj_model=obj_model, + logger=logger, + device=device, + rng=rng, + verbose=verbose, + _token=cls._token, + ) - for opt in self.optimizers.values(): - opt.step() - opt.zero_grad() + def reconstruct( + self, + num_iter: int = 10, + obj_constraints: dict | ObjConstraintsType | None = None, + mode: Literal["sirt", "fbp"] = "sirt", + relaxation: float = 0.25, + reset: bool = False, + inline_alignment: bool = False, + smoothing_sigma: float | None = None, + show_metrics: bool = False, + ): + if obj_constraints is not None: + if isinstance(obj_constraints, dict): + obj_constraints = ObjConstraintParams.parse_dict(obj_constraints) - if self.schedulers is not None: - for sch in self.schedulers.values(): - if isinstance(sch, torch.optim.lr_scheduler.ReduceLROnPlateau): - sch.step(total_loss) - elif sch is not None: - sch.step() + self.obj_model.constraints = obj_constraints - pbar.set_description(f"AD Reconstruction | Loss: {total_loss:.4f}") + pbar = tqdm( + range(num_iter), + desc=f"{mode} Reconstruction | Loss: {0:.4f}", + disable=not self.verbose, + ) + if mode == "sirt" or mode == "fbp": + proj_forward = torch.zeros_like(self.dset.tilt_stack).permute(2, 0, 1) + else: + proj_forward = torch.zeros_like(self.dset.tilt_stack) - if self.logger is not None: - self.logger.log_scalar("loss/total", total_loss.item(), a0) - self.logger.log_scalar("loss/tilt_series", tilt_series_loss.item(), a0) - self.logger.log_scalar( - "loss/soft constraints", self.volume_obj.soft_loss.item(), a0 - ) + if smoothing_sigma is not None: + gaussian_kernel = gaussian_kernel_1d(smoothing_sigma).to(self.device) + else: + gaussian_kernel = None - if a0 % self.logger.log_images_every == 0: - self.logger.projection_images( - volume_obj=self.volume_obj, - epoch=a0, - ) - self.logger.tilt_angles_figure(dataset=self.dataset, step=a0) + patience = self.dset.tilt_angles.max() // 10 + for iter in pbar: + proj_forward, loss = self._reconstruction_epoch( + inline_alignment=inline_alignment, + mode=mode, + proj_forward=proj_forward, + gaussian_kernel=gaussian_kernel, + relaxation=relaxation, + ) - self.logger.flush() + pbar.set_description(f"{mode} Reconstruction | Loss: {loss.item():.4f}") - self.ad_recon_vol = self.volume_obj.forward() + self._epoch_losses.append(loss.item()) - return self + # Change relaxation parameter if loss greater than last epoch + if len(self._epoch_losses) > 1 and self._epoch_losses[-1] > self._epoch_losses[-2]: + if patience == 0: + relaxation *= 0.85 + print(f"Relaxation parameter changed to: {relaxation}") + patience = 10 + else: + patience -= 1 - def reset_recon(self) -> None: - if isinstance(self.volume_obj, ObjectVoxelwise): - self.volume_obj.reset() + if mode == "fbp": + break - self.ad_recon_vol = None + if show_metrics: + self.plot_losses() - # --- Projection Operators ---- - def projection_operator( + # --- Conventional reconstruction method --- + def _adaptive_relaxation(self, n_power_iter: int = 10) -> float: + raise NotImplementedError( + "Adaptive relaxation hasn't been implemented, please input a valid relaxation parameter." + ) + + def _reconstruction_epoch( self, - vol, - z1, - x, - z3, - shift_x, - shift_y, - device, + inline_alignment: bool, + mode: Literal["sirt", "fbp"], + proj_forward: torch.Tensor, + relaxation: float, + gaussian_kernel: torch.Tensor | None = None, ): - projection = ( - rot_ZXZ( - mags=vol.unsqueeze(0), # Add batch dimension - z1=z1, - x=-x, - z3=z3, - device=device, - mode="bilinear", + loss = 0 + + if relaxation == 0.0: + relaxation = self._adaptive_relaxation() + print(f"Adaptive relaxation: {relaxation}") + if inline_alignment: + for ind in range(len(self.dset.tilt_angles)): + im_proj = proj_forward[:, ind, :] + im_meas = self.dset.forward(ind).target # type: ignore + shift = torch_phase_cross_correlation(im_proj, im_meas) + if torch.linalg.norm(shift) <= 32: + shifted = torch.fft.ifft2( + torch.fft.fft2(im_meas) + * torch.exp( + -2j + * np.pi + * ( + shift[0] + * torch.fft.fftfreq( + im_meas.shape[0], device=im_meas.device + ).unsqueeze(1) + + shift[1] + * torch.fft.fftfreq(im_meas.shape[1], device=im_meas.device) + ) + ) + ).real + + proj_forward[:, ind, :] = shifted + + if mode == "sirt" or mode == "fbp": + proj_forward = radon_torch( + self.obj_model.obj, + theta=self.dset.tilt_angles, + device=self.device, ) - .squeeze() - .sum(axis=0) - ) - shifted_projection = differentiable_shift_2d( - image=projection, - shift_x=shift_x, - shift_y=shift_y, - sampling_rate=1.0, # Assuming 1 pixel = 1 physical unit - ) + error = self.dset.tilt_stack.permute(2, 0, 1) - proj_forward + + correction = iradon_torch( + error, + theta=self.dset.tilt_angles, + device=self.device, + filter_name="ramp", + circle=True, + ) + + normalization = iradon_torch( + torch.ones_like(error), + theta=self.dset.tilt_angles, + device=self.device, + circle=True, + filter_name=None, + ) + + normalization[normalization == 0] = 1e-6 + + correction /= normalization + + self.obj_model.obj += correction * relaxation + + if gaussian_kernel is not None: + self.obj_model.obj = gaussian_filter_2d_stack(self.obj_model.obj, gaussian_kernel) + + loss = torch.mean(torch.abs(error)) + + return proj_forward, loss + + # --- Helper Functions --- - return shifted_projection + def plot_losses(self): + fig, ax = plt.subplots() + ax.plot(self._epoch_losses) + ax.set_xlabel("Iteration") + ax.set_ylabel("Loss") + ax.set_title("Reconstruction Loss") + ax.set_yscale("log") + plt.show() diff --git a/src/quantem/tomography/tomography_base.py b/src/quantem/tomography/tomography_base.py index 0d937020..419b5ede 100644 --- a/src/quantem/tomography/tomography_base.py +++ b/src/quantem/tomography/tomography_base.py @@ -1,499 +1,156 @@ -import matplotlib.pyplot as plt import numpy as np -import torch from numpy.typing import NDArray -from torch._tensor import Tensor -from tqdm.auto import tqdm -from quantem.core.datastructures.dataset3d import Dataset3d from quantem.core.io.serialize import AutoSerialize -from quantem.core.visualization.visualization import show_2d -from quantem.imaging.drift import cross_correlation_shift -from quantem.tomography.object_models import ObjectModelType, ObjectVoxelwise -from quantem.tomography.tomography_dataset import TomographyDataset -from quantem.tomography.tomography_logger import LoggerTomography +from quantem.core.ml.ddp import DDPMixin +from quantem.core.utils.rng import RNGMixin +from quantem.tomography.dataset_models import DatasetModelType, TomographyDatasetBase +from quantem.tomography.logger_tomography import LoggerTomography +from quantem.tomography.object_models import ( + ObjConstraintParams, + ObjConstraintsType, + ObjectINR, + ObjectModelType, + ObjectTensorDecomp, +) + + +class TomographyBase(AutoSerialize, RNGMixin, DDPMixin): + """ + A base class for performing electron tomography reconstructions. + Should have all the default attributes needed for tomography reconstructions. + """ -class TomographyBase(AutoSerialize): _token = object() - DEFAULT_HARD_CONSTRAINTS = { - "positivity": False, - "shrinkage": False, - "circular_mask": False, - "fourier_filter": False, - } - - DEFAULT_SOFT_CONSTRAINTS = { - "tv_vol": 0.0, - } - def __init__( self, - dataset: TomographyDataset, - volume_obj: ObjectModelType, # ObjectDIP? - device: str = "cuda", - # ABF/HAADF property + dset: DatasetModelType, + obj_model: ObjectModelType, logger: LoggerTomography | None = None, + device: str = "cuda", + rng: np.random.Generator | int | None = None, + verbose: int | bool = True, _token: object | None = None, ): - """Initialize a Tomography object. - - Parameters - ---------- - array : NDArray | Any - The underlying 3D array data - name : str - A descriptive name for the dataset - """ - - # if _token is not self._token: - # raise RuntimeError( - # "This class is not meant to be instantiated directly. Use the from_data method." - # ) - - self._device = device - self._dataset = dataset - self._volume_obj = volume_obj - self._loss = [] - self._mode = [] - - self._hard_constraints = self.DEFAULT_HARD_CONSTRAINTS.copy() - self._soft_constraints = self.DEFAULT_SOFT_CONSTRAINTS.copy() - - self._logger = None - - @classmethod - def from_data( - cls, - tilt_series: Dataset3d | NDArray | Tensor, - tilt_angles: NDArray | Tensor, - z1_angles: NDArray | Tensor | None = None, - z3_angles: NDArray | Tensor | None = None, - shifts: NDArray | Tensor | None = None, - volume_obj: NDArray | Dataset3d | ObjectModelType | None = None, - device: str = "cpu", - ): - device = device.lower() - - dataset = TomographyDataset.from_data( - tilt_series=tilt_series, - tilt_angles=tilt_angles, - z1_angles=z1_angles, - z3_angles=z3_angles, - shifts=shifts, - ) - - dataset.to(device) - - if volume_obj is None: - max_shape = max(dataset.tilt_series.shape) - volume_obj = ObjectVoxelwise( - volume_shape=(max_shape, max_shape, max_shape), - device=device, - ) - elif isinstance(volume_obj, Dataset3d): - volume = torch.from_numpy(volume_obj.array) - volume_obj = ObjectVoxelwise( - volume_shape=volume_obj.shape, device=device, initial_volume=volume - ) - volume_obj.obj = volume - elif isinstance(volume_obj, np.ndarray): - volume = torch.from_numpy(volume_obj) - volume_obj = ObjectVoxelwise( - volume_shape=volume_obj.shape, device=device, initial_volume=volume - ) - volume_obj.obj = volume - else: - raise ValueError("volume_obj must be a Dataset3d, NDArray ObjectModelType") - - return cls( - dataset=dataset, - volume_obj=volume_obj, - device=device, - _token=cls._token, - ) + if _token is not self._token: + raise RuntimeError("Use .from_* to instantiate this class.") + + super().__init__() + self.obj_model = obj_model + + self.dset = dset + self.verbose = verbose + self.rng = rng + self.device = device + self.logger = logger + + # Loss + self._epoch_losses: list[float] = [] + self._consistency_losses: list[float] = [] + self._val_losses: list[float] = [] + self._lrs: dict[str, list] = {} + # DDP Initialization + if isinstance(obj_model, ObjectINR) or isinstance(obj_model, ObjectTensorDecomp): + self.setup_distributed(device=device) + if self.global_rank == 0: + print("Setting up DDP for obj_model") + + self.dset = dset + self.dset.to(device) # --- Properties --- @property - def dataset(self) -> TomographyDataset: - """Tomography dataset.""" + def dset(self) -> DatasetModelType: + return self._dset - return self._dataset - - @dataset.setter - def dataset( - self, - tilt_series: Dataset3d | NDArray | TomographyDataset, - tilt_angles: NDArray | Tensor, - z1_angles: NDArray | Tensor | None = None, - z3_angles: NDArray | Tensor | None = None, - shifts: NDArray | Tensor | None = None, - # name: str | None = None, - # origin: NDArray | tuple | list | float | int | None = None, - # sampling: NDArray | tuple | list | float | int | None = None, - # units: list[str] | tuple | list | None = None, - # signal_units: str = "arb. units", - ): - """Set the tilt series dataset.""" - - if not isinstance(tilt_series, TomographyDataset): - dataset = TomographyDataset.from_array( - array=tilt_series, - tilt_angles=tilt_angles, - z1_angles=z1_angles, - z3_angles=z3_angles, - shifts=shifts, - ) - - self._dataset = dataset + @dset.setter + def dset(self, new_dset: DatasetModelType): + if not isinstance(new_dset, TomographyDatasetBase) and "TomographyDataset" not in str( + type(new_dset) + ): + raise TypeError(f"dset should be a TomographyDataset, got {type(new_dset)}") + self._dset = new_dset @property - def volume_obj(self) -> Dataset3d | ObjectModelType | None: - """Reconstruction volume dataset.""" + def verbose(self) -> int | bool: + return self._verbose - return self._volume_obj - - @volume_obj.setter - # TODO: add support for ObjectModelType - def volume_obj(self, volume_obj: Dataset3d | NDArray): - """Set the reconstruction volume dataset.""" - if isinstance(volume_obj, ObjectModelType): - self._volume_obj = volume_obj - elif not isinstance(volume_obj, Dataset3d): - volume_obj = Dataset3d.from_array( - array=volume_obj, - # name=self._tilt_series.name, - # origin=self._tilt_series.origin, - # sampling=self._tilt_series.sampling, - # units=self._tilt_series.units, - # signal_units=self._tilt_series.signal_units, - ) - elif isinstance(volume_obj, Dataset3d): - self._volume_obj = volume_obj - else: - raise ValueError("volume_obj must be a Dataset3d or ObjectModelType") - - @property - def device(self) -> str: - """Computation device.""" - - return self._device - - @device.setter - def device(self, device: str): - """Set the computation device.""" - - # if "cuda" not in device or "gpu" not in device: - # raise NotImplementedError("Tomography not currently supported on CPU.") - - self._device = device - - @property - def loss(self) -> list: - """List of loss values during reconstruction.""" - - return self._loss - - @loss.setter - def loss(self, loss: list): - """Set the loss values during reconstruction.""" - - if not isinstance(loss, list): - raise TypeError("Loss must be a list.") - - self._loss = loss + @verbose.setter + def verbose(self, verbose: int | bool): + self._verbose = verbose @property - def mode(self) -> list: - """List of modes used during reconstruction.""" + def obj_model(self) -> ObjectModelType: + return self._obj_model - return self._mode + @obj_model.setter + def obj_model(self, obj_model: ObjectModelType): + self._obj_model = obj_model @property - def epochs(self) -> int: - """Number of epochs used during reconstruction.""" - return len(self.loss) + def constraints(self) -> ObjConstraintsType: # TODO: Also looks at Dataset constraints + return self.obj_model.constraints + + @constraints.setter + def constraints(self, constraints: ObjConstraintsType | dict | None): + if constraints is None: + return + elif isinstance(constraints, dict): + self.obj_model.constraints = ObjConstraintParams.parse_dict(constraints) + elif isinstance(constraints, ObjConstraintsType): + self.obj_model.constraints = constraints + else: + raise ValueError(f"Invalid constraints type: {type(constraints)}") @property - def logger(self) -> LoggerTomography: + def logger(self) -> LoggerTomography | None: return self._logger @logger.setter - def logger(self, logger: LoggerTomography): - if not isinstance(logger, LoggerTomography): - raise TypeError("Logger must be a LoggerTomography") - + def logger(self, logger: LoggerTomography | None): + if not isinstance(logger, LoggerTomography) and logger is not None: + raise TypeError(f"logger should be a LoggerTomography, got {type(logger)}") self._logger = logger - # --- Constraints --- - @property - def hard_constraints(self) -> dict: - """Hard constraints for the reconstruction.""" - return self._hard_constraints - - @hard_constraints.setter - def hard_constraints(self, hard_constraints: dict): - """Set the hard constraints for the reconstruction.""" - - gkeys = self.DEFAULT_HARD_CONSTRAINTS.keys() - for key, value in hard_constraints.items(): - if key not in gkeys: - raise KeyError(f"Invalid object constraint key '{key}', allowed keys are {gkeys}") - self._hard_constraints[key] = value - - self._hard_constraints = hard_constraints + def epoch_losses(self) -> NDArray: + """ + Returns the fidelity loss for each epoch ran. + """ + return np.array(self._epoch_losses) @property - def soft_constraints(self) -> dict: - """Soft constraints for the reconstruction.""" - return self._soft_constraints - - @soft_constraints.setter - def soft_constraints(self, soft_constraints: dict): - """Set the soft constraints for the reconstruction.""" - - gkeys = self.DEFAULT_SOFT_CONSTRAINTS.keys() - for key, value in soft_constraints.items(): - if key not in gkeys: - raise KeyError(f"Invalid object constraint key '{key}', allowed keys are {gkeys}") - self._soft_constraints[key] = value - - self._soft_constraints = soft_constraints - - # --- RESET --- - - def reset_recon(self) -> None: - self.volume_obj.reset() - self.dataset.reset() - self.loss = [] - self.hard_constraints = self.DEFAULT_HARD_CONSTRAINTS.copy() - self.soft_constraints = self.DEFAULT_SOFT_CONSTRAINTS.copy() - - self._optimizers = {} - self._schedulers = {} - - # --- Preprocessing --- - - """ - TODO - 1. Implement tilt series cross-correlation alignment - 2. Background subtraction (for ABF) - 3. COM Alignment - 4. Masking - 5. Drift Correction - """ - - def cross_corr_alignment( - self, - upsample_factor: int = 1, - overwrite: bool = False, - ): - # TODO: This needs to be able to work with torch tensors. - - placeholder_tilt_series = self.dataset.tilt_series.clone().detach().cpu().numpy() - - aligned_tilt_series = np.zeros_like(placeholder_tilt_series) - aligned_tilt_series[0] = placeholder_tilt_series[0] - shifts = [] - num_imgs = placeholder_tilt_series.shape[1] - - pbar = tqdm(range(num_imgs - 1), desc="Cross-correlation alignment") - - for i in pbar: - shift, aligned_img = cross_correlation_shift( - placeholder_tilt_series[i], - placeholder_tilt_series[i + 1], - upsample_factor=upsample_factor, - return_shifted_image=True, - ) - - aligned_tilt_series[i + 1] = aligned_img - shifts.append(shift) - - if overwrite: - # TODO: Check this overwrite idea, maybe also need to save the relative shifts? - self.dataset.tilt_series = np.array(aligned_tilt_series) - - return np.array(aligned_tilt_series), np.array(shifts) - - # --- Postprocessing --- - - """ - TODO - 1. Apply circular mask - """ - - def circular_mask(self, shape, radius, center=None, dtype=torch.float32, device="cpu"): - """Generate a 2D circular mask of given shape and radius.""" - H, W = shape - - if center is None: - center = (H // 2, W // 2) - y = torch.arange(H, dtype=dtype, device=device).view(-1, 1) - x = torch.arange(W, dtype=dtype, device=device).view(1, -1) - dist_sq = (x - center[1]) ** 2 + (y - center[0]) ** 2 - return (dist_sq <= radius**2).to(dtype) - - def recon_vol_circular_mask(self, radii): + def consistency_losses(self) -> NDArray: """ - Apply 2D circular masks along all three axes of a 3D volume. - - Args: - volume (torch.Tensor): 3D tensor of shape (H, W, D) - radii (tuple): (r0, r1, r2) for axes 0, 1, 2 - Returns: - masked_volume: tensor with all masks applied + Returns the consistency loss for each epoch ran. """ - H, W, D = self.volume_obj.array.shape - device = self.device - dtype = torch.float32 - volume_obj = torch.tensor( - self.volume_obj.array, - device=self.device, - dtype=dtype, - ) - # Masks for each axis - mask0 = self.circular_mask((W, D), radii[0], dtype=dtype, device=device).unsqueeze( - 0 - ) # shape (1, W, D) - mask1 = self.circular_mask((H, D), radii[1], dtype=dtype, device=device).unsqueeze( - 1 - ) # shape (H, 1, D) - mask2 = self.circular_mask((H, W), radii[2], dtype=dtype, device=device).unsqueeze( - 2 - ) # shape (H, W, 1) - - # Broadcast and multiply all masks together - total_mask = mask0 * mask1 * mask2 # shape (H, W, D) - - volume_obj = volume_obj * total_mask - volume_obj = volume_obj.detach().cpu().numpy() - self.volume_obj = Dataset3d.from_array( - array=volume_obj, - # name=self.volume_obj.name, - # origin=self.volume_obj.origin, - # sampling=self.volume_obj.sampling, - # units=self.volume_obj.units, - # signal_units=self.volume_obj.signal_units, - ) + return np.array(self._consistency_losses) - # --- Visualizations --- - - def plot_projections( - self, - cmap: str = "turbo", - fft: bool = False, - norm: str = "log_auto", - figax: tuple[plt.Figure, plt.Axes] | None = None, - **kwargs, - ): + @property + def learning_rates(self) -> dict[str, list]: """ - Plots the projections of the volume object. - Note that the volume object is in the order of (z, y, x). - Parameters - ---------- - cmap : str - The colormap to use for the projections. - fft : bool + Returns the learning rates for each epoch ran. """ + return self._lrs - volume_obj_np = self.volume_obj.obj.detach().cpu().numpy() - - if figax is None: - fig, ax = plt.subplots(ncols=3, figsize=(20, 8)) - else: - fig, ax = figax - - show_2d( - volume_obj_np.sum(axis=0), - figax=(fig, ax[0]), - cmap=cmap, - title="Y-X Projection", - ) - show_2d( - volume_obj_np.sum(axis=1), - figax=(fig, ax[1]), - cmap=cmap, - title="Z-X Projection", - ) - show_2d( - volume_obj_np.sum(axis=2), - figax=(fig, ax[2]), - cmap=cmap, - title="Z-Y Projection", - ) - - if fft: - fig, ax = plt.subplots(ncols=3, figsize=(25, 8)) - - show_2d( - np.abs(np.fft.fftshift(np.fft.fftn(volume_obj_np.sum(axis=0)))), - figax=(fig, ax[0]), - cmap=cmap, - title="Y-X Projection FFT", - norm=norm, - ) - - show_2d( - np.abs(np.fft.fftshift(np.fft.fftn(volume_obj_np.sum(axis=1)))), - figax=(fig, ax[1]), - cmap=cmap, - title="Z-X Projection FFT", - norm=norm, - ) - show_2d( - np.abs(np.fft.fftshift(np.fft.fftn(volume_obj_np.sum(axis=2)))), - figax=(fig, ax[2]), - cmap=cmap, - title="Z-Y Projection FFT", - norm=norm, - ) - - def plot_slice( - self, - cmap="turbo", - slice_index: int = 0, - vmin: float = 0, - figax: tuple[plt.Figure, plt.Axes] | None = None, - ): - if figax is None: - fig, ax = plt.subplots(figsize=(15, 8), ncols=3) - else: - fig, ax = figax - - show_2d( - self.volume_obj.obj[slice_index, :, :], - figax=(fig, ax[0]), - cmap=cmap, - vmin=vmin, - ) - show_2d( - self.volume_obj.obj[:, slice_index, :], - figax=(fig, ax[1]), - cmap=cmap, - vmin=vmin, - ) + def append_learning_rates(self, learning_rates: dict[str, float]): + """ + Appends the learning rates for each epoch ran. + """ + for key, value in learning_rates.items(): + if key not in self._lrs: + self._lrs[key] = [] + self._lrs[key].append(float(value)) - show_2d( - self.volume_obj.obj[:, :, slice_index], - figax=(fig, ax[2]), - cmap=cmap, - vmin=vmin, - ) + @property + def num_epochs(self) -> int: + return len(self._epoch_losses) - def plot_loss( - self, - figsize: tuple = (8, 4), - figax: tuple[plt.Figure, plt.Axes] | None = None, - ): - if figax is None: - fig, ax = plt.subplots(figsize=figsize) - else: - fig, ax = figax + # --- Helper Functions --- - ax.semilogy( - self.loss, - label="Loss", - ) + def to(self, device: str): + self.obj_model.to(device) + self.dset.to(device) + self.device = device diff --git a/src/quantem/tomography/tomography_context.py b/src/quantem/tomography/tomography_context.py new file mode 100644 index 00000000..d322287c --- /dev/null +++ b/src/quantem/tomography/tomography_context.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import Optional +from quantem.core.ml.constraints import BaseContext + +import torch + + +@dataclass +class ReconstructionContext(BaseContext): + """ + Handles all reconstruction parameters to be passed into object models. + + Subclasses will pick whatever parameter they need + - Pixelated reads ".volume" + - INR reads ".coords" and recomputes via the model. + - TensorDecomp reads ".coords" and ".pred" (and ".all densities") + + Variable descriptions: + - volume: Reconstructed object (volume). + - coords: Used for INR reconstructions to provide the coordinates to the model. + - pred: Predicted values per coordinate position from the model. + - all_densities: Integrated densities per ray from the model. + - obj: Object model (INR, TensorDecomp, etc.). + """ + + volume: Optional[torch.Tensor] = None + coords: Optional[torch.Tensor] = None + pred: Optional[torch.Tensor] = None + all_densities: Optional[torch.Tensor] = None + obj: Optional[torch.Tensor] = None diff --git a/src/quantem/tomography/tomography_conv.py b/src/quantem/tomography/tomography_conv.py deleted file mode 100644 index 1056da17..00000000 --- a/src/quantem/tomography/tomography_conv.py +++ /dev/null @@ -1,235 +0,0 @@ -import numpy as np -import torch - -# from torch_radon.radon import ParallelBeam as Radon -from quantem.tomography.radon.radon import iradon_torch, radon_torch -from quantem.tomography.tomography_base import TomographyBase -from quantem.tomography.utils import gaussian_filter_2d_stack, torch_phase_cross_correlation - - -class TomographyConv(TomographyBase): - """ - Class for handling conventional reconstruction methods of tomography data. - """ - - # --- Reconstruction Methods --- - - def _sirt_run_epoch( - self, - tilt_series: torch.Tensor, - proj_forward: torch.Tensor, - angles: torch.Tensor, - inline_alignment: bool, - filter_name: str, - circle: bool, - gaussian_kernel: torch.Tensor | None, - ): - loss = 0 - - if inline_alignment: - for ind in range(len(self.dataset.tilt_angles)): - im_proj = proj_forward[ind] - im_meas = tilt_series[ind] - - shift = torch_phase_cross_correlation(im_proj, im_meas) - if torch.linalg.norm(shift) <= 32: - shifted = torch.fft.ifft2( - torch.fft.fft2(im_meas) - * torch.exp( - -2j - * np.pi - * ( - shift[0] - * torch.fft.fftfreq( - im_meas.shape[0], device=im_meas.device - ).unsqueeze(1) - + shift[1] - * torch.fft.fftfreq(im_meas.shape[1], device=im_meas.device) - ) - ) - ).real - - proj_forward[ind] = shifted - - # Forward projection - - sinogram_est = radon_torch(self.volume_obj.obj, theta=angles, device=self.device) - # proj_forward = sinogram_est.permute(1, 2, 0) - # error = (tilt_series - proj_forward).permute(2, 0, 1) - proj_forward = sinogram_est - error = tilt_series - proj_forward - - correction = iradon_torch( - error, theta=angles, device=self.device, filter_name=filter_name, circle=circle - ) - - normalization = iradon_torch( - torch.ones_like(error), - theta=angles, - device=self.device, - filter_name=None, - circle=circle, - ) - - normalization[normalization == 0] = 1e-6 - - correction /= normalization - - self.volume_obj._obj += correction - - loss = torch.mean(torch.abs(error)) - - # for z in tqdm(range(self.volume_obj.obj.shape[0]), desc="SIRT Reconstruction"): - # slice_estimate = self.volume_obj.obj[z] - # sinogram_est = radon_torch(slice_estimate, theta=angles, device=self.device) - - # sinogram_true = tilt_series[:, :, z] - - # error = sinogram_true - sinogram_est - - # correction = iradon_torch( - # error, theta=angles, device=self.device, filter_name=filter_name, circle=circle - # ) - - # # I'm pretty sure this implementation of normalization is wrong - # normalization = iradon_torch( - # torch.ones_like(error), - # theta=angles, - # device=self.device, - # filter_name=None, - # circle=circle, - # ) - # normalization[normalization == 0] = 1e-6 - - # correction /= normalization - - # self.volume_obj._obj[z] += correction - - # proj_forward[:, :, z] = sinogram_est - - # loss += torch.mean(torch.abs(error)) - - # loss /= self.volume_obj._obj.shape[0] - - if gaussian_kernel is not None: - self.volume_obj.obj = gaussian_filter_2d_stack(self.volume_obj.obj, gaussian_kernel) - - return proj_forward, loss - - # Deprecated torch_radon implementations - # def _sirt_run_epoch( - # self, - # radon: Radon, - # stack_recon: torch.Tensor, - # stack_torch: torch.Tensor, - # proj_forward: torch.Tensor, - # step_size: float = 0.25, - # gaussian_kernel: torch.Tensor = None, - # inline_alignment=True, - # enforce_positivity=True, - # shrinkage: float = None, - # ): - # loss = 0 - - # if inline_alignment: - # for ind in range(len(self.tilt_series.tilt_angles)): - # im_proj = proj_forward[:, ind, :] - # im_meas = stack_torch[:, ind, :] - - # shift = torch_phase_cross_correlation(im_proj, im_meas) - # if torch.linalg.norm(shift) <= 32: - # shifted = torch.fft.ifft2( - # torch.fft.fft2(im_meas) - # * torch.exp( - # -2j - # * np.pi - # * ( - # shift[0] - # * torch.fft.fftfreq( - # im_meas.shape[0], device=im_meas.device - # ).unsqueeze(1) - # + shift[1] - # * torch.fft.fftfreq(im_meas.shape[1], device=im_meas.device) - # ) - # ) - # ).real - - # stack_torch[:, ind, :] = shifted - - # proj_forward = radon.forward(stack_recon) - - # proj_diff = stack_torch - proj_forward - - # loss = torch.mean(torch.abs(proj_diff)) - - # recon_slice_update = radon.backward( - # radon.filter_sinogram( - # proj_diff, - # ) - # ) - - # stack_recon += step_size * recon_slice_update - # if enforce_positivity: - # stack_recon = torch.clamp(stack_recon, min=0) - - # if gaussian_kernel is not None: - # stack_recon = gaussian_filter_2d_stack( - # stack_recon, - # gaussian_kernel, - # ) - - # if shrinkage is not None: - # stack_recon = torch.max( - # stack_recon - shrinkage, - # torch.zeros_like(stack_recon), - # ) - - # return stack_recon, loss - - # def _sirt_serial_run_epoch( - # self, - # radon: Radon, - # stack_recon: torch.Tensor, - # stack_torch: torch.Tensor, - # proj_forward: torch.Tensor, - # step_size: float = 0.25, - # gaussian_kernel: torch.Tensor = None, - # inline_alignment=True, - # enforce_positivity=True, - # ): - # recon_slice_update = torch.zeros_like(stack_recon).to(self.device) - - # loss = 0 - - # for i in range(stack_recon.shape[0]): - # proj_forward[i] = radon.forward(stack_recon[i]) - - # proj_diff = stack_torch - proj_forward - - # loss = torch.mean(torch.abs(proj_diff)) - - # for i in range(stack_recon.shape[0]): - # recon_slice_update[i] = radon.backward( - # radon.filter_sinogram( - # proj_diff[i], - # ) - # ) - - # stack_recon += step_size * recon_slice_update - - # if enforce_positivity: - # stack_recon = torch.clamp(stack_recon, min=0) - - # return stack_recon, loss - - # --- Properties --- - # @property - # def reconstruction_method(self) -> str: - # """Get the reconstruction method.""" - # return self._reconstruction_method - # @reconstruction_method.setter - # def reconstruction_method(self, value: str): - # """Set the reconstruction method.""" - # if value not in ["SIRT", "FBP"]: - # raise ValueError("Invalid reconstruction method. Choose 'SIRT' or 'FBP'.") - # self._reconstruction_method = value diff --git a/src/quantem/tomography/tomography_dataset.py b/src/quantem/tomography/tomography_dataset.py deleted file mode 100644 index 816259b6..00000000 --- a/src/quantem/tomography/tomography_dataset.py +++ /dev/null @@ -1,226 +0,0 @@ -import torch -from numpy.typing import NDArray -from torch._tensor import Tensor - -from quantem.core.datastructures.dataset3d import Dataset3d -from quantem.core.io.serialize import AutoSerialize -from quantem.core.utils.validators import ( - validate_array, - validate_tensor, -) - - -class TomographyDataset(AutoSerialize): - _token = object() - - """ - A tomography dataset which contains the tilt series, and also instatiates the - z1, z3, and shifts of the tilt series. - - Idea for this dataset is so that we can avoid moving things around as a torch tensor, - since the SIRT reconstruction algorthim, and AD reconstruction we have are all torch based. - """ - - def __init__( - self, - tilt_series: Tensor, - tilt_angles: Tensor, - z1_angles: Tensor, - z3_angles: Tensor, - shifts: Tensor, - ): - self._tilt_series = tilt_series - self._tilt_angles = tilt_angles - self._z1_angles = z1_angles - self._z3_angles = z3_angles - self._shifts = shifts - - self._initial_tilt_angles = tilt_angles.clone() - self._initial_z1_angles = z1_angles.clone() - self._initial_z3_angles = z3_angles.clone() - self._initial_shifts = shifts.clone() - - # --- Class Methods --- - @classmethod - def from_data( - cls, - tilt_series: Dataset3d | NDArray | Tensor, - tilt_angles: NDArray | Tensor, - z1_angles: NDArray | Tensor | None = None, - z3_angles: NDArray | Tensor | None = None, - shifts: NDArray | Tensor | None = None, - # name: str | None = None, - # origin: NDArray | tuple | list | float | int | None = None, - # sampling: NDArray | tuple | list | float | int | None = None, - # units: list[str] | tuple | list | None = None, - # signal_units: str = "arb. units", - # _token: object | None = None, - ): - """ - tilt_series: (N, H, W) - tilt_angles: (N,) - In units of degrees, the alpha tilt angle. - z1_angles: (N,) - In units of degrees, beta tilt angle. - z3_angles: (N,) - In units of degrees, negative beta tilt angle. - shifts: (N, 2) - - - The convention we use for projecting down is ZXZ Euler Angles. - - In theory, Z1 and Z3 should be the same value, except Z3 would be the - negative value of Z1. However, in some cases this is not the case and - there could be some merit in also optimizing both angles. However, the - downside is the rotation becomes less interpretable. - - The tilt angle can also be optimized. - """ - validated_tilt_series = torch.tensor(validate_array(tilt_series, "tilt_series")) - validated_tilt_angles = torch.tensor(validate_array(tilt_angles, "tilt_angles")) - - if z1_angles is not None: - validated_z1_angles = torch.tensor(validate_array(z1_angles, "z1_angles")) - else: - validated_z1_angles = torch.zeros(len(validated_tilt_angles)) - - if z3_angles is not None: - validated_z3_angles = torch.tensor(validate_array(z3_angles, "z3_angles")) - else: - validated_z3_angles = torch.zeros(len(validated_tilt_angles)) - - if shifts is not None: - validated_shifts = torch.tensor(validate_array(shifts, "shifts")) - else: - validated_shifts = torch.zeros(len(validated_tilt_angles), 2) - - return cls( - tilt_series=validated_tilt_series, - tilt_angles=validated_tilt_angles, - z1_angles=validated_z1_angles, - z3_angles=validated_z3_angles, - shifts=validated_shifts, - # name=name, - # origin=origin, - # sampling=sampling, - # units=units, - # signal_units=signal_units, - ) - - def to(self, device: str): - self._tilt_series = self._tilt_series.to(device) - self._tilt_angles = self._tilt_angles.to(device) - self._z1_angles = self._z1_angles.to(device) - self._z3_angles = self._z3_angles.to(device) - self._shifts = self._shifts.to(device) - - # --- Properties --- - - @property - def tilt_series(self) -> Tensor: - return self._tilt_series - - @property - def tilt_angles(self) -> Tensor: - return self._tilt_angles - - @property - def z1_angles(self) -> Tensor: - return self._z1_angles - - @property - def z3_angles(self) -> Tensor: - return self._z3_angles - - @property - def shifts(self) -> Tensor: - return self._shifts - - @property - def initial_tilt_angles(self) -> Tensor: - return self._initial_tilt_angles - - @property - def initial_z1_angles(self) -> Tensor: - return self._initial_z1_angles - - @property - def initial_z3_angles(self) -> Tensor: - return self._initial_z3_angles - - @property - def initial_shifts(self) -> Tensor: - return self._initial_shifts - - # --- Setters --- - - @tilt_series.setter - def tilt_series(self, tilt_series: NDArray | Tensor | Dataset3d) -> None: - if isinstance(tilt_series, Dataset3d): - validated_tilt_series = torch.tensor(tilt_series.array) - elif isinstance(tilt_series, Tensor): - validated_tilt_series = validate_tensor(tilt_series, "tilt_series") - else: - validated_tilt_series = torch.tensor(validate_array(tilt_series, "tilt_series")) - - self._tilt_series = validated_tilt_series - - @tilt_angles.setter - def tilt_angles(self, tilt_angles: NDArray | Tensor) -> None: - if tilt_angles.shape[0] != self.tilt_series.shape[0]: - raise ValueError("Tilt angles must match the number of projections.") - - validated_tilt_angles = torch.tensor(validate_array(tilt_angles, "tilt_angles")) - self._tilt_angles = validated_tilt_angles - - @z1_angles.setter - def z1_angles(self, z1_angles: NDArray | Tensor) -> None: - if z1_angles.shape[0] != self.tilt_series.shape[0]: - raise ValueError("Z1 angles must match the number of projections.") - - if isinstance(z1_angles, Tensor): - validated_z1_angles = validate_tensor(z1_angles, "z1_angles") - else: - validated_z1_angles = torch.tensor(validate_array(z1_angles, "z1_angles")) - self._z1_angles = validated_z1_angles - - @z3_angles.setter - def z3_angles(self, z3_angles: NDArray | Tensor) -> None: - if z3_angles.shape[0] != self.tilt_series.shape[0]: - raise ValueError("Z3 angles must match the number of projections.") - - if isinstance(z3_angles, Tensor): - validated_z3_angles = validate_tensor(z3_angles, "z3_angles") - else: - validated_z3_angles = torch.tensor(validate_array(z3_angles, "z3_angles")) - self._z3_angles = validated_z3_angles - - @shifts.setter - def shifts(self, shifts: NDArray | Tensor) -> None: - if shifts.shape[0] != self.tilt_series.shape[0]: - raise ValueError("Shifts must match the number of projections.") - - if isinstance(shifts, Tensor): - validated_shifts = validate_tensor(shifts, "shifts") - else: - validated_shifts = torch.tensor(validate_array(shifts, "shifts")) - - self._shifts = validated_shifts - - @initial_tilt_angles.setter - def initial_tilt_angles(self, tilt_angles: NDArray | Tensor) -> None: - self._initial_tilt_angles = tilt_angles - - @initial_z1_angles.setter - def initial_z1_angles(self, z1_angles: NDArray | Tensor) -> None: - self._initial_z1_angles = z1_angles - - @initial_z3_angles.setter - def initial_z3_angles(self, z3_angles: NDArray | Tensor) -> None: - self._initial_z3_angles = z3_angles - - @initial_shifts.setter - def initial_shifts(self, shifts: NDArray | Tensor) -> None: - self._initial_shifts = shifts - - # --- RESET --- - - def reset(self) -> None: - self._tilt_angles = self._initial_tilt_angles.clone() - self._z1_angles = self._initial_z1_angles.clone() - self._z3_angles = self._initial_z3_angles.clone() - self._shifts = self._initial_shifts.clone() diff --git a/src/quantem/tomography/tomography_lite.py b/src/quantem/tomography/tomography_lite.py new file mode 100644 index 00000000..7bd9ad67 --- /dev/null +++ b/src/quantem/tomography/tomography_lite.py @@ -0,0 +1,172 @@ +import os +from typing import Literal, Self + +import numpy as np +import torch +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.ml.inr import HSiren +from quantem.core.ml.optimizer_mixin import ( + OptimizerParams, + SchedulerParams, +) +from quantem.tomography.dataset_models import ( + DatasetConstraintsType, + TomographyINRDataset, + TomographyPixDataset, +) +from quantem.tomography.logger_tomography import LoggerTomography +from quantem.tomography.object_models import ObjConstraintsType, ObjectINR, ObjectPixelated +from quantem.tomography.tomography import Tomography, TomographyConventional + + +class TomographyLiteINR(Tomography): + """ + A lite version of the Tomography class. + """ + + @classmethod + def from_dataset( + cls, + tilt_series: Dataset3d | NDArray | torch.Tensor, + tilt_angles: NDArray | torch.Tensor, + device: str = "cuda", + log_dir: os.PathLike | str | None = None, + log_images_every: int = 10, + rng: np.random.Generator | int | None = None, + ) -> Self: + dset_model = TomographyINRDataset.from_data( + tilt_stack=tilt_series, + tilt_angles=tilt_angles, + ) + + # Define the object model + model = HSiren(alpha=1, winner_initialization=72) + obj_model = ObjectINR.from_model( + model=model, + shape=( + max(dset_model.tilt_stack.shape), + max(dset_model.tilt_stack.shape), + max(dset_model.tilt_stack.shape), + ), + device=device, + rng=rng, + ) + + # TODO: Implement pretrain + + if log_dir is not None: + logger = LoggerTomography( + log_dir=str(log_dir), + run_prefix="tomography_lite_inr", + run_suffix="", + log_images_every=log_images_every, + ) + else: + logger = None + + tomography = cls.from_models( + dset=dset_model, + obj_model=obj_model, + device=device, + rng=rng, + logger=logger, + ) + + return tomography + + def reconstruct( # type:ignore[reportIncompatibleMethodOverride] ## easier than overloads + self, + num_iter: int = 10, + reset: bool = False, + obj_lr: float = 1e-4, + pose_lr: float = 1e-2, + batch_size: int = 1024, + num_workers: int = 32, + learn_pose: bool = True, + warmup_routine: bool = True, + scheduler_type: Literal[ + "exp", "cyclic", "plateau", "cosine_annealing", "linear", "full_warmup", "none" + ] = "none", + scheduler_params: dict = {}, + new_optimizers: bool = False, + obj_constraints: ObjConstraintsType | dict | None = None, + dset_constraints: DatasetConstraintsType | dict | None = None, + show_metrics: bool = False, + ): + if self.num_epochs == 0: + opt_params = { + "object": OptimizerParams.Adam(lr=obj_lr), + } + + all_scheduler_params = { + "object": SchedulerParams.parse_dict( + { + "name": scheduler_type, + **scheduler_params, + } + ), + } + + if learn_pose: + opt_params["pose"] = OptimizerParams.Adam(lr=pose_lr) + all_scheduler_params["pose"] = SchedulerParams.parse_dict( + { + "name": scheduler_type, + **scheduler_params, + } + ) + else: + opt_params = None + all_scheduler_params = None + obj_constraints = None + dset_constraints = None + + num_samples_per_ray = int(max(self.dset.tilt_stack.shape)) + return super().reconstruct( + num_iter=num_iter, + batch_size=batch_size, + num_workers=num_workers, + reset=reset, + num_samples_per_ray=num_samples_per_ray, + optimizer_params=opt_params, + scheduler_params=all_scheduler_params, + obj_constraints=obj_constraints, + dset_constraints=dset_constraints, + show_metrics=show_metrics, + ) + + +class TomographyLiteConv(TomographyConventional): + @classmethod + def from_dataset( + cls, + tilt_series: Dataset3d | NDArray | torch.Tensor, + tilt_angles: NDArray | torch.Tensor, + device: str = "cuda", + rng: np.random.Generator | int | None = None, + ) -> Self: + dset_model = TomographyPixDataset.from_data( + tilt_stack=tilt_series, + tilt_angles=tilt_angles, + ) + + obj_model = ObjectPixelated.from_uniform( + shape=( + max(tilt_series.shape), + max(tilt_series.shape), + max(tilt_series.shape), + ), + device=device, + rng=rng, + ) + + tomography = cls.from_models( + dset=dset_model, + obj_model=obj_model, + device=device, + rng=rng, + ) + + return tomography diff --git a/src/quantem/tomography/tomography_logger.py b/src/quantem/tomography/tomography_logger.py deleted file mode 100644 index 8156837b..00000000 --- a/src/quantem/tomography/tomography_logger.py +++ /dev/null @@ -1,68 +0,0 @@ -import matplotlib.pyplot as plt - -from quantem.core.ml.logger import LoggerBase -from quantem.tomography.object_models import ObjectModelType -from quantem.tomography.tomography_dataset import TomographyDataset - - -class LoggerTomography(LoggerBase): - def __init__(self, log_dir: str, run_prefix: str, run_suffix: str = None): - super().__init__(log_dir, run_prefix, run_suffix) - - # --- Tomography focused logging methods --- - - def tilt_angles_figure(self, dataset: TomographyDataset, step: int): - figs = [] - for angle_array, title in zip( - [dataset.z1_angles, dataset.tilt_angles, dataset.z3_angles], - ["Z1 Angles", "Tilt/ X Angles", "Z3 Angles"], - ): - fig, ax = plt.subplots(figsize=(5, 5)) - ax.plot(angle_array.detach().cpu().numpy()) - ax.set_title(title) - ax.set_xlabel("Index") - ax.set_ylabel("Angle") - figs.append(fig) - plt.close(fig) - - self.log_figure( - tag="tilt_angles/z1", - fig=figs[0], - step=step, - ) - self.log_figure( - tag="tilt_angles/x", - fig=figs[1], - step=step, - ) - self.log_figure( - tag="tilt_angles/z3", - fig=figs[2], - step=step, - ) - - def projection_images( - self, volume_obj: ObjectModelType, epoch: int, logger_cmap: str = "turbo" - ): - sum_0 = volume_obj.obj.sum(axis=0) - sum_1 = volume_obj.obj.sum(axis=1) - sum_2 = volume_obj.obj.sum(axis=2) - - self.log_image( - tag="projections/Y-X Projection", - image=sum_0, - step=epoch, - cmap=logger_cmap, - ) - self.log_image( - tag="projections/Z-X Projection", - image=sum_1, - step=epoch, - cmap=logger_cmap, - ) - self.log_image( - tag="projections/Z-Y Projection", - image=sum_2, - step=epoch, - cmap=logger_cmap, - ) diff --git a/src/quantem/tomography/tomography_ml.py b/src/quantem/tomography/tomography_ml.py deleted file mode 100644 index 4a902887..00000000 --- a/src/quantem/tomography/tomography_ml.py +++ /dev/null @@ -1,304 +0,0 @@ -from typing import Any, Generator, Iterator, Sequence - -import torch - -from quantem.tomography.tomography_base import TomographyBase - - -class TomographyML(TomographyBase): - """ - Class for handling conventional reconstruction methods of tomography data. - """ - - OPTIMIZABLE_VALS = ["volume", "z1", "x", "z3", "shifts"] - DEFAULT_LRS = { - "volume": 1e-2, - "z1": 1e-1, - "x": 1e-1, - "z3": 1e-1, - "shifts": 1e-1, - "tv_weight_vol": 0, - "tv_weight_z1": 0, - "tv_weight_x": 0, - "tv_weight_z3": 0, - } - DEFAULT_OPTIMIZER_TYPE = "adam" - - # --- Properties --- - - @property - def optimizer_params(self) -> dict[str, dict]: - """Returns the parameters used to set the optimizers.""" - return self._optimizer_params - - @optimizer_params.setter - def optimizer_params(self, d: dict) -> None: - """ - # Takes a dictionary {key: torch.optim.Adam(params=[blah], lr=[blah]), ...} - Takes a dictionary: - { - "key1": { - "type": "adam", - "lr": 0.001, - }, - "key2": { - ... - }, - ... - } - """ - # resets _optimizers as well - self._optimizers = {} - self._optimizer_params = {} - if isinstance(d, (tuple, list)): - d = {k: {} for k in d} - - for k, v in d.items(): - if k not in self.OPTIMIZABLE_VALS: - raise ValueError( - f"key to be optimized, {k}, not in allowed keys: {self.OPTIMIZABLE_VALS}" - ) - if "type" not in v.keys(): - v["type"] = self.DEFAULT_OPTIMIZER_TYPE - if "lr" not in v.keys(): - v["lr"] = self.DEFAULT_LRS[k] - self._optimizer_params[k] = v - - @property - def optimizers(self) -> dict[str, torch.optim.Adam | torch.optim.AdamW]: - return self._optimizers - - def set_optimizers(self): - """Reset all optimizers and set them according to the optimizer_params.""" - for key, _ in self._optimizer_params.items(): - if key == "volume": - self._add_optimizer(key, self.volume_obj.params, self._optimizer_params[key]) - elif key == "shifts": - self._add_optimizer(key, self.dataset.shifts, self._optimizer_params[key]) - elif key == "z1": - self._add_optimizer(key, self.dataset.z1_angles, self._optimizer_params[key]) - elif key == "x": - self._add_optimizer(key, self.dataset.tilt_angles, self._optimizer_params[key]) - elif key == "z3": - self._add_optimizer(key, self.dataset.z3_angles, self._optimizer_params[key]) - else: - raise ValueError( - f"key to be optimized, {key}, not in allowed keys: {self.OPTIMIZABLE_VALS}" - ) - - def remove_optimizer(self, key: str) -> None: - self._optimizers.pop(key, None) - self._optimizer_params.pop(key, None) - return - - def _add_optimizer( - self, - key: str, - params: "torch.Tensor|Sequence[torch.Tensor]|Iterator[torch.Tensor]", - opt_params: dict, - ) -> None: - """Can be used to add an optimizer without resetting the other optimizers.""" - - if key not in self.OPTIMIZABLE_VALS: - raise ValueError( - f"key to be optimized, {key}, not in allowed keys: {self.OPTIMIZABLE_VALS}" - ) - if isinstance(params, torch.Tensor): - params = [params] - elif isinstance(params, Generator): - params = list(params) - [p.requires_grad_(True) for p in params] - self.optimizer_params[key] = opt_params - opt_params = opt_params.copy() - opt_type = opt_params.pop("type") - if isinstance(opt_type, type): - opt = opt_type(params, **opt_params) - elif opt_type == "adam": - opt = torch.optim.Adam(params, **opt_params) - elif opt_type == "adamw": - opt = torch.optim.AdamW(params, **opt_params) # TODO pass all other kwargs - else: - raise NotImplementedError(f"Unknown optimizer type: {opt_params['type']}") - # if key in self.optimizers.keys(): - # self.vprint(f"Key {key} is already in optimizers, overwriting.") - self._optimizers[key] = opt - - @property - def scheduler_params(self) -> dict[str, dict]: - """Returns the parameters used to set the schedulers.""" - return self._scheduler_params - - @scheduler_params.setter - def scheduler_params(self, d: dict) -> None: - """ - Takes a dictionary: - { - "key1": { - "type": "cyclic", - "base_lr": 0.001, - }, - "key2": { - ... - }, - ... - } - """ - self._schedulers = {} - for k, v in d.items(): - if not any(v): - continue - if k not in self.OPTIMIZABLE_VALS: - raise ValueError( - f"key to be optimized, {k}, not in allowed keys: {self.OPTIMIZABLE_VALS}" - ) - if v["type"] not in ["cyclic", "plateau", "exp", "gamma", "none"]: - raise ValueError( - f"Unknown scheduler type: {v['type']}, expected one of ['cyclic', 'plateau', 'exp', 'gamma', 'none']" - ) - self._scheduler_params = d - - @property - def schedulers( - self, - ) -> dict[ - str, - ( - torch.optim.lr_scheduler.CyclicLR - | torch.optim.lr_scheduler.ReduceLROnPlateau - | torch.optim.lr_scheduler.ExponentialLR - | None - ), - ]: - return self._schedulers - - def set_schedulers( - self, - params: dict[str, dict], - num_iter: int | None = None, - ): - """ - TODO allow for new schedulers to be passed in when adding new optimizers without - removing the old schedulers or overwrtiting them. Not entirely sure what usecases there - will be for this. - - Sets the schedulers for the optimizer from a dictionary. Expects a dictionary of the form: - { - "optimizable_key1": { - "type": "scheduler_type", - "scheduler_kwarg": scheduler_kwarg_value, - ... - }, - "optimizable_key2": { - "type": "scheduler_type", - "scheduler_kwarg": scheduler_kwarg_value, - ... - }, - ... - } - where the keys are the same as the keys in self.OPTIMIZABLE_VALS. - - The scheduler type can be one of the following: - - "cyclic" - - "plateau" or "reducelronplateau" - - "exponential" - - None - - The num_iter kwarg is only used for exponential schedulers and if a "factor" is given - as a scheduler_kwarg instead of gamma. In that case, the gamma is calculated from num_iter - and the factor. - - TODO could update this to allow passing key:optimizer directly, would likely need to - rewrite get_schedulers to check the tpye - """ - if not any(self.optimizers): - raise NameError("self.optimizers have not yet been set.") - self._schedulers = self._get_schedulers( - params=params, - optimizers=self.optimizers, - num_iter=num_iter, - ) - - def _get_schedulers( - self, - params: dict[str, dict], - optimizers: dict, - num_iter: int | None = None, - ) -> dict[ - str, - ( - torch.optim.lr_scheduler.CyclicLR - | torch.optim.lr_scheduler.ReduceLROnPlateau - | torch.optim.lr_scheduler.ExponentialLR - | None - ), - ]: - """ - return schedulers for a given set of optimizers. Kept seperate from schedulers.setter so - that it can be called for pre-training - """ - schedulers = {} - for opt_key, p in params.items(): - if not any(p): - continue - elif opt_key not in self.OPTIMIZABLE_VALS: - raise KeyError( - f"Scheduler got bad key {opt_key}, schedulers can only be attached to one of {self.OPTIMIZABLE_VALS}" - ) - elif opt_key not in optimizers.keys(): - raise KeyError(f"optimizers does not have an optimizer for: {opt_key}") - else: - schedulers[opt_key] = self._get_scheduler( - optimizer=optimizers[opt_key], params=p, num_iter=num_iter - ) - return schedulers - - def _get_scheduler( - self, - optimizer: torch.optim.Adam, - params: dict[str, Any] | torch.optim.lr_scheduler._LRScheduler, - num_iter: int | None = None, - ) -> ( - torch.optim.lr_scheduler._LRScheduler - | torch.optim.lr_scheduler.CyclicLR - | torch.optim.lr_scheduler.ReduceLROnPlateau - | torch.optim.lr_scheduler.ExponentialLR - | None - ): - if isinstance(params, torch.optim.lr_scheduler._LRScheduler): - return params - - sched_type: str = params["type"].lower() - base_LR = optimizer.param_groups[0]["lr"] - if sched_type == "none": - scheduler = None - elif sched_type == "cyclic": - scheduler = torch.optim.lr_scheduler.CyclicLR( - optimizer, - base_lr=params.get("base_lr", base_LR / 4), - max_lr=params.get("max_lr", base_LR * 4), - step_size_up=params.get("step_size_up", 100), - mode=params.get("mode", "triangular2"), - cycle_momentum=params.get("momentum", False), - ) - elif sched_type.startswith(("plat", "reducelronplat")): - scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( - optimizer, - mode="min", - factor=params.get("factor", 0.5), - patience=params.get("patience", 10), - threshold=params.get("threshold", 1e-3), - min_lr=params.get("min_lr", base_LR / 20), - cooldown=params.get("cooldown", 50), - ) - elif sched_type in ["exp", "gamma", "exponential"]: - if "gamma" in params.keys(): - gamma = params["gamma"] - elif num_iter is not None: - fac = params.get("factor", 0.01) - gamma = fac ** (1.0 / num_iter) - else: - gamma = 0.999 - scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=gamma) - else: - raise ValueError(f"Unknown scheduler type: {sched_type}") - return scheduler diff --git a/src/quantem/tomography/tomography_opt.py b/src/quantem/tomography/tomography_opt.py new file mode 100644 index 00000000..7f98fabd --- /dev/null +++ b/src/quantem/tomography/tomography_opt.py @@ -0,0 +1,179 @@ +from collections.abc import Mapping + +import torch + +from quantem.core.ml.optimizer_mixin import ( + OptimizerParams, + OptimizerParamsType, + SchedulerParamsType, +) +from quantem.tomography.tomography_base import TomographyBase + + +class TomographyOpt(TomographyBase): + """ + Class for handling all the optimizers and schedulers for the tomography reconstruction. + """ + + OPTIMIZABLE_VALS = ["object", "pose"] + DEFAULT_OPTIMIZER_TYPE: OptimizerParamsType = OptimizerParams.Adam() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _get_default_lr(self, key: str) -> float: + """Get default learning rate for a given optimization key.""" + if key == "object": + return self.obj_model.DEFAULT_LRS.get("object", 1e-5) + elif key == "pose": + return self.dset.DEFAULT_LRS.get("pose", 5e-2) + else: + raise ValueError(f"Unknown optimization key: {key}") + + @property + def optimizer_params(self) -> dict[str, OptimizerParamsType | dict[str, OptimizerParamsType]]: + return { + key: params + for key, params in [ + ("object", self.obj_model.optimizer_params), + ("pose", self.dset.optimizer_params), + ] + if params + } + + @optimizer_params.setter + def optimizer_params(self, d: dict[str, OptimizerParamsType] | dict[str, dict]): + """Set the optimizer parameters.""" + if isinstance(d, (tuple, list)): + d = {k: {} for k in d} + + targets = { + "object": self.obj_model, + "pose": self.dset, + } + + for k, v in d.items(): + if k not in targets: + raise ValueError(f"Unknown optimization key: {k}") + + # if not isinstance(v, OptimizerParamsType): + # v = OptimizerParams.parse_dict(v) + + targets[k].optimizer_params = v + + @property + def optimizers(self) -> dict[str, torch.optim.Optimizer]: + optimizers = {} + + if self.obj_model.optimizer is not None: + optimizers["object"] = self.obj_model.optimizer + if self.dset.optimizer is not None: + optimizers["pose"] = self.dset.optimizer + + return optimizers + + def set_optimizers(self): + for key, params in self.optimizer_params.items(): + if key == "object": + self.obj_model.set_optimizer(params) + elif key == "pose": + self.dset.set_optimizer(params) + else: + raise ValueError(f"Unknown optimization key: {key}") + + def get_current_lrs(self) -> dict[str, float]: + if self.obj_model.has_optimizer(): + obj_lr = self.obj_model.get_current_lr() + else: + obj_lr = 0.0 + if self.dset.has_optimizer(): + pose_lr = self.dset.get_current_lr() + else: + pose_lr = 0.0 + return { + "object": obj_lr, + "pose": pose_lr, + } + + def remove_optimizer(self, key: str): + if key == "object": + self.obj_model.remove_optimizer() + elif key == "pose": + self.dset.remove_optimizer() + else: + raise ValueError(f"Unknown optimization key: {key}") + + @property + def scheduler_params(self) -> dict[str, SchedulerParamsType]: + """Returns the parameters used to set the schedulers.""" + return { + "object": self.obj_model.scheduler_params, + "pose": self.dset.scheduler_params, + } + + @scheduler_params.setter + def scheduler_params(self, d: dict): + """Set the scheduler parameters.""" + d = dict(d) if d else {} + self._scheduler_params = d.copy() + + for key in self.OPTIMIZABLE_VALS: + if key not in d: + d[key] = {} + + for k, v in d.items(): + if k == "object": + self.obj_model.scheduler_params = v + elif k == "pose": + self.dset.scheduler_params = v + else: + raise ValueError(f"Unknown optimization key: {k}") + + @property + def schedulers(self) -> dict[str, torch.optim.lr_scheduler._LRScheduler]: + schedulers = {} + + if self.obj_model.scheduler is not None: + schedulers["object"] = self.obj_model.scheduler + if self.dset.scheduler is not None: + schedulers["pose"] = self.dset.scheduler + + return schedulers + + def set_schedulers( + self, params: Mapping[str, SchedulerParamsType | dict], num_iter: int | None = None + ): + for key, scheduler_params in params.items(): + if key == "object": + self.obj_model.set_scheduler(scheduler_params, num_iter=num_iter) + elif key == "pose": + self.dset.set_scheduler(scheduler_params, num_iter=num_iter) + else: + raise ValueError(f"Unknown optimization key: {key}") + + def step_optimizers(self): + for key in self.optimizer_params.keys(): + if key not in self.OPTIMIZABLE_VALS: + raise ValueError(f"Unknown optimization key: {key}") + if key == "object" and self.obj_model.has_optimizer(): + self.obj_model.step_optimizer() + elif key == "pose" and self.dset.has_optimizer(): + self.dset.step_optimizer() + + def zero_grad_all(self): + for key in self.optimizer_params.keys(): + if key not in self.OPTIMIZABLE_VALS: + raise ValueError(f"Unknown optimization key: {key}") + if key == "object" and self.obj_model.has_optimizer(): + self.obj_model.zero_optimizer_grad() + elif key == "pose" and self.dset.has_optimizer(): + self.dset.zero_optimizer_grad() + + def step_schedulers(self, loss: float | None = None): + for key in self.scheduler_params.keys(): + if self.obj_model.scheduler is not None and key == "object": + self.obj_model.step_scheduler(loss) + if self.dset.scheduler is not None and key == "pose": + self.dset.step_scheduler(loss) + if key not in self.OPTIMIZABLE_VALS: + raise ValueError(f"Unknown optimization key: {key}") diff --git a/src/quantem/tomography/utils.py b/src/quantem/tomography/utils.py index d93f15e0..08093925 100644 --- a/src/quantem/tomography/utils.py +++ b/src/quantem/tomography/utils.py @@ -1,11 +1,5 @@ -import numpy as np import torch import torch.nn.functional as F -from scipy.ndimage import center_of_mass, gaussian_filter, shift -from scipy.stats import norm -from tqdm.auto import tqdm - -from quantem.core.utils.imaging_utils import cross_correlation_shift # --- Projection Operator Utils --- @@ -75,224 +69,29 @@ def transform_slice(mag_slice): return rotated_mags.permute(1, 2, 3, 0) -def differentiable_shift_2d(image, shift_x, shift_y, sampling_rate): - """ - Shifts a 2D image using grid_sample in a differentiable manner. - - Args: - image: Tensor of shape [H, W] - shift_x: Scalar tensor (dx) for shift in x-direction (in physical units) - shift_y: Scalar tensor (dy) for shift in y-direction (in physical units) - sampling_rate: Scalar value (physical units per pixel) to correctly normalize shifts - - Returns: - Shifted image of shape [H, W] +def tv_loss_1d(x: torch.Tensor, reduction: str = "mean") -> torch.Tensor: """ - H, W = image.shape - - # Convert physical shift to pixel shift - shift_x_pixel = shift_x - shift_y_pixel = shift_y - - # Normalize shift for grid_sample (assuming align_corners=True) - normalized_shift_x = shift_x_pixel * 2 / (W - 1) - normalized_shift_y = shift_y_pixel * 2 / (H - 1) - - # Create normalized grid - grid_y, grid_x = torch.meshgrid( - torch.linspace(-1, 1, H, device=image.device), - torch.linspace(-1, 1, W, device=image.device), - indexing="ij", - ) - - grid = torch.stack((grid_x, grid_y), dim=-1).unsqueeze(0) # [1, H, W, 2] - - # Apply shift (ensure it's differentiable) - grid[:, :, :, 0] -= normalized_shift_x - grid[:, :, :, 1] -= normalized_shift_y - - # Add batch and channel dimensions - image = image.unsqueeze(0).unsqueeze(0) # [1, 1, H, W] - - # Sample using grid_sample (fully differentiable) - shifted_image = F.grid_sample( - image, grid, mode="bicubic", padding_mode="zeros", align_corners=True - ) - - return shifted_image.squeeze(0).squeeze(0) # Back to [H, W] - - -# --- TV loss --- - - -def get_TV_loss(tensor, factor=1e-3): - tv_d = torch.pow(tensor[:, :, 1:, :, :] - tensor[:, :, :-1, :, :], 2).sum() - tv_h = torch.pow(tensor[:, :, :, 1:, :] - tensor[:, :, :, :-1, :], 2).sum() - tv_w = torch.pow(tensor[:, :, :, :, 1:] - tensor[:, :, :, :, :-1], 2).sum() - tv_loss = tv_d + tv_h + tv_w - - return tv_loss * factor / (torch.prod(torch.tensor(tensor.shape))) + 1D Total Variation Loss. - -# --- Gaussian filters --- - - -def gaussian_kernel_1d(sigma: float, num_sigmas: float = 3.0) -> torch.Tensor: - radius = np.ceil(num_sigmas * sigma) - support = torch.arange(-radius, radius + 1, dtype=torch.float) - kernel = torch.distributions.Normal(loc=0, scale=sigma).log_prob(support).exp_() - # Ensure kernel weights sum to 1, so that image brightness is not altered - return kernel.mul_(1 / kernel.sum()) - - -def gaussian_filter_2d( - img: torch.Tensor, sigma: float, kernel_1d: torch.Tensor -) -> torch.Tensor: # Add kernel_1d as an argument - # kernel_1d = gaussian_kernel_1d(sigma) # Create 1D Gaussian kernel - Moved outside function - padding = len(kernel_1d) // 2 # Ensure that image size does not change - img = img.unsqueeze(0).unsqueeze_(0) # Make copy, make 4D for ``conv2d()`` - # Convolve along columns and rows - img = torch.nn.functional.conv2d(img, weight=kernel_1d.view(1, 1, -1, 1), padding=(padding, 0)) - img = torch.nn.functional.conv2d(img, weight=kernel_1d.view(1, 1, 1, -1), padding=(0, padding)) - return img.squeeze_(0).squeeze_(0) # Make 2D again - - -def gaussian_filter_2d_stack(stack: torch.Tensor, kernel_1d: torch.Tensor) -> torch.Tensor: - """ - Apply 2D Gaussian blur to each slice stack[:, i, :] in a vectorized way. + Encourages piecewise smoothness by penalizing differences between + adjacent elements. Args: - stack (torch.Tensor): Tensor of shape (H, N, W) where N is num_sinograms - kernel_1d (torch.Tensor): 1D Gaussian kernel + x: Input tensor of shape (N, C, L) or (N, L) or (L,) + reduction: 'mean' | 'sum' | 'none' Returns: - torch.Tensor: Blurred stack of same shape (H, N, W) + Scalar loss (or per-sample tensor if reduction='none') """ - H, N, W = stack.shape - padding = len(kernel_1d) // 2 - - # Reshape to (N, 1, H, W) for conv2d - stack_reshaped = stack.permute(1, 0, 2).unsqueeze(1) # (N, 1, H, W) - - # Apply separable conv2d: vertical then horizontal - out = torch.nn.functional.conv2d( - stack_reshaped, kernel_1d.view(1, 1, -1, 1), padding=(padding, 0) - ) - out = torch.nn.functional.conv2d(out, kernel_1d.view(1, 1, 1, -1), padding=(0, padding)) - - # Restore shape to (H, N, W) - return out.squeeze(1).permute(1, 0, 2) - - -# Circular mask - - -def torch_phase_cross_correlation(im1, im2): - f1 = torch.fft.fft2(im1) - f2 = torch.fft.fft2(im2) - cc = torch.fft.ifft2(f1 * torch.conj(f2)) - cc_abs = torch.abs(cc) - - max_idx = torch.argmax(cc_abs) - shifts = torch.tensor(np.unravel_index(max_idx.item(), im1.shape), device=im1.device).float() - - for i, dim in enumerate(im1.shape): - if shifts[i] > dim // 2: - shifts[i] -= dim - - # return shifts.flip(0) # (dx, dy) - return shifts - - -# --- Tilt Series Processing Utility Functions --- - - -def fourier_cropping(img, crop_size): - """ - Crop the img in Fourier space to the specified size. - """ - center = np.array(img.shape) // 2 - - fft_img = np.fft.fftshift(np.fft.fft2(img)) - - cropped_fft = fft_img[ - center[0] - crop_size[0] // 2 : center[0] + crop_size[0] // 2, - center[1] - crop_size[1] // 2 : center[1] + crop_size[1] // 2, - ] - cropped_img = np.fft.ifft2(np.fft.ifftshift(cropped_fft)).real - return cropped_img - - -def estimate_background( - img, - num_iterations=10, - cutoff=3, - smoothing_sigma=1.0, -): - """ - Estimate the background of the image using a Gaussian filter. - """ - if smoothing_sigma > 0: - img = gaussian_filter(img, sigma=smoothing_sigma) - pixel_vals = img.ravel() - - for i in range(num_iterations): - mu, std = norm.fit(pixel_vals) - - # Set cutoff threshold (e.g., 3 standard deviations) - lower = mu - cutoff * std - upper = mu + cutoff * std - - # Mask pixel values within ±3σ - pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] - - return mu - - -def cross_correlation_align_stack(ref_img, stack): - """ - Aligns a stack of images to a reference image using cross-correlation. - - This function assumes the stack does not contain the reference image itself. - - Stack shape should be (N, H, W) where N is the number of images. - """ - - new_images = [] - pred_shifts = [] - - prev_img = ref_img - for img in tqdm(stack): - shift_pred = cross_correlation_shift(prev_img, img) - shifted_image = shift(img, shift=shift_pred, mode="constant", cval=0.0) - - pred_shifts.append(shift_pred) - new_images.append(shifted_image) - - prev_img = shifted_image - - return new_images, pred_shifts - - -def centering_com_alignment(image_stack): - """ - Aligns the image stack to the center of mass of the whole image_stack to the - image center. This is useful for aligning the tilt series to the invariant line. - """ - - aligned_stack = np.zeros_like(image_stack) - h, w = image_stack.shape[1:] - image_center = np.array([h // 2, w // 2]) - - com_reference = np.array(center_of_mass(image_stack.mean(axis=0))) - - for i, img in enumerate(image_stack): - com_img = np.array(center_of_mass(img)) - shift_vec = com_reference - com_img - aligned_stack[i] = shift(img, shift=shift_vec, mode="constant", cval=0.0) - - final_shift = image_center - com_reference - for i in range(aligned_stack.shape[0]): - aligned_stack[i] = shift(aligned_stack[i], shift=final_shift, mode="constant", cval=0.0) - - return aligned_stack + # Difference between adjacent elements along the last dimension + diff = x[..., 1:] - x[..., :-1] # shape: (..., L-1) + tv = diff.abs() # L1 variant ← most common + + if reduction == "mean": + return tv.mean() + elif reduction == "sum": + return tv.sum() + elif reduction == "none": + return tv + else: + raise ValueError(f"Unknown reduction: {reduction!r}") diff --git a/tests/datastructures/test_dataset.py b/tests/datastructures/test_dataset.py index 93449aa2..201fe92b 100644 --- a/tests/datastructures/test_dataset.py +++ b/tests/datastructures/test_dataset.py @@ -241,28 +241,93 @@ def test_crop(self, sample_dataset_2d): """Test crop method.""" # Crop 1 pixel from each side cropped_dataset = sample_dataset_2d.crop(crop_widths=((1, 9), (1, 9))) - # Check shape assert cropped_dataset.shape == (8, 8) # Original (10, 10) - 1 from each side - + assert np.array_equal(cropped_dataset.origin, np.array([1, 1])) # Check that the original dataset is unchanged assert sample_dataset_2d.shape == (10, 10) - + assert np.array_equal(sample_dataset_2d.origin, np.array([0, 0])) # Test modify_in_place sample_dataset_2d.crop(crop_widths=((1, 9), (1, 9)), modify_in_place=True) assert sample_dataset_2d.shape == (8, 8) + assert np.array_equal(sample_dataset_2d.origin, np.array([1, 1])) + + def test_crop_4dstem_kspace(self): + """Test cropping k-space axes of a 4D-STEM dataset.""" + dset = Dataset.from_array(np.random.rand(8, 8, 96, 96)) + cropped = dset.crop(crop_widths=((4, 92), (4, 92)), axes=(2, 3)) + assert cropped.shape == (8, 8, 88, 88) + assert dset.shape == (8, 8, 96, 96) + + def test_crop_4dstem_realspace_in_place(self): + """Test in-place real-space crop of a 4D-STEM dataset.""" + dset = Dataset.from_array( + np.random.rand(16, 16, 32, 32), + origin=(10, 20, 30, 40), + sampling=(0.5, 0.25, 2, 3), + ) + dset.crop(crop_widths=((4, 12), (4, 12)), axes=(0, 1), modify_in_place=True) + assert dset.shape == (8, 8, 32, 32) + assert np.array_equal(dset.origin, np.array([12, 21, 30, 40])) + + def test_crop_4dstem_stop_zero(self): + """Test that stop=0 keeps all remaining elements.""" + dset = Dataset.from_array(np.random.rand(8, 8, 96, 96)) + cropped = dset.crop(crop_widths=((10, 0), (10, 0)), axes=(2, 3)) + assert cropped.shape == (8, 8, 86, 86) + + def test_crop_single_axis_updates_origin_in_place(self): + """Test in-place single-axis crop updates origin.""" + dset = Dataset.from_array( + np.random.rand(2048, 64), + origin=(5, 7), + sampling=(0.5, 2), + ) + dset.crop(crop_widths=((80, 2000),), axes=0, modify_in_place=True) + assert dset.shape == (1920, 64) + assert np.array_equal(dset.origin, np.array([45, 7])) + + def test_getitem_slice_updates_origin_like_crop(self): + """Test slicing keeps origin aligned with crop semantics.""" + dset = Dataset.from_array( + np.random.rand(16, 8), + origin=(10, 20), + sampling=(0.5, 2.0), + units=["nm", "nm"], + ) + + sliced = dset[4:12] + cropped = dset.crop(crop_widths=((4, 12),), axes=0) + + assert sliced.shape == (8, 8) + assert np.array_equal(sliced.origin, np.array([12, 20])) + assert np.array_equal(sliced.origin, cropped.origin) + assert np.array_equal(sliced.sampling, cropped.sampling) + + def test_getitem_slice_reduced_rank_updates_origin_and_sampling(self): + """Test slicing before integer indexing updates remaining metadata.""" + dset = Dataset.from_array( + np.random.rand(10, 6, 4), + origin=(1.5, 10, -2), + sampling=(0.25, 2.0, 5.0), + units=["nm", "nm", "1/nm"], + ) + + sliced = dset[2:8:2, 3] + + assert sliced.shape == (3, 4) + assert np.allclose(sliced.origin, np.array([2.0, -2.0])) + assert np.allclose(sliced.sampling, np.array([0.5, 5.0])) + assert sliced.units == ["nm", "1/nm"] def test_bin(self, sample_dataset_2d): """Test bin method.""" # Bin by factor of 2 binned_dataset = sample_dataset_2d.bin(bin_factors=2) - # Check shape assert binned_dataset.shape == (5, 5) # Original (10, 10) / 2 - # Check that the original dataset is unchanged assert sample_dataset_2d.shape == (10, 10) - # Test modify_in_place sample_dataset_2d.bin(bin_factors=2, modify_in_place=True) assert sample_dataset_2d.shape == (5, 5) @@ -369,3 +434,21 @@ def test_api_errors(self, sample_dataset_2d): # Neither specified with pytest.raises(ValueError): sample_dataset_2d.fourier_resample() + + +class TestDatasetTorch: + """Tests for torch-backed Dataset (from_tensor path).""" + + def test_numpy_copy_is_readonly(self): + """``.numpy()`` on a tensor-backed dataset returns a read-only CPU copy + so writes raise instead of silently updating only the detached copy. + """ + import torch + from quantem.core.datastructures.dataset4dstem import Dataset4dstem + ds = Dataset4dstem.from_tensor(torch.zeros(2, 2, 2, 2)) + arr = ds.numpy() + assert arr.flags.writeable is False + with pytest.raises(ValueError, match="read-only"): + arr[0, 0, 0, 0] = 99.0 + + diff --git a/tests/datastructures/test_dataset3d_show.py b/tests/datastructures/test_dataset3d_show.py index 27c18f41..7938c66f 100644 --- a/tests/datastructures/test_dataset3d_show.py +++ b/tests/datastructures/test_dataset3d_show.py @@ -31,16 +31,19 @@ def extract_frame_indices_from_figure(fig): class TestShowInputValidation: """Test that invalid inputs raise clear errors.""" - @pytest.mark.parametrize("kwargs,match", [ - ({"step": 0}, "cannot be zero"), - ({"start": 100}, "out of bounds"), - ({"start": -100}, "out of bounds"), - ({"start": 5, "end": 5}, "No frames to display"), - ({"ncols": 0}, "ncols must be >= 1"), - ({"ncols": -1}, "ncols must be >= 1"), - ({"max": 0}, "max must be >= 1"), - ({"max": -1}, "max must be >= 1"), - ]) + @pytest.mark.parametrize( + "kwargs,match", + [ + ({"step": 0}, "cannot be zero"), + ({"start": 100}, "out of bounds"), + ({"start": -100}, "out of bounds"), + ({"start": 5, "end": 5}, "No frames to display"), + ({"ncols": 0}, "ncols must be >= 1"), + ({"ncols": -1}, "ncols must be >= 1"), + ({"max": 0}, "max must be >= 1"), + ({"max": -1}, "max must be >= 1"), + ], + ) def test_raises_value_error(self, dataset_with_10_frames, kwargs, match): with pytest.raises(ValueError, match=match): dataset_with_10_frames.show(**kwargs) @@ -49,39 +52,44 @@ def test_raises_value_error(self, dataset_with_10_frames, kwargs, match): class TestShowFrameSelection: """Test frame selection with start, end, step, max combinations.""" - @pytest.mark.parametrize("kwargs,expected_indices", [ - ({}, list(range(20))), - ({"max": 5}, [0, 1, 2, 3, 4]), - ({"max": None}, list(range(100))), - ({"start": 90}, list(range(90, 100))), - ({"start": 95, "max": 3}, [95, 96, 97]), - ]) + @pytest.mark.parametrize( + "kwargs,expected_indices", + [ + ({}, list(range(20))), + ({"max": 5}, [0, 1, 2, 3, 4]), + ({"max": None}, list(range(100))), + ({"start": 90}, list(range(90, 100))), + ({"start": 95, "max": 3}, [95, 96, 97]), + ], + ) def test_large_dataset(self, dataset_with_100_frames, kwargs, expected_indices): fig, _ = dataset_with_100_frames.show(returnfig=True, **kwargs) assert extract_frame_indices_from_figure(fig) == expected_indices plt.close(fig) - @pytest.mark.parametrize("kwargs,expected_indices", [ - # Default shows all frames (< max) - ({}, list(range(10))), - # Start and end - ({"start": 5}, [5, 6, 7, 8, 9]), - ({"end": 5}, [0, 1, 2, 3, 4]), - # Step - ({"step": 2}, [0, 2, 4, 6, 8]), - ({"step": 3}, [0, 3, 6, 9]), - ({"start": 2, "end": 8, "step": 2}, [2, 4, 6]), - # Negative start index - ({"start": -1, "max": 1}, [9]), - ({"start": -3, "max": 2}, [7, 8]), - # Negative step (reverse order) - ({"start": 9, "step": -1}, [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]), - ({"start": 9, "end": 4, "step": -1}, [9, 8, 7, 6, 5]), - ({"start": 9, "step": -2}, [9, 7, 5, 3, 1]), - ({"start": 9, "step": -1, "max": 3}, [9, 8, 7]), - ]) + @pytest.mark.parametrize( + "kwargs,expected_indices", + [ + # Default shows all frames (< max) + ({}, list(range(10))), + # Start and end + ({"start": 5}, [5, 6, 7, 8, 9]), + ({"end": 5}, [0, 1, 2, 3, 4]), + # Step + ({"step": 2}, [0, 2, 4, 6, 8]), + ({"step": 3}, [0, 3, 6, 9]), + ({"start": 2, "end": 8, "step": 2}, [2, 4, 6]), + # Negative start index + ({"start": -1, "max": 1}, [9]), + ({"start": -3, "max": 2}, [7, 8]), + # Negative step (reverse order) + ({"start": 9, "step": -1}, [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]), + ({"start": 9, "end": 4, "step": -1}, [9, 8, 7, 6, 5]), + ({"start": 9, "step": -2}, [9, 7, 5, 3, 1]), + ({"start": 9, "step": -1, "max": 3}, [9, 8, 7]), + ], + ) def test_small_dataset(self, dataset_with_10_frames, kwargs, expected_indices): fig, _ = dataset_with_10_frames.show(returnfig=True, **kwargs) assert extract_frame_indices_from_figure(fig) == expected_indices plt.close(fig) - diff --git a/tests/datastructures/test_vector.py b/tests/datastructures/test_vector.py index e2085b76..954059d6 100644 --- a/tests/datastructures/test_vector.py +++ b/tests/datastructures/test_vector.py @@ -1,433 +1,436 @@ +import zipfile + import numpy as np import pytest from quantem.core.datastructures.vector import Vector +from quantem.core.io.serialize import load -class TestVector: - """Test suite for the Vector class.""" +def make_line_vector() -> Vector: + v = Vector.from_shape( + shape=(4,), + fields=["intensity", "kx", "ky"], + units=["a.u.", "px", "px"], + name="line", + ) + v[0] = np.array([[1.0, 10.0, 100.0], [2.0, 20.0, 200.0]]) + v[1] = np.array([[3.0, 30.0, 300.0]]) + v[2] = np.array([[4.0, 40.0, 400.0], [5.0, 50.0, 500.0]]) + v[3] = np.array([[6.0, 60.0, 600.0]]) + return v + + +def make_grid_vector() -> Vector: + v = Vector.from_shape(shape=(3, 2), fields=["intensity", "kx", "ky"]) + for i in range(3): + for j in range(2): + base = float(i * 10 + j) + v[i, j] = np.array([[base, base + 100.0, base + 200.0]]) + return v - def test_initialization(self): - """Test Vector initialization with different parameters.""" - # Test with fields - v1 = Vector.from_shape(shape=(2, 3), fields=["field0", "field1", "field2"]) + +class TestVector: + def test_initialization_and_len(self): + v1 = Vector.from_shape(shape=(2, 3), fields=["a", "b", "c"]) assert v1.shape == (2, 3) + assert len(v1) == 2 + assert v1.num_cells == 6 assert v1.num_fields == 3 - assert v1.fields == ["field0", "field1", "field2"] + assert v1.dtype == np.dtype(float) + assert v1.fields == ["a", "b", "c"] assert v1.units == ["none", "none", "none"] assert v1.name == "2d ragged array" - assert hasattr(v1, "metadata") - - # Test with num_fields - v2 = Vector.from_shape(shape=(2, 3), num_fields=3) - assert v2.shape == (2, 3) - assert v2.num_fields == 3 - assert v2.fields == ["field_0", "field_1", "field_2"] - assert v2.units == ["none", "none", "none"] - assert hasattr(v2, "metadata") - - # Test with custom name and units - v3 = Vector.from_shape( - shape=(2, 3), - fields=["field0", "field1", "field2"], - name="my_vector", - units=["unit0", "unit1", "unit2"], - ) - assert v3.name == "my_vector" - assert v3.units == ["unit0", "unit1", "unit2"] - assert hasattr(v3, "metadata") + assert v1[0, 0].array.shape == (0, 3) + np.testing.assert_array_equal(v1[0, 0].flatten(), v1[0, 0].array) + + v2 = Vector.from_shape(shape=(2, 3), num_fields=2) + assert v2.fields == ["field_0", "field_1"] + + with pytest.raises(TypeError): + len(v1[0, 0]) - # Test error cases with pytest.raises(ValueError, match="Must specify either 'fields' or 'num_fields'."): Vector.from_shape(shape=(2, 3)) with pytest.raises(ValueError, match="does not match length of fields"): - Vector.from_shape(shape=(2, 3), num_fields=3, fields=["field0", "field1"]) + Vector.from_shape(shape=(2, 3), num_fields=2, fields=["a", "b", "c"]) with pytest.raises(ValueError, match="Duplicate field names"): - Vector.from_shape(shape=(2, 3), fields=["field0", "field0", "field2"]) + Vector.from_shape(shape=(2, 3), fields=["a", "a"]) - def test_data_access(self): - """Test data access and assignment.""" - v = Vector.from_shape(shape=(2, 3), fields=["field0", "field1", "field2"]) + assert str(v1) == ( + "quantem.Vector, shape=(2, 3), name=2d ragged array\n" + " fields = ['a', 'b', 'c']\n" + " units: ['none', 'none', 'none']" + ) - # Set data at specific indices - data1 = np.array([[1.0, 2.0, 3.0]]) - v[0, 0] = data1 - np.testing.assert_array_equal(v.get_data(0, 0), data1) # type: ignore + def test_indexing_and_array_contract(self): + v = make_grid_vector() - # Test get_data method - assert np.array_equal(v.get_data(0, 0), data1) + assert isinstance(v[:2, 1], Vector) + assert v[:2, 1].shape == (2,) + assert v[1].shape == (2,) + assert v[1, 1].shape == () + np.testing.assert_array_equal(v[-1, -1].array, np.array([[21.0, 121.0, 221.0]])) - # Test set_data method - data2 = np.array([[4.0, 5.0, 6.0]]) - v.set_data(data2, 0, 1) - assert np.array_equal(v.get_data(0, 1), data2) + with pytest.raises(ValueError): + _ = v[:, 1].array - # Test error cases - with pytest.raises(IndexError): - v[2, 0] = data1 # Out of bounds + result = v[[-1, 0], 1] + assert result.shape == (2,) + assert result.num_cells == 2 + np.testing.assert_array_equal(result[0].array, np.array([[21.0, 121.0, 221.0]])) + np.testing.assert_array_equal(result[1].array, np.array([[1.0, 101.0, 201.0]])) - with pytest.raises(ValueError): - v[0, 0] = np.array([[1.0, 2.0]]) # Wrong number of fields + def test_select_fields_and_chaining_equivalence(self): + v = make_line_vector() - with pytest.raises(ValueError): - v.set_data(np.array([[1.0, 2.0]]), 0, 0) # Wrong number of fields - - def test_field_operations(self): - """Test field-level operations.""" - v = Vector.from_shape(shape=(2, 3), fields=["field0", "field1", "field2"]) - - # Set initial data - v[0, 0] = np.array([[1.0, 2.0, 3.0]]) - v[0, 1] = np.array([[4.0, 5.0, 6.0]]) - v[0, 2] = np.array([[7.0, 8.0, 9.0]]) - - # Test field access - field_view = v["field0"] - assert ( - hasattr(field_view, "vector") - and hasattr(field_view, "field_name") - and hasattr(field_view, "field_index") + selected = v.select_fields("kx") + assert selected.fields == ["kx"] + assert selected.units == ["px"] + assert selected.shape == v.shape + + np.testing.assert_array_equal( + v.select_fields("kx")[2].array, + v[2].select_fields("kx").array, ) - # Test field operations - v["field0"] += 10 # type: ignore - np.testing.assert_array_equal(v.get_data(0, 0)[:, 0], np.array([11.0])) # type: ignore - np.testing.assert_array_equal(v.get_data(0, 1)[:, 0], np.array([14.0])) # type: ignore - np.testing.assert_array_equal(v.get_data(0, 2)[:, 0], np.array([17.0])) # type: ignore + with pytest.raises(KeyError): + v.select_fields("missing") - # Test applying a function to a field - v["field1"] *= 2 # Using multiplication instead of lambda # type: ignore - np.testing.assert_array_equal(v.get_data(0, 0)[:, 1], np.array([4.0])) # type: ignore - np.testing.assert_array_equal(v.get_data(0, 1)[:, 1], np.array([10.0])) # type: ignore - np.testing.assert_array_equal(v.get_data(0, 2)[:, 1], np.array([16.0])) # type: ignore + with pytest.raises(TypeError): + _ = v["kx"] - # Test field flattening - flat = v["field2"].flatten() - np.testing.assert_array_equal(flat, np.array([3.0, 6.0, 9.0])) # type: ignore + with pytest.raises(TypeError): + _ = v[1, "kx"] + + multi = v.select_fields("intensity", "kx") + assert multi.fields == ["intensity", "kx"] + assert multi.dtype == np.dtype(float) + assert multi.total_rows == 6 + assert multi.row_counts() == [2, 1, 2, 1] + + def test_array_mutation_writes_through_for_single_field(self): + v = make_line_vector() + cell = v.select_fields("kx")[1].array + cell[0, 0] = 99.0 + assert v[1].array[0, 1] == 99.0 + + def test_set_flattened_updates_rowwise(self): + v = make_line_vector() + kx = v.select_fields("kx") + + flat_kx = kx.flatten() + mask = flat_kx >= 30.0 + flat_kx[mask[:, 0], 0] = -1.0 + kx.set_flattened(flat_kx) + + np.testing.assert_array_equal( + kx.flatten(), + np.array([[10.0], [20.0], [-1.0], [-1.0], [-1.0], [-1.0]]), + ) - # Test setting flattened data - v["field2"].set_flattened(np.array([18.0, 18.0, 18.0])) + def test_field_arithmetic_with_scalar_and_ndarray(self): + v = make_line_vector() - # Test error cases - with pytest.raises(KeyError): - v["nonexistent_field"] + kx = v.select_fields("kx") + kx += 10 + np.testing.assert_array_equal( + v.select_fields("kx").flatten(), + np.array([[20.0], [30.0], [40.0], [50.0], [60.0], [70.0]]), + ) - with pytest.raises(ValueError): - v["field0"].set_flattened(np.array([1.0, 2.0])) # Wrong length - - def test_slicing(self): - """Test slicing operations.""" - v = Vector.from_shape(shape=(4, 3), fields=["field0", "field1", "field2"]) - - # Set data - for i in range(4): - for j in range(3): - v[i, j] = np.array( - [[float(i * 3 + j), float(i * 3 + j + 1), float(i * 3 + j + 2)]] - ) - - # Test slicing - sliced = v[1:3, 1] - assert isinstance(sliced, Vector) - assert sliced.shape == (2, 1) - - # Compare arrays directly - expected1 = np.array([[4.0, 5.0, 6.0]]) - expected2 = np.array([[7.0, 8.0, 9.0]]) - np.testing.assert_array_equal(sliced.get_data(0, 0), expected1) # type: ignore - np.testing.assert_array_equal(sliced.get_data(1, 0), expected2) # type: ignore - - # Test field access on sliced vector - field_sliced = sliced["field1"] - np.testing.assert_array_equal(field_sliced.flatten(), np.array([5.0, 8.0])) # type: ignore - - # Test copying slices of vectors - v[2:4, 1] = v[1:3, 1] - - # Test copying slices of vectors with fancy indexing - v[[0, 1], 1] = v[[2, 3], 0] - - def test_field_management(self): - """Test adding and removing fields.""" - v = Vector.from_shape(shape=(2, 3), fields=["field0", "field1", "field2"]) - - # Set initial data - v[0, 0] = np.array([[1.0, 2.0, 3.0]]) - - # Test adding fields - v.add_fields(["field3", "field4"]) - assert v.num_fields == 5 - assert v.fields == ["field0", "field1", "field2", "field3", "field4"] - assert v.units == ["none", "none", "none", "none", "none"] - - # Check that new fields are initialized to zeros - np.testing.assert_array_equal(v.get_data(0, 0)[:, 3:5], np.array([[0.0, 0.0]])) # type: ignore - - # Test removing fields - v.remove_fields(["field1", "field3"]) - assert v.num_fields == 3 - assert v.fields == ["field0", "field2", "field4"] - assert v.units == ["none", "none", "none"] - - # Check that data is preserved for remaining fields - np.testing.assert_array_equal(v.get_data(0, 0)[:, 0], np.array([1.0])) # type: ignore - np.testing.assert_array_equal(v.get_data(0, 0)[:, 1], np.array([3.0])) # type: ignore - np.testing.assert_array_equal(v.get_data(0, 0)[:, 2], np.array([0.0])) # type: ignore - - # Test error cases - with pytest.raises(ValueError): - v.add_fields(["field0"]) # Duplicate field - - v.remove_fields(["nonexistent_field"]) # Should just print a warning - - def test_copy(self): - """Test deep copying.""" - v = Vector.from_shape(shape=(2, 3), fields=["field0", "field1", "field2"]) - v[0, 0] = np.array([[1.0, 2.0, 3.0]]) - - # Create a copy - v_copy = v.copy() - - # Check that it's a deep copy - assert v_copy is not v - assert v_copy.shape == v.shape - assert v_copy.fields == v.fields - assert v_copy.units == v.units - np.testing.assert_array_equal(v_copy.get_data(0, 0), v.get_data(0, 0)) # type: ignore - - # Modify the copy and check that the original is unchanged - v_copy[0, 0] = np.array([[4.0, 5.0, 6.0]]) - np.testing.assert_array_equal(v.get_data(0, 0), np.array([[1.0, 2.0, 3.0]])) # type: ignore - - def test_flatten(self): - """Test flattening the entire vector.""" - v = Vector.from_shape(shape=(2, 3), fields=["field0", "field1", "field2"]) - - # Set data - v[0, 0] = np.array([[1.0, 2.0, 3.0]]) - v[0, 1] = np.array([[4.0, 5.0, 6.0]]) - v[0, 2] = np.array([[7.0, 8.0, 9.0]]) - v[1, 0] = np.array([[10.0, 11.0, 12.0]]) - v[1, 1] = np.array([[13.0, 14.0, 15.0]]) - v[1, 2] = np.array([[16.0, 17.0, 18.0]]) - - # Flatten the vector - flattened = v.flatten() - - # Check the flattened array - expected = np.array( - [ - [1.0, 2.0, 3.0], - [4.0, 5.0, 6.0], - [7.0, 8.0, 9.0], - [10.0, 11.0, 12.0], - [13.0, 14.0, 15.0], - [16.0, 17.0, 18.0], - ] + v.select_fields("kx")[...] += np.arange(6) + np.testing.assert_array_equal( + v.select_fields("kx").flatten(), + np.array([[20.0], [31.0], [42.0], [53.0], [64.0], [75.0]]), ) - np.testing.assert_array_equal(flattened, expected) # type: ignore - def test_from_data(self): - """Test creating a Vector from ragged lists or numpy arrays.""" - # Create test data - data = [ - np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), - np.array([[7.0, 8.0, 9.0]]), - np.array([[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]]), - ] + summed = v.select_fields("intensity") + v.select_fields("ky") + np.testing.assert_array_equal( + summed.flatten(), + np.array([[101.0], [202.0], [303.0], [404.0], [505.0], [606.0]]), + ) - # Test with explicit fields - v1 = Vector.from_data( - data=data, - fields=["field0", "field1", "field2"], - name="test_vector", - units=["unit0", "unit1", "unit2"], + def test_power_operations(self): + v = make_line_vector() + + squared = v.select_fields("intensity") ** 2 + np.testing.assert_array_equal( + squared.flatten(), + np.array([[1.0], [4.0], [9.0], [16.0], [25.0], [36.0]]), ) - # Check properties - assert v1.shape == (3,) - assert v1.num_fields == 3 - assert v1.fields == ["field0", "field1", "field2"] - assert v1.units == ["unit0", "unit1", "unit2"] - assert v1.name == "test_vector" + intensity = v.select_fields("intensity") + intensity **= 2 + np.testing.assert_array_equal( + intensity.flatten(), + np.array([[1.0], [4.0], [9.0], [16.0], [25.0], [36.0]]), + ) - # Check data - np.testing.assert_array_equal(v1.get_data(0), data[0]) # type: ignore - np.testing.assert_array_equal(v1.get_data(1), data[1]) # type: ignore - np.testing.assert_array_equal(v1.get_data(2), data[2]) # type: ignore + reverse = 2 ** v.select_fields("intensity") + np.testing.assert_array_equal( + reverse.flatten(), + np.array([[2.0], [16.0], [512.0], [65536.0], [33554432.0], [68719476736.0]]), + ) - # Test with inferred fields - v2 = Vector.from_data(data=data, num_fields=3) + def test_unary_mod_and_floor_division_operations(self): + v = make_line_vector() - # Check properties - assert v2.shape == (3,) - assert v2.num_fields == 3 - assert v2.fields == ["field_0", "field_1", "field_2"] - assert v2.units == ["none", "none", "none"] + negative = -v.select_fields("intensity") + np.testing.assert_array_equal( + negative.flatten(), + np.array([[-1.0], [-2.0], [-3.0], [-4.0], [-5.0], [-6.0]]), + ) - # Check data - np.testing.assert_array_equal(v2.get_data(0), data[0]) # type: ignore - np.testing.assert_array_equal(v2.get_data(1), data[1]) # type: ignore - np.testing.assert_array_equal(v2.get_data(2), data[2]) # type: ignore + absolute = abs(negative) + np.testing.assert_array_equal( + absolute.flatten(), + np.array([[1.0], [2.0], [3.0], [4.0], [5.0], [6.0]]), + ) - # Test error cases - with pytest.raises(TypeError, match="Data must be a list"): - Vector.from_data(data=np.array([1, 2, 3])) # type: ignore + floored = v.select_fields("ky") // 150 + np.testing.assert_array_equal( + floored.flatten(), + np.array([[0.0], [1.0], [2.0], [2.0], [3.0], [4.0]]), + ) - with pytest.raises(ValueError, match="does not match length of fields"): - Vector.from_data( - data=data, - fields=["field0", "field1"], # Wrong number of fields - ) + modded = v.select_fields("ky") % 150 + np.testing.assert_array_equal( + modded.flatten(), + np.array([[100.0], [50.0], [0.0], [100.0], [50.0], [0.0]]), + ) - with pytest.raises(ValueError, match="Duplicate field names"): - Vector.from_data( - data=data, - fields=["field0", "field0", "field2"], # Duplicate field names - ) - - def test_fancy_indexing(self): - """Test fancy indexing with __getitem__ and __setitem__.""" - v = Vector.from_shape(shape=(3, 2), fields=["field0", "field1", "field2"]) - - # Set initial data - v[0, 0] = np.array([[1.0, 2.0, 3.0]]) - v[0, 1] = np.array([[4.0, 5.0, 6.0]]) - v[1, 0] = np.array([[7.0, 8.0, 9.0]]) - v[1, 1] = np.array([[10.0, 11.0, 12.0]]) - v[2, 0] = np.array([[13.0, 14.0, 15.0]]) - v[2, 1] = np.array([[16.0, 17.0, 18.0]]) - - # Test list indexing with __getitem__ - result = v[[0, 1], 0] - assert isinstance(result, Vector) - assert result.shape == (2, 1) - np.testing.assert_array_equal(result.get_data(0, 0), np.array([[1.0, 2.0, 3.0]])) - np.testing.assert_array_equal(result.get_data(1, 0), np.array([[7.0, 8.0, 9.0]])) - - # Test numpy array indexing with __getitem__ - result = v[np.array([1, 2]), 1] # type: ignore - assert isinstance(result, Vector) - assert result.shape == (2, 1) - np.testing.assert_array_equal(result.get_data(0, 0), np.array([[10.0, 11.0, 12.0]])) - np.testing.assert_array_equal(result.get_data(1, 0), np.array([[16.0, 17.0, 18.0]])) - - # Test fancy indexing with __setitem__ - new_data = [np.array([[20.0, 21.0, 22.0]]), np.array([[23.0, 24.0, 25.0]])] - v[[0, 2], 1] = new_data - np.testing.assert_array_equal(v.get_data(0, 1), new_data[0]) - np.testing.assert_array_equal(v.get_data(2, 1), new_data[1]) - - # Test numpy array fancy indexing with __setitem__ - new_data = [np.array([[26.0, 27.0, 28.0]]), np.array([[29.0, 30.0, 31.0]])] - v[np.array([1, 2]), 0] = new_data # type: ignore - np.testing.assert_array_equal(v.get_data(1, 0), new_data[0]) - np.testing.assert_array_equal(v.get_data(2, 0), new_data[1]) - - # Test error cases - with pytest.raises(IndexError): - v[[3, 4], 0] # Index out of bounds + ky = v.select_fields("ky") + ky //= 150 + np.testing.assert_array_equal( + ky.flatten(), + np.array([[0.0], [1.0], [2.0], [2.0], [3.0], [4.0]]), + ) - with pytest.raises(IndexError): - v[[0, 1], 2] # Index out of bounds + intensity = v.select_fields("intensity") + intensity %= 2 + np.testing.assert_array_equal( + intensity.flatten(), + np.array([[1.0], [0.0], [1.0], [0.0], [1.0], [0.0]]), + ) - with pytest.raises(ValueError): - v[[0, 1], 0] = [np.array([[1.0]])] # Wrong number of arrays + def test_numpy_ufunc_support(self): + v = make_line_vector() - with pytest.raises(ValueError): - v[[0, 1], 0] = [ - np.array([[1.0]]), - np.array([[2.0]]), - ] # Wrong number of fields - - def test_get_data_methods(self): - """Test get_data method with various indexing scenarios.""" - v = Vector.from_shape(shape=(3, 2), fields=["field0", "field1", "field2"]) - - # Set some test data - v[0, 0] = np.array([[1.0, 2.0, 3.0]]) - v[0, 1] = np.array([[4.0, 5.0, 6.0]]) - v[1, 0] = np.array([[7.0, 8.0, 9.0]]) - v[1, 1] = np.array([[10.0, 11.0, 12.0]]) - v[2, 0] = np.array([[13.0, 14.0, 15.0]]) - v[2, 1] = np.array([[16.0, 17.0, 18.0]]) - - # Test single integer indexing - result = v.get_data(0, 0) - np.testing.assert_array_equal(result, np.array([[1.0, 2.0, 3.0]])) - - # Test list indexing - result = v.get_data([0, 1], 0) - np.testing.assert_array_equal(result[0], np.array([[1.0, 2.0, 3.0]])) - np.testing.assert_array_equal(result[1], np.array([[7.0, 8.0, 9.0]])) - - # Test numpy array indexing - result = v.get_data(np.array([1, 2]), 1) - np.testing.assert_array_equal(result[0], np.array([[10.0, 11.0, 12.0]])) - np.testing.assert_array_equal(result[1], np.array([[16.0, 17.0, 18.0]])) - - # Test slice indexing - result = v.get_data(slice(1, 3), 0) - np.testing.assert_array_equal(result[0], np.array([[7.0, 8.0, 9.0]])) - np.testing.assert_array_equal(result[1], np.array([[13.0, 14.0, 15.0]])) - - # Test error cases - with pytest.raises(ValueError, match="Expected 2 indices"): - v.get_data(0) # Too few indices - - with pytest.raises(ValueError, match="Expected 2 indices"): - v.get_data(0, 0, 0) # Too many indices + sine = np.sin(v.select_fields("kx")) + np.testing.assert_allclose( + sine.flatten(), + np.sin(v.select_fields("kx").flatten()), + ) - with pytest.raises(IndexError): - v.get_data(3, 0) # Index out of bounds + maximum = np.maximum(v.select_fields("intensity"), 3.0) # type: ignore[arg-type] + np.testing.assert_array_equal( + maximum.flatten(), + np.array([[3.0], [3.0], [3.0], [4.0], [5.0], [6.0]]), + ) - with pytest.raises(IndexError): - v.get_data([3, 4], 0) # List index out of bounds - - def test_set_data_methods(self): - """Test set_data method with various indexing scenarios.""" - v = Vector.from_shape(shape=(3, 2), fields=["field0", "field1", "field2"]) - - # Test single integer indexing - data1 = np.array([[1.0, 2.0, 3.0]]) - v.set_data(data1, 0, 0) - np.testing.assert_array_equal(v.get_data(0, 0), data1) - - # Test list indexing - data2 = [np.array([[4.0, 5.0, 6.0]]), np.array([[7.0, 8.0, 9.0]])] - v.set_data(data2, [0, 1], 1) - np.testing.assert_array_equal(v.get_data(0, 1), data2[0]) - np.testing.assert_array_equal(v.get_data(1, 1), data2[1]) - - # Test numpy array indexing - data3 = [np.array([[10.0, 11.0, 12.0]]), np.array([[13.0, 14.0, 15.0]])] - v.set_data(data3, np.array([1, 2]), 0) - np.testing.assert_array_equal(v.get_data(1, 0), data3[0]) - np.testing.assert_array_equal(v.get_data(2, 0), data3[1]) - - # Test slice indexing - data4 = [np.array([[16.0, 17.0, 18.0]]), np.array([[19.0, 20.0, 21.0]])] - v.set_data(data4, slice(1, 3), 1) - np.testing.assert_array_equal(v.get_data(1, 1), data4[0]) - np.testing.assert_array_equal(v.get_data(2, 1), data4[1]) - - # Test error cases - with pytest.raises(ValueError, match="Expected 2 indices"): - v.set_data(data1, 0) # Too few indices - - with pytest.raises(ValueError, match="Expected 2 indices"): - v.set_data(data1, 0, 0, 0) # Too many indices + frac, whole = np.modf(v.select_fields("intensity") / 2.0) + np.testing.assert_allclose( + frac.flatten(), + np.array([[0.5], [0.0], [0.5], [0.0], [0.5], [0.0]]), + ) + np.testing.assert_allclose( + whole.flatten(), + np.array([[0.0], [1.0], [1.0], [2.0], [2.0], [3.0]]), + ) - with pytest.raises(IndexError): - v.set_data(data1, 3, 0) # Index out of bounds + def test_field_assignment_from_vector_expression(self): + v = make_line_vector() + scale = 2.5 + + v[:2].select_fields("intensity")[...] = v[2:4].select_fields("intensity") * scale + np.testing.assert_array_equal( + v[:2].select_fields("intensity").flatten(), + np.array([[10.0], [12.5], [15.0]]), + ) + + def test_field_assignment_requires_matching_per_cell_row_counts(self): + v = make_line_vector() + with pytest.raises(ValueError, match="Per-cell row counts must match"): + v[:2].select_fields("intensity")[...] = v[1:3].select_fields("intensity") + + def test_full_cell_assignment_allows_row_count_changes(self): + v = make_line_vector() + + v[1] = v[0] + assert v[1].array.shape == (2, 3) + np.testing.assert_array_equal(v[1].array, v[0].array) + + v[0:2] = v[1:3] + assert v[0].array.shape == (2, 3) + assert v[1].array.shape == (2, 3) + + broadcast_cell = np.array([[9.0, 8.0, 7.0]]) + v[[0, 3]] = broadcast_cell + np.testing.assert_array_equal(v[0].array, broadcast_cell) + np.testing.assert_array_equal(v[3].array, broadcast_cell) + + def test_append_rows_and_compact(self): + v = make_line_vector() + + v.append_rows(1, np.array([[7.0, 70.0, 700.0]])) + np.testing.assert_array_equal( + v[1].array, + np.array([[3.0, 30.0, 300.0], [7.0, 70.0, 700.0]]), + ) + + v[1] = np.array([[8.0, 80.0, 800.0]]) + assert v._state["data"].shape[0] > v.total_rows + + v.compact() + assert v._state["data"].shape[0] == v.total_rows + + with pytest.raises(ValueError, match="exactly one cell"): + v.append_rows(slice(None), np.array([[1.0, 2.0, 3.0]])) + + def test_boolean_indexing_is_axis_wise(self): + v = make_grid_vector() + + rows = np.array([True, False, True]) + cols = np.array([False, True]) + selected = v[rows, cols] + + assert selected.shape == (2, 1) + np.testing.assert_array_equal(selected[0, 0].array, np.array([[1.0, 101.0, 201.0]])) + np.testing.assert_array_equal(selected[1, 0].array, np.array([[21.0, 121.0, 221.0]])) with pytest.raises(IndexError): - v.set_data([data1, data1], [3, 4], 0) # List index out of bounds + _ = v[np.array([[True, False], [False, True]])] - with pytest.raises(TypeError): - v.set_data([1, 2, 3], 0, 0) # Invalid data type # type: ignore + def test_empty_selection_is_valid_and_no_op_for_scalar_math(self): + v = make_grid_vector() + before = v.copy().flatten() - with pytest.raises(ValueError): - v.set_data(np.array([[1.0]]), 0, 0) # Wrong number of fields + empty = v[[], :] + assert empty.shape == (0, 2) + assert empty.flatten().shape == (0, 3) + + empty.select_fields("kx")[...] += 1 + np.testing.assert_array_equal(v.flatten(), before) + + def test_add_fields_defaults_expression_and_multiple_values(self): + v = make_line_vector() + + v.add_fields(("h", "k")) + assert v.fields == ["intensity", "kx", "ky", "h", "k"] + assert np.isnan(v[0].array[:, 3:5]).all() + + v.add_fields("field_out", v.select_fields("kx") + v.select_fields("ky")) + np.testing.assert_array_equal( + v.select_fields("field_out").flatten(), + np.array([[110.0], [220.0], [330.0], [440.0], [550.0], [660.0]]), + ) + + v2 = make_line_vector() + v2.add_fields(("h", "k"), (1.0, np.array([5.0, 6.0, 7.0, 8.0, 9.0, 10.0]))) + np.testing.assert_array_equal(v2.select_fields("h").flatten(), np.ones((6, 1))) + np.testing.assert_array_equal( + v2.select_fields("k").flatten(), + np.array([[5.0], [6.0], [7.0], [8.0], [9.0], [10.0]]), + ) + + with pytest.raises(ValueError, match="all fields are selected"): + v2.select_fields("kx").add_fields("bad") + + def test_rename_fields(self): + v = make_line_vector() + kx_data = v.select_fields("kx").flatten().copy() + + v.rename_fields({"kx": "qx", "ky": "qy"}) + assert v.fields == ["intensity", "qx", "qy"] + np.testing.assert_array_equal(v.select_fields("qx").flatten(), kx_data) + + # Renaming through a field-selected view updates that view's selected names + view = v.select_fields("qx") + assert view.fields == ["qx"] + view.rename_fields({"qx": "px"}) + assert view.fields == ["px"] + assert v.fields == ["intensity", "px", "qy"] + + with pytest.raises(KeyError, match="Unknown field"): + v.rename_fields({"nonexistent": "x"}) + + with pytest.raises(ValueError, match="already exist"): + v.rename_fields({"px": "intensity"}) + + def test_remove_fields_preserves_remaining_data(self): + v = make_line_vector() + v.add_fields("extra", 1.0) + v.remove_fields(("kx", "extra")) + + assert v.fields == ["intensity", "ky"] + np.testing.assert_array_equal( + v[0].array, + np.array([[1.0, 100.0], [2.0, 200.0]]), + ) + + def test_copy_is_deep(self): + v = make_line_vector() + v_copy = v.select_fields(["intensity", "kx"]).copy() + + v_copy[0].array[0, 0] = -1.0 + assert v[0].array[0, 0] == 1.0 + assert v_copy.fields == ["intensity", "kx"] + assert v_copy.shape == (4,) + + def test_from_data_supports_nested_fixed_grid(self): + data = [ + [np.array([[1.0, 2.0]]), np.array([[3.0, 4.0], [5.0, 6.0]])], + [np.array([[7.0, 8.0]]), np.array([[9.0, 10.0]])], + ] + v = Vector.from_data(data=data, fields=["a", "b"], units=["u1", "u2"], name="nested") + + assert v.shape == (2, 2) + assert v.fields == ["a", "b"] + assert v.units == ["u1", "u2"] + assert v.name == "nested" + np.testing.assert_array_equal(v[0, 1].array, np.array([[3.0, 4.0], [5.0, 6.0]])) + + tuple_cells = [ + ([1.0, 2.0], [3.0, 4.0]), + ([5.0, 6.0], [7.0, 8.0], [9.0, 10.0]), + ] + tuple_vector = Vector.from_data(data=tuple_cells, fields=["a", "b"]) + assert tuple_vector.shape == (2,) + np.testing.assert_array_equal(tuple_vector[0].array, np.array([[1.0, 2.0], [3.0, 4.0]])) + np.testing.assert_array_equal( + tuple_vector[1].array, + np.array([[5.0, 6.0], [7.0, 8.0], [9.0, 10.0]]), + ) + + tuple_data = (np.array([[1.0, 2.0]]), np.array([[3.0, 4.0]])) + tuple_outer = Vector.from_data(data=tuple_data, fields=["a", "b"]) + assert tuple_outer.shape == (2,) + + with pytest.raises(TypeError, match="Data must be a list or tuple"): + Vector.from_data(data=np.array([1, 2, 3])) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="same number of fields"): + Vector.from_data(data=[np.array([[1.0, 2.0]]), np.array([[1.0, 2.0, 3.0]])]) + + def test_save_and_load_round_trip(self, tmp_path): + v = make_grid_vector() + v.add_fields("extra", v.select_fields("intensity") + 1.0) + + path = tmp_path / "vector_test.zip" + v.save(path, mode="o", compression_level=4) + + with zipfile.ZipFile(path) as zf: + names = [info.filename for info in zf.infolist()] + assert len(names) < 30 + assert "_state/data/zarr.json" in names + assert all(not name.startswith("_selection_coords/") for name in names) + + loaded = load(path) + assert isinstance(loaded, Vector) + assert loaded.shape == v.shape + assert loaded.fields == v.fields + assert loaded.units == v.units + np.testing.assert_array_equal(loaded[2, 1].array, v[2, 1].array) diff --git a/tests/imaging/test_lattice.py b/tests/imaging/test_lattice.py new file mode 100644 index 00000000..1b9a7d44 --- /dev/null +++ b/tests/imaging/test_lattice.py @@ -0,0 +1,179 @@ +import numpy as np +import pytest +from numpy.testing import assert_array_almost_equal + +from quantem.core.datastructures.dataset2d import Dataset2d +from quantem.core.io.serialize import load +from quantem.imaging.lattice import Lattice + + +class TestLatticeInit: + """Test Lattice initialization and from_data.""" + + def test_init_and_constructor(self): + """Test that direct init is blocked and from_data works.""" + image = np.random.randn(100, 100) + ds2d = Dataset2d.from_array(image) + + with pytest.raises(RuntimeError, match="Use Lattice.from_data"): + Lattice(ds2d) + + lattice_img = Lattice.from_data(image) + lattice_dset = Lattice.from_data(ds2d) + assert isinstance(lattice_img, Lattice) + assert lattice_img.image is not None + assert isinstance(lattice_dset, Lattice) + assert lattice_dset.image is not None + + def test_normalization(self): + """Test min/max normalization.""" + image = np.random.randn(100, 100) * 1000.0 + image[0, 0] = -10.0 + image[99, 99] = 1000.0 + + # Both normalizations + lattice = Lattice.from_data(image) + assert lattice.image.array.min() == 0 + assert lattice.image.array.max() == 1 + + # No normalization + lattice = Lattice.from_data(image, normalize_min=False, normalize_max=False) + assert_array_almost_equal(lattice.image.array, image) + + # Min normalization + lattice = Lattice.from_data(image, normalize_min=True, normalize_max=False) + assert lattice.image.array.min() == 0 + + # Max normalization + lattice = Lattice.from_data(image, normalize_min=False, normalize_max=True) + assert lattice.image.array.max() == 1 + + def test_edge_cases(self): + """Test NaN handling.""" + nan_arr = np.array([[1, np.nan], [3, 4]], dtype=float) + lattice = Lattice.from_data(nan_arr) + assert isinstance(lattice, Lattice) + + def test_invalid_inputs(self): + """Test that invalid inputs raise errors.""" + with pytest.raises(ValueError, match="must be a 2D array"): + Lattice.from_data(np.array([1, 2, 3])) + + with pytest.raises(ValueError, match="must be a 2D array"): + Lattice.from_data(np.ones((2, 2, 2))) + + with pytest.raises(ValueError, match="must not be empty"): + Lattice.from_data(np.array([[]])) + + +class TestLatticeImage: + """Test image property getter and setter.""" + + def test_image_property(self): + """Test getting and setting image.""" + image = np.random.randn(100, 100) + lattice = Lattice.from_data(image) + + # Get + assert isinstance(lattice.image, Dataset2d) + + # Set with new array + new_image = np.random.randn(50, 50) + lattice.image = new_image + assert lattice.image.array.shape == (50, 50) + + # Invalid set + with pytest.raises(ValueError, match="must be a 2D array"): + lattice.image = np.array([1, 2, 3]) + + +class TestDefineLatticeVectors: + """Test define_lattice_vectors method.""" + + def test_basic_define(self): + """Test basic lattice definition.""" + image = np.random.randn(100, 100) + lattice = Lattice.from_data(image) + + result = lattice.define_lattice_vectors( + origin=[50, 50], u=[5, 0], v=[0, 5], refine_lattice=False + ) + + assert result is lattice + assert hasattr(lattice, "_lat") + assert lattice._lat.shape == (3, 2) + + def test_refinement_options(self): + """Test lattice refinement and block_size.""" + image = np.random.randn(100, 100) + lattice = Lattice.from_data(image) + + # With refinement + lattice.define_lattice_vectors( + origin=[50, 50], + u=[5, 0], + v=[0, 5], + refine_lattice=True, + refine_maxiter=5, + ) + assert lattice._lat.shape == (3, 2) + + # With block_size + lattice.define_lattice_vectors( + origin=[50, 50], + u=[5, 0], + v=[0, 5], + refine_lattice=True, + refine_maxiter=5, + block_size=5, + ) + assert lattice._lat.shape == (3, 2) + + def test_invalid_lattice_params(self): + """Test invalid lattice parameters.""" + image = np.random.randn(100, 100) + lattice = Lattice.from_data(image) + + # Wrong shape + with pytest.raises(ValueError): + lattice.define_lattice_vectors(origin=[1, 2, 3], u=[5, 0], v=[0, 5]) + + # Negative block_size + with pytest.raises(ValueError): + lattice.define_lattice_vectors(origin=[50, 50], u=[5, 0], v=[0, 5], block_size=-1) + + # Origin out of bounds + with pytest.raises(ValueError): + lattice.define_lattice_vectors(origin=[10, 105], u=[5, 0], v=[0, 5]) + + # Non-ivertible lattice vectors + with pytest.raises(ValueError): + lattice.define_lattice_vectors(origin=[50, 50], u=[5, 0], v=[10, 0]) + + +class TestLatticeSerialize: + """Test Lattice Autoserialize implementation.""" + + @pytest.mark.parametrize("store", ["zip", "dir"]) + def test_lattice_save_load(self, tmp_path, store): + """Test save/load of lattice.""" + # Create lattice with image and defined lattice + image = np.random.randn(100, 100) + lattice = Lattice.from_data(image) + lattice.define_lattice_vectors(origin=[50, 50], u=[5, 0], v=[0, 5], refine_lattice=False) + + # Save + filepath = tmp_path / ("lattice.zip" if store == "zip" else "lattice_dir") + lattice.save(str(filepath), mode="w", store=store) + + # Load + loaded = load(str(filepath)) + + # Verify + assert isinstance(loaded, Lattice) + assert isinstance(loaded.image, Dataset2d) + assert loaded.image.array.shape == lattice.image.array.shape + assert np.allclose(loaded.image.array, lattice.image.array) + assert hasattr(loaded, "_lat") + assert loaded._lat.shape == (3, 2) + assert np.allclose(loaded._lat, lattice._lat) diff --git a/tests/ml/test_optimizermixin.py b/tests/ml/test_optimizermixin.py new file mode 100644 index 00000000..d6347ce6 --- /dev/null +++ b/tests/ml/test_optimizermixin.py @@ -0,0 +1,453 @@ +"""Tests for OptimizerParams and SchedulerParams dataclasses.""" + +import pytest + +# Now import the module under test — adjust the path if needed +from quantem.core.ml.optimizer_mixin import ( + OptimizerParams, + SchedulerParams, +) + +# ─── OptimizerParams defaults ─────────────────────────────────────────────── + + +class TestAdamDefaults: + def test_defaults(self): + adam = OptimizerParams.Adam() + assert adam.lr == 1e-3 + assert adam.betas == (0.9, 0.999) + assert adam.eps == 1e-8 + assert adam.weight_decay == 0 + assert adam._name == "adam" + + def test_params_dict(self): + adam = OptimizerParams.Adam(lr=0.01, weight_decay=1e-4) + p = adam.params() + assert p == { + "lr": 0.01, + "betas": (0.9, 0.999), + "eps": 1e-8, + "weight_decay": 1e-4, + } + + def test_custom_betas(self): + adam = OptimizerParams.Adam(betas=(0.8, 0.99)) + assert adam.params()["betas"] == (0.8, 0.99) + + +class TestAdamWDefaults: + def test_defaults(self): + adamw = OptimizerParams.AdamW() + assert adamw.lr == 1e-3 + assert adamw._name == "adamw" + + def test_params_dict(self): + adamw = OptimizerParams.AdamW(lr=5e-4, eps=1e-7) + p = adamw.params() + assert p["lr"] == 5e-4 + assert p["eps"] == 1e-7 + + +class TestSGDDefaults: + def test_defaults(self): + sgd = OptimizerParams.SGD() + assert sgd.lr == 1e-3 + assert sgd.momentum == 0 + assert sgd.dampening == 0 + assert sgd.nesterov is False + assert sgd._name == "sgd" + + def test_params_dict(self): + sgd = OptimizerParams.SGD(lr=0.1, momentum=0.9, nesterov=True) + p = sgd.params() + assert p == { + "lr": 0.1, + "momentum": 0.9, + "dampening": 0, + "weight_decay": 0, + "nesterov": True, + } + + +class TestNoneOptimizer: + def test_defaults(self): + none_opt = OptimizerParams.NoneOptimizer() + assert none_opt._name == "none" + assert none_opt.params() == {} + + +# ─── OptimizerParams.parse_dict ───────────────────────────────────────────── + + +class TestOptimizerParseDict: + def test_parse_adam(self): + result = OptimizerParams.parse_dict({"name": "adam", "lr": 0.01}) + assert isinstance(result, OptimizerParams.Adam) + assert result.lr == 0.01 + + def test_parse_adamw(self): + result = OptimizerParams.parse_dict({"name": "adamw", "weight_decay": 0.1}) + assert isinstance(result, OptimizerParams.AdamW) + assert result.weight_decay == 0.1 + + def test_parse_sgd(self): + result = OptimizerParams.parse_dict({"name": "sgd", "momentum": 0.9}) + assert isinstance(result, OptimizerParams.SGD) + assert result.momentum == 0.9 + + def test_parse_case_insensitive(self): + result = OptimizerParams.parse_dict({"name": "Adam"}) + assert isinstance(result, OptimizerParams.Adam) + + def test_parse_unknown_raises(self): + with pytest.raises(ValueError, match="Unknown optimizer type"): + OptimizerParams.parse_dict({"name": "rmsprop"}) + + def test_parse_does_not_mutate_input(self): + d = {"name": "adam", "lr": 0.01} + original = dict(d) + OptimizerParams.parse_dict(d) + assert d == original + + def test_parse_invalid_name_type_raises(self): + with pytest.raises(ValueError, match="Unknown optimizer type"): + OptimizerParams.parse_dict({"name": 42}) + + +# ─── parse_dict "name" vs "type" key handling ─────────────────────────────── + + +class TestOptimizerParseDictKeyHandling: + def test_parse_with_type_key(self): + result = OptimizerParams.parse_dict({"type": "adam", "lr": 0.01}) + assert isinstance(result, OptimizerParams.Adam) + assert result.lr == 0.01 + + def test_name_takes_precedence_over_type(self): + result = OptimizerParams.parse_dict({"name": "adam", "type": "sgd"}) + assert isinstance(result, OptimizerParams.Adam) + + def test_neither_name_nor_type_raises(self): + with pytest.raises(ValueError, match="Must provide either"): + OptimizerParams.parse_dict({"lr": 0.01}) + + def test_type_key_not_leaked_into_constructor(self): + """'type' should be popped from d so it doesn't become an unexpected kwarg.""" + result = OptimizerParams.parse_dict({"type": "sgd", "momentum": 0.9}) + assert isinstance(result, OptimizerParams.SGD) + assert result.momentum == 0.9 + + def test_both_keys_popped_when_name_used(self): + """Even when 'name' is used, 'type' should be popped so it doesn't leak.""" + result = OptimizerParams.parse_dict({"name": "adam", "type": "ignored", "lr": 0.05}) + assert isinstance(result, OptimizerParams.Adam) + assert result.lr == 0.05 + + +class TestSchedulerParseDictKeyHandling: + def test_parse_with_type_key(self): + result = SchedulerParams.parse_dict({"type": "plateau", "patience": 20}) + assert isinstance(result, SchedulerParams.Plateau) + assert result.patience == 20 + + def test_name_takes_precedence_over_type(self): + result = SchedulerParams.parse_dict({"name": "plateau", "type": "linear"}) + assert isinstance(result, SchedulerParams.Plateau) + + def test_neither_name_nor_type_defaults_to_none(self): + result = SchedulerParams.parse_dict({"patience": 20}) + assert isinstance(result, SchedulerParams.NoneScheduler) + + def test_type_key_not_leaked_into_constructor(self): + result = SchedulerParams.parse_dict({"type": "cyclic", "step_size_up": 50}) + assert isinstance(result, SchedulerParams.Cyclic) + assert result.step_size_up == 50 + + def test_both_keys_popped_when_name_used(self): + result = SchedulerParams.parse_dict({"name": "plateau", "type": "ignored", "patience": 5}) + assert isinstance(result, SchedulerParams.Plateau) + assert result.patience == 5 + + +# ─── SchedulerParams defaults ─────────────────────────────────────────────── + + +class TestPlateauDefaults: + def test_defaults(self): + p = SchedulerParams.Plateau() + assert p.mode == "min" + assert p.factor == 0.5 + assert p.patience == 10 + assert p.cooldown == 50 + assert p.min_lr is None + assert p._name == "plateau" + + def test_params_computes_min_lr(self): + p = SchedulerParams.Plateau() + result = p.params(base_LR=0.01) + assert result["min_lr"] == pytest.approx(0.01 / 20) + + def test_params_explicit_min_lr(self): + p = SchedulerParams.Plateau(min_lr=1e-6) + result = p.params(base_LR=0.01) + assert result["min_lr"] == 1e-6 + + +class TestExponentialDefaults: + def test_defaults(self): + e = SchedulerParams.Exponential() + assert e.gamma == 0.9 + assert e._name == "exponential" + + def test_params_with_num_iter(self): + e = SchedulerParams.Exponential(factor=None) + result = e.params(base_LR=0.01, num_iter=100) + assert result == {"gamma": 0.9} + + def test_params_factor_overrides_gamma(self): + e = SchedulerParams.Exponential(factor=0.01) + result = e.params(base_LR=0.01, num_iter=100) + expected_gamma = 0.01 ** (1.0 / 100) + assert result["gamma"] == pytest.approx(expected_gamma) + + def test_params_no_num_iter_raises(self): + e = SchedulerParams.Exponential() + with pytest.raises(ValueError, match="num_iter must be set"): + e.params(base_LR=0.01, num_iter=None) + + def test_params_uses_own_num_iter(self): + e = SchedulerParams.Exponential(num_iter=50, factor=None) + result = e.params(base_LR=0.01, num_iter=None) + assert result == {"gamma": 0.9} + + +class TestCyclicDefaults: + def test_defaults(self): + c = SchedulerParams.Cyclic() + assert c.mode == "triangular2" + assert c.cycle_momentum is False + assert c._name == "cyclic" + + def test_params_computes_lr_bounds(self): + c = SchedulerParams.Cyclic() + result = c.params(base_LR=0.01) + assert result["base_lr"] == pytest.approx(0.01 / 4) + assert result["max_lr"] == pytest.approx(0.01 * 4) + + def test_params_explicit_lr_bounds(self): + c = SchedulerParams.Cyclic(base_lr=0.001, max_lr=0.1) + result = c.params(base_LR=999.0) # should be ignored + assert result["base_lr"] == 0.001 + assert result["max_lr"] == 0.1 + + +class TestLinearDefaults: + def test_defaults(self): + test = SchedulerParams.Linear() + assert test.start_factor == 0.1 + assert test.end_factor == 1.0 + assert test._name == "linear" + + def test_params_uses_num_iter(self): + test = SchedulerParams.Linear() + result = test.params(base_LR=0.01, num_iter=200) + assert result["total_iters"] == 200 + + def test_params_explicit_total_iters(self): + test = SchedulerParams.Linear(total_iters=50) + result = test.params(base_LR=0.01, num_iter=200) + assert result["total_iters"] == 50 + + def test_params_no_iters_raises(self): + test = SchedulerParams.Linear() + with pytest.raises(ValueError, match="total_iters must be set"): + test.params(base_LR=0.01, num_iter=None) + + +class TestCosineAnnealingDefaults: + def test_defaults(self): + ca = SchedulerParams.CosineAnnealing() + assert ca.eta_min == 1e-7 + assert ca.T_max is None + assert ca._name == "cosine_annealing" + + def test_params_uses_num_iter(self): + ca = SchedulerParams.CosineAnnealing() + result = ca.params(base_LR=0.01, num_iter=300) + assert result["T_max"] == 300 + + def test_params_explicit_T_max(self): + ca = SchedulerParams.CosineAnnealing(T_max=150) + result = ca.params(base_LR=0.01, num_iter=300) + assert result["T_max"] == 150 + + def test_params_no_T_max_raises(self): + ca = SchedulerParams.CosineAnnealing() + with pytest.raises(ValueError, match="T_max must be set"): + ca.params(base_LR=0.01, num_iter=None) + + +class TestNoneScheduler: + def test_defaults(self): + ns = SchedulerParams.NoneScheduler() + assert ns._name == "none" + assert ns.params(base_LR=0.01) == {} + + +# ─── SchedulerParams.parse_dict ───────────────────────────────────────────── + + +class TestSchedulerParseDict: + def test_parse_plateau(self): + result = SchedulerParams.parse_dict({"name": "plateau", "patience": 20}) + assert isinstance(result, SchedulerParams.Plateau) + assert result.patience == 20 + + def test_parse_exponential(self): + result = SchedulerParams.parse_dict({"name": "exponential", "gamma": 0.95}) + assert isinstance(result, SchedulerParams.Exponential) + assert result.gamma == 0.95 + + def test_parse_cyclic(self): + result = SchedulerParams.parse_dict({"name": "cyclic", "step_size_up": 50}) + assert isinstance(result, SchedulerParams.Cyclic) + assert result.step_size_up == 50 + + def test_parse_linear(self): + result = SchedulerParams.parse_dict({"name": "linear", "start_factor": 0.5}) + assert isinstance(result, SchedulerParams.Linear) + assert result.start_factor == 0.5 + + def test_parse_cosine_annealing(self): + result = SchedulerParams.parse_dict({"name": "cosine_annealing", "T_max": 100}) + assert isinstance(result, SchedulerParams.CosineAnnealing) + assert result.T_max == 100 + + def test_parse_none(self): + result = SchedulerParams.parse_dict({"name": "none"}) + assert isinstance(result, SchedulerParams.NoneScheduler) + + def test_parse_case_insensitive(self): + result = SchedulerParams.parse_dict({"name": "Plateau"}) + assert isinstance(result, SchedulerParams.Plateau) + + def test_parse_unknown_raises(self): + with pytest.raises(ValueError, match="Unknown scheduler type"): + SchedulerParams.parse_dict({"name": "warmup"}) + + def test_parse_does_not_mutate_input(self): + d = {"name": "plateau", "patience": 5} + original = dict(d) + SchedulerParams.parse_dict(d) + assert d == original + + def test_parse_invalid_name_type_raises(self): + with pytest.raises(ValueError, match="Unknown scheduler type"): + SchedulerParams.parse_dict({"name": 3.14}) + + def test_parse_default_name_is_none(self): + result = SchedulerParams.parse_dict({}) + assert isinstance(result, SchedulerParams.NoneScheduler) + + +# ─── OptimizerMixin.set_optimizer / PPLR behavior ─────────────────────────── + +from quantem.core import config # noqa: E402 +from quantem.core.ml.optimizer_mixin import OptimizerMixin # noqa: E402 + +torch = pytest.importorskip("torch") if config.get("has_torch") else None +requires_torch = pytest.mark.skipif( + not config.get("has_torch"), reason="requires torch" +) + + +def _param(value=1.0): + return torch.nn.Parameter(torch.tensor([value])) + + +class _FakeModel(OptimizerMixin): + """Minimal concrete OptimizerMixin for exercising set_optimizer/reset_optimizer. + + ``groups`` is the dict[str, list[Parameter]] returned by get_optimization_parameters. + Pass ``raise_on_params=True`` to prove the disable path short-circuits before the call. + """ + + def __init__(self, groups, raise_on_params=False): + super().__init__() + self._groups = groups + self._raise_on_params = raise_on_params + + def get_optimization_parameters(self): + if self._raise_on_params: + raise AssertionError("get_optimization_parameters should not have been called") + return self._groups + + +@requires_torch +class TestSetOptimizer: + def test_single_optimizer_build(self): + model = _FakeModel({"default": [_param()]}) + model.set_optimizer(OptimizerParams.Adam(lr=1e-3)) + assert model.has_optimizer() + assert isinstance(model.optimizer, torch.optim.Adam) + assert len(model.optimizer.param_groups) == 1 + assert model.optimizer.param_groups[0]["lr"] == 1e-3 + + def test_single_optimizer_dict_shorthand(self): + model = _FakeModel({"default": [_param()]}) + model.set_optimizer({"name": "sgd", "lr": 5e-2, "momentum": 0.9}) + assert isinstance(model.optimizer, torch.optim.SGD) + assert model.optimizer.param_groups[0]["lr"] == 5e-2 + assert model.optimizer.param_groups[0]["momentum"] == 0.9 + + def test_none_optimizer_removes_without_touching_params(self): + # raise_on_params proves the disable path short-circuits before get_optimization_parameters + model = _FakeModel({"default": [_param()]}, raise_on_params=True) + model.set_optimizer(OptimizerParams.NoneOptimizer()) + assert model.optimizer is None + assert not model.has_optimizer() + assert model.optimizer_params == { + OptimizerMixin.DEFAULT_OPTIMIZER_KEY: OptimizerParams.NoneOptimizer() + } + + def test_multi_group_pplr_applies_per_group_lr(self): + model = _FakeModel({"descan": [_param()], "scan_positions": [_param(2.0)]}) + model.set_optimizer( + {"descan": OptimizerParams.SGD(lr=1e-2), "scan_positions": OptimizerParams.SGD(lr=1e-3)} + ) + groups = model.optimizer.param_groups + assert len(groups) == 2 + # key order is preserved (descan, then scan_positions) + assert groups[0]["lr"] == 1e-2 + assert groups[1]["lr"] == 1e-3 + + def test_key_mismatch_raises(self): + model = _FakeModel({"default": [_param()]}) + with pytest.raises(ValueError, match="do not match"): + model.set_optimizer( + {"descan": OptimizerParams.SGD(lr=1e-2), "scan_positions": OptimizerParams.SGD()} + ) + + def test_mixed_optimizer_classes_raises(self): + model = _FakeModel({"a": [_param()], "b": [_param()]}) + with pytest.raises(ValueError, match="same optimizer type"): + model.set_optimizer( + {"a": OptimizerParams.Adam(lr=1e-3), "b": OptimizerParams.SGD(lr=1e-3)} + ) + + def test_reset_optimizer_on_unconfigured_model_is_noop(self): + model = _FakeModel({"default": [_param()]}) + # fresh model defaults to {"default": NoneOptimizer()} + model.reset_optimizer() + assert model.optimizer is None + assert not model.has_optimizer() + + def test_set_scheduler_base_lr_uses_max_group_lr(self): + model = _FakeModel({"descan": [_param()], "scan_positions": [_param(2.0)]}) + model.set_optimizer( + {"descan": OptimizerParams.SGD(lr=1e-2), "scan_positions": OptimizerParams.SGD(lr=1e-3)} + ) + model.set_scheduler(SchedulerParams.Plateau(), num_iter=10) + assert model.scheduler is not None + # Plateau min_lr defaults to base_LR / 20, with base_LR = max group lr (1e-2) + assert model.scheduler.min_lrs[0] == pytest.approx(1e-2 / 20) diff --git a/tests/tomography/conftest.py b/tests/tomography/conftest.py new file mode 100644 index 00000000..06957095 --- /dev/null +++ b/tests/tomography/conftest.py @@ -0,0 +1,72 @@ +"""Shared fixtures and markers for the tomography test suite. + +The suite is split into three tiers (see the plan / individual modules): + +* CPU, always-on -- radon, utils, object/dataset models, optimizer-param wiring. +* CPU, slow -- conventional SIRT/FBP reconstruction (``--runslow``). +* GPU, slow -- INR / KPlanes reconstruction (``requires_gpu`` + ``--runslow``). + +``torch`` and ``scikit-image`` are core dependencies of quantem, so they are always +importable; ``requires_torch`` is kept only for parity with the existing test style. The +meaningful gate is ``requires_gpu`` (CI runs CPU-only) combined with ``@pytest.mark.slow``. +""" + +import numpy as np +import pytest +import torch + +from quantem.core import config +from quantem.tomography.utils import rot_ZXZ + +# --- Markers --------------------------------------------------------------- +requires_torch = pytest.mark.skipif(not config.get("has_torch"), reason="requires torch") +requires_gpu = pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a CUDA device") + + +@pytest.fixture +def torch_device() -> str: + """Device for torch-only tests. + + ``cuda:0`` when available, else ``cpu``. Note: object models that go through + ``setup_distributed`` must be built on ``cuda`` when CUDA is present (the CPU path is + only valid when no CUDA device exists), so torch-only construction tests follow this + fixture rather than hard-coding ``"cpu"``. + """ + return "cuda:0" if torch.cuda.is_available() else "cpu" + + +# --- Synthetic data -------------------------------------------------------- +def make_tilt_series(volume: torch.Tensor, angles: np.ndarray) -> np.ndarray: + """Project a volume into a tilt series with quantem's own forward model. + + Mirrors ``tomography_00_generate_phantom``: rotate by ``rot_ZXZ`` (Euler ZXZ, tilt on + the X axis) then sum along the beam axis. Using this rather than ``radon_torch`` keeps + the synthetic data consistent with the geometry the reconstructors assume. + """ + projections = [] + vol = volume.unsqueeze(0) # (1, Z, Y, X) + for angle in angles: + rotated = rot_ZXZ(vol, 0.0, float(angle), 0.0, device="cpu") + projections.append(rotated[0].sum(0)) + return torch.stack(projections).numpy().astype(np.float32) + + +@pytest.fixture(scope="module") +def phantom_volume() -> np.ndarray: + """Small deterministic (32, 32, 32) phantom with a couple of solid blocks.""" + vol = np.zeros((32, 32, 32), dtype=np.float32) + vol[8:24, 10:20, 12:22] = 1.0 + vol[18:26, 6:12, 16:24] = 0.6 + return vol + + +@pytest.fixture(scope="module") +def tilt_angles() -> np.ndarray: + """Nine tilt angles spanning -70..70 degrees.""" + return np.linspace(-70, 70, 9).astype(np.float32) + + +@pytest.fixture(scope="module") +def tilt_series(phantom_volume: np.ndarray, tilt_angles: np.ndarray) -> np.ndarray: + """Synthetic tilt series, shape (n_angles, 32, 32).""" + return make_tilt_series(torch.from_numpy(phantom_volume), tilt_angles) diff --git a/tests/tomography/test_dataset_models.py b/tests/tomography/test_dataset_models.py new file mode 100644 index 00000000..f0b6aa2d --- /dev/null +++ b/tests/tomography/test_dataset_models.py @@ -0,0 +1,195 @@ +"""Tests for ``quantem.tomography.dataset_models``. + +Covers constraint parsing, the pixelated dataset (validation, normalisation, tilt-angle +convention, pose-parameter materialisation) and the INR / pretrain datasets. +""" + +import numpy as np +import pytest +import torch + +from quantem.tomography.dataset_models import ( + DatasetConstraintParams, + DatasetValue, + TomographyINRDataset, + TomographyINRPretrainDataset, + TomographyPixDataset, +) + +from .conftest import requires_torch + + +class TestDatasetConstraintParse: + def test_parse_base_by_name(self): + c = DatasetConstraintParams.parse_dict({"name": "base_tomography_dataset", "tv_zs": 0.1}) + assert isinstance(c, DatasetConstraintParams.BaseTomographyDatasetConstraints) + assert c.tv_zs == 0.1 + + def test_parse_base_by_type_key(self): + c = DatasetConstraintParams.parse_dict( + {"type": "base_tomography_dataset", "tv_shifts": 0.2} + ) + assert c.tv_shifts == 0.2 + + def test_unknown_name_raises(self): + with pytest.raises(ValueError): + DatasetConstraintParams.parse_dict({"name": "nope"}) + + +def _stack(nang=5, n=12, seed=0): + rng = np.random.default_rng(seed) + return (rng.random((nang, n, n)) * 10).astype(np.float32) + + +class TestTomographyPixDataset: + def test_wrong_projection_axis_raises(self): + # projections must live on axis 0, matching the number of tilt angles. + # here the projections are on the last axis, so axis 0 (12) != n_angles (5). + bad = np.zeros((12, 12, 5), dtype=np.float32) + with pytest.raises(ValueError): + TomographyPixDataset.from_data(bad, np.linspace(-60, 60, 5).astype(np.float32)) + + def test_tilt_angles_are_negated(self): + angles = np.linspace(-40, 60, 5).astype(np.float32) + d = TomographyPixDataset.from_data(_stack(), angles) + np.testing.assert_allclose(d.tilt_angles.numpy(), -angles, atol=1e-5) + + def test_normalised_by_95th_quantile(self): + d = TomographyPixDataset.from_data(_stack(), np.linspace(-60, 60, 5).astype(np.float32)) + q95 = torch.quantile(d.tilt_stack, 0.95) + assert torch.isclose(q95, torch.tensor(1.0), atol=1e-4) + + def test_reference_idx_and_learnable_tilts(self): + # negated angles -> [40, 15, -10, -35, -60]; smallest |angle| is index 2. + angles = np.linspace(-40, 60, 5).astype(np.float32) + d = TomographyPixDataset.from_data(_stack(), angles) + assert d.reference_tilt_idx == 2 + assert d.learnable_tilts == 4 + + def test_forward_returns_dataset_value(self): + angles = np.linspace(-40, 60, 5).astype(np.float32) + d = TomographyPixDataset.from_data(_stack(nang=5, n=12), angles) + out = d.forward(0) + assert isinstance(out, DatasetValue) + assert out.target.shape == (12, 12) + assert out.tilt_angle == pytest.approx(float(-angles[0])) + + def test_to_materialises_pose_parameters(self): + d = TomographyPixDataset.from_data(_stack(), np.linspace(-60, 60, 5).astype(np.float32)) + d.to("cpu") + assert isinstance(d.z1_params, torch.nn.Parameter) + assert d.shifts_params.shape == (d.learnable_tilts, 2) + + +@requires_torch +class TestTomographyINRDataset: + def test_len_is_projections_times_pixels(self): + d = TomographyINRDataset.from_data( + _stack(nang=5, n=12), np.linspace(-60, 60, 5, dtype="f4") + ) + assert len(d) == 5 * 12 * 12 + + def test_getitem_keys(self): + d = TomographyINRDataset.from_data( + _stack(nang=5, n=12), np.linspace(-60, 60, 5, dtype="f4") + ) + item = d[0] + assert {"phi", "pixel_i", "pixel_j", "projection_idx", "target_value"} <= set(item.keys()) + + @pytest.mark.parametrize( + "learn_shift,learn_tilt_axis", + [(True, True), (True, False), (False, True), (False, False)], + ) + def test_forward_gates_shift_and_tilt(self, learn_shift, learn_tilt_axis): + """``forward`` zeros the disabled component and passes the enabled one through. + + Guards the gating after removing the unreachable duplicate branch: shifts are + controlled by ``learn_shift``; the z1/z3 Euler angles by ``learn_tilt_axis``. + """ + d = TomographyINRDataset.from_data( + _stack(nang=5, n=12), + np.linspace(-60, 60, 5, dtype="f4"), + learn_shift=learn_shift, + learn_tilt_axis=learn_tilt_axis, + ) + d.to("cpu") + # Make every pose parameter non-zero so the gating is observable by value. + for p in (d.z1_params, d.z3_params, d.shifts_params): + p.data.fill_(1.0) + d._z1_ref = torch.ones_like(d._z1_ref) + d._z3_ref = torch.ones_like(d._z3_ref) + d._shifts_ref = torch.ones_like(d._shifts_ref) + + shifts, z1, z3 = d.forward(None) + assert bool(shifts.any()) == learn_shift + assert bool(z1.any()) == learn_tilt_axis + assert bool(z3.any()) == learn_tilt_axis + + +class TestTomographyINRPretrainDataset: + def test_len_and_getitem(self): + vol = torch.rand(1, 8, 8, 8) + ds = TomographyINRPretrainDataset(pretrain_target=vol) + assert len(ds) == 8**3 + item = ds[0] + assert set(item.keys()) == {"coords", "target"} + assert item["coords"].shape == (3,) + + +@requires_torch +class TestINRRayMath: + """The static ray helpers are pure tensor math (CPU), exercised here without a recon.""" + + def test_create_batch_rays_shape_and_endpoints(self): + N, S = 8, 5 + rays = TomographyINRDataset.create_batch_rays( + torch.tensor([0, N - 1]), torch.tensor([0, N - 1]), N=N, num_samples_per_ray=S + ) + assert rays.shape == (2, S, 3) + # pixel 0 maps to -1, pixel N-1 maps to +1 on both x (j) and y (i). + assert torch.allclose(rays[0, :, 0], torch.full((S,), -1.0)) + assert torch.allclose(rays[1, :, 0], torch.full((S,), 1.0)) + # z spans the full -1..1 sampling range. + assert torch.isclose(rays[0, 0, 2], torch.tensor(-1.0)) + assert torch.isclose(rays[0, -1, 2], torch.tensor(1.0)) + + def test_transform_batch_rays_identity_at_zero_pose(self): + rays = torch.rand(4, 6, 3) + zero = torch.zeros(4) + out = TomographyINRDataset.transform_batch_rays( + rays, z1=zero, x=zero, z3=zero, shifts=torch.zeros(4, 2), N=8, sampling_rate=1.0 + ) + # No rotation and no shift -> rays pass through unchanged. + assert torch.allclose(out, rays, atol=1e-5) + + def test_integrate_rays_sums_with_step_size(self): + B, S = 3, 5 + out = TomographyINRDataset.integrate_rays( + torch.ones(B, S), num_samples_per_ray=S, target_values_len=B + ) + step = 2.0 / (S - 1) + assert out.shape == (B,) + assert torch.allclose(out, torch.full((B,), S * step)) + + def test_getitem_index_mapping(self): + # n=4 -> H*W=16 per projection. idx=21 -> projection 1, remaining 5 -> i=1, j=1. + stack = _stack(nang=3, n=4) + d = TomographyINRDataset.from_data(stack, np.linspace(-60, 60, 3, dtype="f4")) + item = d[21] + assert int(item["projection_idx"]) == 1 + assert int(item["pixel_i"]) == 1 + assert int(item["pixel_j"]) == 1 + assert torch.isclose(item["target_value"], d.tilt_stack[1, 1, 1]) + + def test_save_load_parameters_roundtrip(self, tmp_path): + angles = np.linspace(-60, 60, 5, dtype="f4") + d = TomographyINRDataset.from_data(_stack(), angles) + d.to("cpu") + d.z1_params.data.fill_(0.37) + path = str(tmp_path / "params.pt") + d.save_parameters(path) + + d2 = TomographyINRDataset.from_data(_stack(), angles) + d2.to("cpu") + d2.load_parameters(path) + assert torch.allclose(d2.z1_params.detach(), d.z1_params.detach()) diff --git a/tests/tomography/test_logger_tomography.py b/tests/tomography/test_logger_tomography.py new file mode 100644 index 00000000..88d6bb26 --- /dev/null +++ b/tests/tomography/test_logger_tomography.py @@ -0,0 +1,103 @@ +"""Tests for ``quantem.tomography.logger_tomography``. + +``LoggerTomography`` is a thin tensorboard wrapper that the reconstruction loop only drives +when a ``log_dir`` is passed, so the end-to-end recon tests never exercise it. These CPU, +always-on tests construct a logger against a ``tmp_path`` and drive each method with small +stubs that expose only the attributes the logger reads, asserting the calls run and write +event files. Matplotlib backend is ``Agg`` (set in the root conftest), so figure logging is +headless. +""" + +from types import SimpleNamespace + +import numpy as np +import torch + +from quantem.tomography.logger_tomography import LoggerTomography + + +def _make_logger(tmp_path) -> LoggerTomography: + return LoggerTomography( + log_dir=str(tmp_path), + run_prefix="test_tomo", + run_suffix="", + log_images_every=1, + ) + + +def test_init_creates_log_dir(tmp_path): + logger = _make_logger(tmp_path) + try: + assert logger.log_dir.exists() + assert logger.log_dir.name.startswith("test_tomo_") + finally: + logger.close() + + +def test_log_epoch_writes_events(tmp_path): + logger = _make_logger(tmp_path) + try: + logger.log_epoch(epoch=0, loss=1.0, tilt_series_loss=0.8, soft_loss=0.2) + logger.flush() + events = list(logger.log_dir.glob("events.out.tfevents.*")) + assert events, "log_epoch should have written a tensorboard event file" + finally: + logger.close() + + +def test_log_iter_unpacks_learning_rates(tmp_path): + logger = _make_logger(tmp_path) + obj_model = SimpleNamespace(_soft_constraint_losses=[0.3]) + try: + logger.log_iter( + object_model=obj_model, + iter=2, + consistency_loss=0.5, + total_loss=0.7, + learning_rates={"object": 1e-3, "pose": 1e-2}, + num_samples_per_ray=16, + val_loss=0.4, + ) + logger.flush() + assert list(logger.log_dir.glob("events.out.tfevents.*")) + finally: + logger.close() + + +def test_log_iter_without_val_loss(tmp_path): + logger = _make_logger(tmp_path) + obj_model = SimpleNamespace(_soft_constraint_losses=[0.1]) + try: + # val_loss defaults to None -> the val branch must be skipped without error. + logger.log_iter( + object_model=obj_model, + iter=0, + consistency_loss=0.5, + total_loss=0.6, + learning_rates={}, + num_samples_per_ray=8, + ) + logger.flush() + finally: + logger.close() + + +def test_log_iter_images(tmp_path): + logger = _make_logger(tmp_path) + n_tilts = 5 + dataset_model = SimpleNamespace( + z1_params=torch.linspace(-1.0, 1.0, n_tilts), + z3_params=torch.linspace(1.0, -1.0, n_tilts), + shifts_params=torch.zeros(n_tilts, 2), + ) + pred_volume = np.random.default_rng(0).random((2, 6, 6, 6)).astype(np.float32) + try: + logger.log_iter_images( + pred_volume=pred_volume, + dataset_model=dataset_model, + iter=1, + ) + logger.flush() + assert list(logger.log_dir.glob("events.out.tfevents.*")) + finally: + logger.close() diff --git a/tests/tomography/test_object_models.py b/tests/tomography/test_object_models.py new file mode 100644 index 00000000..c4216827 --- /dev/null +++ b/tests/tomography/test_object_models.py @@ -0,0 +1,274 @@ +"""Tests for ``quantem.tomography.object_models``. + +The constraint-parsing and ``ObjectPixelated`` tests are pure CPU. The INR / tensor-decomp +construction tests are ``requires_torch`` and follow the ``torch_device`` fixture (they must +be built on CUDA when CUDA is present; see conftest). +""" + +import numpy as np +import pytest +import torch + +from quantem.tomography.object_models import ( + ObjConstraintParams, + ObjectBase, + ObjectINR, + ObjectPixelated, + ObjectTensorDecomp, +) +from quantem.tomography.tomography_context import ReconstructionContext + +from .conftest import requires_torch + + +class TestObjConstraintParse: + def test_parse_pixelated_by_name(self): + c = ObjConstraintParams.parse_dict({"name": "obj_pixelated", "tv_vol": 0.01}) + assert isinstance(c, ObjConstraintParams.ObjPixelatedConstraints) + assert c.tv_vol == 0.01 + + def test_parse_inr_by_type_key(self): + c = ObjConstraintParams.parse_dict({"type": "obj_inr", "sparsity": 0.05}) + assert isinstance(c, ObjConstraintParams.ObjINRConstraints) + assert c.sparsity == 0.05 + + def test_parse_tensor_decomp(self): + c = ObjConstraintParams.parse_dict({"name": "obj_tensor_decomp", "tv_plane": 0.1}) + assert isinstance(c, ObjConstraintParams.ObjTensorDecompConstraints) + assert c.tv_plane == 0.1 + + def test_missing_name_raises(self): + with pytest.raises(ValueError): + ObjConstraintParams.parse_dict({"tv_vol": 0.1}) + + def test_unknown_name_raises(self): + with pytest.raises(ValueError): + ObjConstraintParams.parse_dict({"name": "obj_nope"}) + + def test_constraint_key_partitions(self): + c = ObjConstraintParams.ObjPixelatedConstraints() + assert "positivity" in c.hard_constraint_keys + assert "tv_vol" in c.soft_constraint_keys + + def test_constraint_keys_are_real_fields(self): + """Regression: every soft/hard key must be an attribute, so __str__ never raises. + + ``ObjINRConstraints`` previously listed ``tv_plane`` (a field it does not have), which + made ``str(constraints)`` blow up with AttributeError via ``Constraints.__str__``. + """ + for cls in ( + ObjConstraintParams.ObjPixelatedConstraints, + ObjConstraintParams.ObjINRConstraints, + ObjConstraintParams.ObjTensorDecompConstraints, + ): + c = cls() + for key in c.soft_constraint_keys + c.hard_constraint_keys: + assert hasattr(c, key), f"{cls.__name__} lists missing key {key!r}" + assert isinstance(str(c), str) # must not raise + + +class TestObjectPixelatedConstruction: + def test_from_uniform_is_zeros(self): + obj = ObjectPixelated.from_uniform(shape=(8, 8, 8), device="cpu") + assert obj.shape == (8, 8, 8) + assert torch.allclose(obj.obj, torch.zeros(8, 8, 8)) + assert obj.obj_type == "pixelated" + + def test_from_array_numpy(self): + arr = np.random.default_rng(0).random((6, 6, 6)).astype(np.float32) + obj = ObjectPixelated.from_array(arr, device="cpu") + assert obj.shape == (6, 6, 6) + assert torch.allclose(obj.obj, torch.from_numpy(arr)) + assert obj.dtype == torch.float32 + + def test_from_array_torch_is_copied(self): + t = torch.ones(4, 4, 4) + obj = ObjectPixelated.from_array(t, device="cpu") + t += 5.0 + assert torch.allclose(obj.obj, torch.ones(4, 4, 4)) # original copy untouched + + def test_obj_view_shape(self): + obj = ObjectPixelated.from_uniform(shape=(5, 6, 7), device="cpu") + assert obj.obj_view.shape == (1, 5, 6, 7) + + def test_forward_returns_obj(self): + obj = ObjectPixelated.from_array(torch.full((4, 4, 4), 2.0), device="cpu") + assert torch.allclose(obj.forward(), obj.obj) + + +class TestObjectPixelatedConstraints: + def test_positivity_clamps_negatives(self): + obj = ObjectPixelated.from_array(torch.full((4, 4, 4), -1.0), device="cpu") + obj.constraints.positivity = True + assert torch.all(obj.obj >= 0.0) + + def test_positivity_off_keeps_negatives(self): + obj = ObjectPixelated.from_array(torch.full((4, 4, 4), -1.0), device="cpu") + obj.constraints.positivity = False + assert torch.all(obj.obj < 0.0) + + def test_shrinkage_subtracts_then_floors(self): + obj = ObjectPixelated.from_array(torch.full((4, 4, 4), 1.0), device="cpu") + obj.constraints.positivity = False + obj.constraints.shrinkage = 0.25 + assert torch.allclose(obj.obj, torch.full((4, 4, 4), 0.75)) + + def test_tv_loss_scales_with_weight(self): + # ctx.obj is the 3-D pixelated volume (D, H, W), matching ObjectPixelated._obj. + ctx = ReconstructionContext(obj=torch.rand(8, 8, 8)) + obj = ObjectPixelated.from_uniform(shape=(8, 8, 8), device="cpu") + obj.constraints.tv_vol = 1.0 + loss1 = obj.get_tv_loss(ctx) + obj.constraints.tv_vol = 2.0 + loss2 = obj.get_tv_loss(ctx) + assert torch.isclose(loss2, 2.0 * loss1) + + @pytest.mark.parametrize( + "shape", + [ + (8, 8, 8), # bare 3-D volume + (1, 8, 8, 8), # obj_view layout [C=1, D, H, W] + (3, 8, 8, 8), # multimodal [C, D, H, W] (e.g. 3 elemental channels) + ], + ) + def test_tv_loss_rank_agnostic_finite_and_positive(self, shape): + """Regression: get_tv_loss takes TV over the trailing spatial dims for any leading + channel/batch axes -- not the old 5-D-only indexing. Supports multimodal [C, ...].""" + ctx = ReconstructionContext(obj=torch.rand(*shape)) + obj = ObjectPixelated.from_uniform(shape=(8, 8, 8), device="cpu") + obj.constraints.tv_vol = 1.0 + loss = obj.get_tv_loss(ctx) + assert loss.ndim == 0 + assert torch.isfinite(loss) + assert loss > 0.0 # random volume has non-zero total variation + + def test_soft_constraint_zero_when_tv_off(self): + ctx = ReconstructionContext(obj=torch.rand(8, 8, 8)) + obj = ObjectPixelated.from_uniform(shape=(8, 8, 8), device="cpu") + obj.constraints.tv_vol = 0.0 + assert float(obj.apply_soft_constraints(ctx).detach()) == 0.0 + + +class TestFactoryGuard: + def test_objectbase_requires_token(self): + with pytest.raises(RuntimeError): + ObjectBase(shape=(4, 4, 4)) + + +@requires_torch +class TestObjectINR: + def test_from_model_builds(self, torch_device): + from quantem.core.ml.inr import HSiren + + model = HSiren(in_features=3, out_features=1, hidden_layers=1, hidden_features=8) + obj = ObjectINR.from_model(model, shape=(16, 16, 16), device=torch_device) + assert obj.shape == (16, 16, 16) + assert obj.model is not None + + def test_optimization_parameters_single_group(self, torch_device): + from quantem.core.ml.inr import HSiren + + model = HSiren(in_features=3, out_features=1, hidden_layers=1, hidden_features=8) + obj = ObjectINR.from_model(model, shape=(16, 16, 16), device=torch_device) + groups = obj.get_optimization_parameters() + assert list(groups.keys()) == ["default"] + assert len(groups["default"]) > 0 + + +@requires_torch +class TestObjectTensorDecomp: + def _model(self, n=16): + from quantem.core.ml.models.kplanes import KPlanesTILTED + + return KPlanesTILTED( + M_features=2, resolution=(n, n, n), multiscale_res_multipliers=[1], T=2 + ) + + def test_pplr_optimization_parameter_keys(self, torch_device): + obj = ObjectTensorDecomp.from_model(self._model(), shape=(16, 16, 16), device=torch_device) + keys = set(obj.get_optimization_parameters().keys()) + assert keys == {"grids", "sigma_net", "so3"} + + def test_pretrain_not_implemented(self, torch_device): + obj = ObjectTensorDecomp.from_model(self._model(), shape=(16, 16, 16), device=torch_device) + with pytest.raises(NotImplementedError): + obj.pretrain() + + +@requires_torch +class TestObjectINRBehaviour: + def _obj(self, device, n=16): + from quantem.core.ml.inr import HSiren + + model = HSiren(in_features=3, out_features=1, hidden_layers=1, hidden_features=8) + return ObjectINR.from_model(model, shape=(n, n, n), device=device) + + def test_forward_masks_out_of_range(self, torch_device): + obj = self._obj(torch_device) + coords = torch.tensor([[0.0, 0.0, 0.0], [5.0, 0.0, 0.0]], device=torch_device) + out = obj.forward(coords) + assert out.shape[0] == 2 + assert float(out[1].detach()) == 0.0 # x=5 is outside [-1, 1] -> masked to zero + + def test_apply_hard_constraints_positivity(self, torch_device): + obj = self._obj(torch_device) + obj.constraints.positivity = True + pred = torch.tensor([-1.0, 0.5, 2.0], device=torch_device) + assert torch.all(obj.apply_hard_constraints(pred) >= 0.0) + + def test_get_tv_loss_scalar(self, torch_device): + obj = self._obj(torch_device) + obj.constraints.tv_vol = 1.0 + coords = torch.rand(64, 3, device=torch_device) * 2 - 1 + ctx = ReconstructionContext(coords=coords, pred=torch.rand(64, device=torch_device)) + loss = obj.get_tv_loss(ctx) + assert loss.ndim == 0 + assert torch.isfinite(loss) + + +@requires_torch +class TestObjectTensorDecompTV: + def _obj(self, device, n=16): + from quantem.core.ml.models.kplanes import KPlanesTILTED + + model = KPlanesTILTED( + M_features=2, resolution=(n, n, n), multiscale_res_multipliers=[1], T=2 + ) + return ObjectTensorDecomp.from_model(model, shape=(n, n, n), device=device) + + def test_apply_hard_constraints_positivity(self, torch_device): + obj = self._obj(torch_device) + obj.constraints.positivity = True + pred = torch.tensor([-2.0, 0.0, 3.0], device=torch_device) + assert torch.all(obj.apply_hard_constraints(pred) >= 0.0) + + def test_plane_tv_loss_nonneg_scalar(self, torch_device): + obj = self._obj(torch_device) + obj.constraints.tv_plane = 0.1 + loss = obj._get_plane_tv_loss() + assert loss.ndim == 0 + assert float(loss.detach()) >= 0.0 + + def test_volume_tv_loss_scalar(self, torch_device): + obj = self._obj(torch_device) + obj.constraints.tv_vol = 0.1 + coords = torch.rand(64, 3, device=torch_device) * 2 - 1 + loss = obj.get_volume_tv_loss(coords) + assert loss.ndim == 0 + assert torch.isfinite(loss) + + def test_normalize_optimizer_params_rejects_non_dict(self, torch_device): + from quantem.core.ml.optimizer_mixin import OptimizerParams + + obj = self._obj(torch_device) + with pytest.raises(TypeError): + obj._normalize_optimizer_params([OptimizerParams.Adam()]) + + def test_normalize_optimizer_params_rejects_wrong_keys(self, torch_device): + from quantem.core.ml.optimizer_mixin import OptimizerParams + + obj = self._obj(torch_device) + with pytest.raises(ValueError): + obj._normalize_optimizer_params( + {"grids": OptimizerParams.Adam(), "wrong": OptimizerParams.Adam()} + ) diff --git a/tests/tomography/test_radon.py b/tests/tomography/test_radon.py new file mode 100644 index 00000000..41516f03 --- /dev/null +++ b/tests/tomography/test_radon.py @@ -0,0 +1,122 @@ +"""Tests for the pure-torch Radon transform (``quantem.tomography.radon.radon``). + +All CPU, deterministic. Cross-checked against scikit-image where a ground truth helps. +""" + +import numpy as np +import pytest +import torch + +from quantem.tomography.radon.radon import ( + get_fourier_filter_torch, + iradon_torch, + radon_torch, +) + + +def _disk(n: int, cy: int, cx: int, r: int) -> torch.Tensor: + yy, xx = np.mgrid[0:n, 0:n] + return torch.from_numpy((((yy - cy) ** 2 + (xx - cx) ** 2) < r**2).astype(np.float32)) + + +class TestRadonShapes: + def test_2d_input_returns_angles_by_pixels(self): + img = _disk(64, 32, 32, 10) + theta = torch.linspace(0, 180, 30) + sino = radon_torch(img, theta=theta) + assert sino.shape == (30, 64) + + def test_batched_input_returns_batch_angles_pixels(self): + imgs = torch.stack([_disk(48, 24, 20, 8), _disk(48, 24, 28, 8)]) + theta = torch.linspace(0, 180, 20) + sino = radon_torch(imgs, theta=theta) + assert sino.shape == (2, 20, 48) + + def test_default_theta_is_180_angles(self): + sino = radon_torch(_disk(32, 16, 16, 6)) + assert sino.shape == (180, 32) + + def test_iradon_shapes(self): + sino = radon_torch(_disk(40, 20, 20, 8), theta=torch.linspace(0, 180, 25)) + rec = iradon_torch(sino, theta=torch.linspace(0, 180, 25)) + assert rec.shape == (40, 40) + + def test_iradon_output_size_override(self): + sino = radon_torch(_disk(40, 20, 20, 8), theta=torch.linspace(0, 180, 25)) + rec = iradon_torch(sino, theta=torch.linspace(0, 180, 25), output_size=32) + assert rec.shape == (32, 32) + + +class TestFourierFilter: + def test_even_size_ok(self): + f = get_fourier_filter_torch(64, "ramp") + assert f.shape == (1, 64) + + def test_odd_size_raises(self): + with pytest.raises(ValueError): + get_fourier_filter_torch(63, "ramp") + + def test_unknown_filter_raises(self): + with pytest.raises(ValueError): + get_fourier_filter_torch(64, "not-a-filter") + + def test_none_filter_is_all_ones(self): + f = get_fourier_filter_torch(64, None) + assert torch.allclose(f, torch.ones_like(f)) + + @pytest.mark.parametrize("name", ["ramp", "shepp-logan", "cosine", "hamming", "hann"]) + def test_named_filters_run(self, name): + f = get_fourier_filter_torch(64, name) + assert f.shape == (1, 64) + assert torch.isfinite(f).all() + + +class TestRadonBehaviour: + def test_circular_mask_zeros_corners(self): + """The forward transform masks to the inscribed circle, so corner mass is dropped.""" + img = torch.ones(32, 32) + full = img.sum() + sino = radon_torch(img, theta=torch.tensor([0.0])) + # A single 0-degree projection sums columns; total equals the masked mass < full. + assert sino.sum() < full + + def test_iradon_circle_zeros_outside(self): + sino = radon_torch(_disk(48, 24, 24, 10), theta=torch.linspace(0, 180, 30)) + rec = iradon_torch(sino, theta=torch.linspace(0, 180, 30), circle=True) + n = rec.shape[0] + yy, xx = np.mgrid[0:n, 0:n] + outside = ((yy - n // 2) ** 2 + (xx - n // 2) ** 2) > (n // 2) ** 2 + assert torch.allclose(rec[outside], torch.zeros(int(outside.sum()))) + + def test_roundtrip_recovers_structure(self): + disk = _disk(64, 32, 24, 9) + theta = torch.linspace(0, 180, 60) + rec = iradon_torch(radon_torch(disk, theta=theta), theta=theta, filter_name="ramp") + corr = np.corrcoef(disk.numpy().ravel(), rec.numpy().ravel())[0, 1] + assert corr > 0.9 + + def test_default_theta_roundtrip_is_consistent(self): + """radon and iradon must share an angle convention when ``theta`` is defaulted. + + iradon's default previously included the 180-degree endpoint while radon's did not, + so a default-theta round-trip sampled mismatched angles. + """ + disk = _disk(64, 32, 28, 10) + rec = iradon_torch(radon_torch(disk), filter_name="ramp") # both default theta + corr = np.corrcoef(disk.numpy().ravel(), rec.numpy().ravel())[0, 1] + assert corr > 0.9 + + +class TestRadonVsSkimage: + """Loose cross-check against scikit-image's reference implementation.""" + + def test_forward_matches_skimage(self): + sk = pytest.importorskip("skimage.transform") + n = 64 + disk = _disk(n, n // 2, n // 2, 12) + theta = np.linspace(0.0, 180.0, 45, endpoint=False).astype(np.float32) + ours = radon_torch(disk, theta=torch.from_numpy(theta)).numpy() # (A, N) + ref = sk.radon(disk.numpy(), theta=theta, circle=True).T # skimage: (N, A) -> (A, N) + # Different interpolation conventions; require strong agreement, not equality. + corr = np.corrcoef(ours.ravel(), ref.ravel())[0, 1] + assert corr > 0.95 diff --git a/tests/tomography/test_tomography.py b/tests/tomography/test_tomography.py new file mode 100644 index 00000000..da71f9c9 --- /dev/null +++ b/tests/tomography/test_tomography.py @@ -0,0 +1,177 @@ +"""Tests for the ``Tomography`` / ``TomographyConventional`` orchestrators and the shared +``TomographyBase`` plumbing, without running a reconstruction. + +The ``TomographyConventional`` path uses ``ObjectPixelated`` (no DDP setup), so it builds on +CPU and is always-on -- this exercises the bulk of ``tomography_base.py`` (factory, property +setters/validation, loss accessors). The ``Tomography`` (INR) factory and ``save_volume`` go +through ``setup_distributed`` and so follow the ``torch_device`` fixture under +``requires_torch`` (build on CUDA when present; see conftest). +""" + +import numpy as np +import pytest +import torch + +from quantem.tomography.dataset_models import TomographyINRDataset, TomographyPixDataset +from quantem.tomography.object_models import ObjConstraintParams, ObjectINR, ObjectPixelated +from quantem.tomography.tomography import Tomography, TomographyConventional +from quantem.tomography.tomography_lite import TomographyLiteINR + +from .conftest import requires_torch + + +def _stack(nang=5, n=12, seed=0): + rng = np.random.default_rng(seed) + return (rng.random((nang, n, n)) * 10).astype(np.float32) + + +def _conventional(n=12): + angles = np.linspace(-60, 60, 5).astype(np.float32) + dset = TomographyPixDataset.from_data(_stack(nang=5, n=n), angles) + obj = ObjectPixelated.from_uniform(shape=(n, n, n), device="cpu") + return TomographyConventional.from_models( + dset=dset, obj_model=obj, device="cpu", verbose=False + ) + + +class TestConventionalFactory: + def test_from_models_builds(self): + tomo = _conventional() + assert isinstance(tomo, TomographyConventional) + assert isinstance(tomo.obj_model, ObjectPixelated) + assert isinstance(tomo.dset, TomographyPixDataset) + assert tomo.num_epochs == 0 + + def test_direct_init_requires_token(self): + with pytest.raises(RuntimeError): + TomographyConventional( + dset=TomographyPixDataset.from_data( + _stack(), np.linspace(-60, 60, 5).astype(np.float32) + ), + obj_model=ObjectPixelated.from_uniform(shape=(12, 12, 12), device="cpu"), + ) + + +class TestBaseProperties: + def test_constraints_setter_dict_and_object(self): + tomo = _conventional() + tomo.constraints = {"name": "obj_pixelated", "tv_vol": 0.02} + assert isinstance(tomo.constraints, ObjConstraintParams.ObjPixelatedConstraints) + assert tomo.constraints.tv_vol == 0.02 + obj_c = ObjConstraintParams.ObjPixelatedConstraints(positivity=True) + tomo.constraints = obj_c + assert tomo.constraints is obj_c + + def test_constraints_setter_none_is_noop(self): + tomo = _conventional() + before = tomo.constraints + tomo.constraints = None + assert tomo.constraints is before + + def test_constraints_setter_invalid_raises(self): + tomo = _conventional() + with pytest.raises(ValueError): + tomo.constraints = 1.0 + + def test_logger_setter_rejects_wrong_type(self): + tomo = _conventional() + with pytest.raises(TypeError): + tomo.logger = "not a logger" + + def test_dset_setter_rejects_wrong_type(self): + tomo = _conventional() + with pytest.raises(TypeError): + tomo.dset = object() + + def test_loss_accessors_start_empty(self): + tomo = _conventional() + assert tomo.epoch_losses.shape == (0,) + assert tomo.consistency_losses.shape == (0,) + assert tomo.learning_rates == {} + + def test_append_learning_rates_accumulates(self): + tomo = _conventional() + tomo.append_learning_rates({"object": 1e-3, "pose": 1e-2}) + tomo.append_learning_rates({"object": 5e-4, "pose": 5e-3}) + assert tomo.learning_rates["object"] == [1e-3, 5e-4] + assert tomo.learning_rates["pose"] == [1e-2, 5e-3] + + def test_to_updates_device(self): + tomo = _conventional() + tomo.to("cpu") + assert torch.device(tomo.device) == torch.device("cpu") + + def test_plot_losses_runs(self): + tomo = _conventional() + tomo._epoch_losses.extend([1.0, 0.5, 0.25]) + tomo.plot_losses() # Agg backend; plt.show() is a no-op + + +@requires_torch +class TestInrFactory: + def _inr_tomo(self, device, n=16): + from quantem.core.ml.inr import HSiren + + model = HSiren(in_features=3, out_features=1, hidden_layers=1, hidden_features=8) + obj = ObjectINR.from_model(model, shape=(n, n, n), device=device) + dset = TomographyINRDataset.from_data( + _stack(nang=5, n=n), np.linspace(-60, 60, 5).astype(np.float32) + ) + return Tomography.from_models(dset=dset, obj_model=obj, device=device, verbose=False) + + def test_from_models_builds(self, torch_device): + tomo = self._inr_tomo(torch_device) + assert isinstance(tomo, Tomography) + assert isinstance(tomo.obj_model, ObjectINR) + + def test_plot_losses_runs(self, torch_device): + tomo = self._inr_tomo(torch_device) + tomo._epoch_losses.extend([1.0, 0.5]) + tomo._lrs["object"] = [1e-3, 5e-4] + tomo.plot_losses() + + def test_save_volume_overwrite_guard(self, torch_device, tmp_path): + tomo = self._inr_tomo(torch_device) + path = str(tmp_path / "vol.npz") + tomo.save_volume(path) + assert (tmp_path / "vol.npz").exists() + with pytest.raises(FileExistsError): + tomo.save_volume(path) + tomo.save_volume(path, overwrite=True) # must not raise + with np.load(path) as data: + assert "volume" in data + + +@requires_torch +class TestLiteINRReconstructBranch: + """``TomographyLiteINR.reconstruct`` bundles optimizer/scheduler params only on the first + epoch and passes ``None`` afterwards. Stub out the heavy ``Tomography.reconstruct`` to + assert the branch without running a reconstruction.""" + + def _lite(self, device, n=12): + return TomographyLiteINR.from_dataset( + tilt_series=_stack(nang=5, n=n), + tilt_angles=np.linspace(-60, 60, 5).astype(np.float32), + device=device, + ) + + def test_param_bundling_first_then_subsequent(self, torch_device, monkeypatch): + tomo = self._lite(torch_device) + captured = {} + + def fake_reconstruct(self, **kwargs): + captured.clear() + captured.update(kwargs) + self._epoch_losses.append(1.0) # mark an epoch as having run + + monkeypatch.setattr(Tomography, "reconstruct", fake_reconstruct) + + # First call (num_epochs == 0): object + pose params are assembled. + tomo.reconstruct(num_iter=1, num_workers=0, learn_pose=True) + assert set(captured["optimizer_params"].keys()) == {"object", "pose"} + assert set(captured["scheduler_params"].keys()) == {"object", "pose"} + + # Second call (num_epochs > 0): params are passed through as None. + tomo.reconstruct(num_iter=1, num_workers=0) + assert captured["optimizer_params"] is None + assert captured["scheduler_params"] is None diff --git a/tests/tomography/test_tomography_conventional.py b/tests/tomography/test_tomography_conventional.py new file mode 100644 index 00000000..a7cfb5e0 --- /dev/null +++ b/tests/tomography/test_tomography_conventional.py @@ -0,0 +1,72 @@ +"""End-to-end conventional (SIRT / FBP) reconstruction. + +CPU, deterministic, but marked ``slow`` because it runs a (tiny) iterative reconstruction. +Reconstructions are capped at a few iterations: the suite checks behaviour and wiring (loss +decreases, output stays physical) rather than convergence quality, so spatial agreement with +the phantom is only a loose lower bound. +""" + +import numpy as np +import pytest + +from quantem.tomography.dataset_models import TomographyPixDataset +from quantem.tomography.object_models import ObjConstraintParams, ObjectPixelated +from quantem.tomography.tomography import TomographyConventional + +pytestmark = pytest.mark.slow + + +def _build(tilt_series, tilt_angles, n): + dset = TomographyPixDataset.from_data( + tilt_series, tilt_angles, learn_shift=False, learn_tilt_axis=False + ) + obj = ObjectPixelated.from_uniform(shape=(n, n, n), device="cpu") + return TomographyConventional.from_models( + dset=dset, obj_model=obj, device="cpu", verbose=False + ) + + +class TestSIRT: + def test_loss_decreases_and_output_physical(self, phantom_volume, tilt_series, tilt_angles): + n = phantom_volume.shape[0] + tomo = _build(tilt_series, tilt_angles, n) + tomo.reconstruct( + num_iter=4, + mode="sirt", + obj_constraints=ObjConstraintParams.ObjPixelatedConstraints(positivity=True), + ) + losses = tomo.epoch_losses + assert tomo.num_epochs == 4 + assert losses[-1] < losses[0] + rec = tomo.obj_model.obj.detach().cpu().numpy() + assert np.isfinite(rec).all() + assert rec.min() >= 0.0 # positivity + + def test_recon_correlates_with_phantom(self, phantom_volume, tilt_series, tilt_angles): + n = phantom_volume.shape[0] + tomo = _build(tilt_series, tilt_angles, n) + tomo.reconstruct( + num_iter=4, + mode="sirt", + obj_constraints=ObjConstraintParams.ObjPixelatedConstraints(positivity=True), + ) + rec = tomo.obj_model.obj.detach().cpu().numpy() + corr = np.corrcoef(rec.ravel(), phantom_volume.ravel())[0, 1] + assert corr > 0.15 # loose: only a handful of iterations + + def test_obj_constraints_accepts_dict(self, phantom_volume, tilt_series, tilt_angles): + n = phantom_volume.shape[0] + tomo = _build(tilt_series, tilt_angles, n) + tomo.reconstruct(num_iter=2, mode="sirt", obj_constraints={"name": "obj_pixelated"}) + assert tomo.num_epochs == 2 + + +class TestFBP: + def test_fbp_runs_single_epoch(self, phantom_volume, tilt_series, tilt_angles): + n = phantom_volume.shape[0] + tomo = _build(tilt_series, tilt_angles, n) + tomo.reconstruct(num_iter=5, mode="fbp") + # FBP breaks after the first epoch regardless of num_iter. + assert tomo.num_epochs == 1 + rec = tomo.obj_model.obj.detach().cpu().numpy() + assert np.isfinite(rec).all() diff --git a/tests/tomography/test_tomography_cpu_recon.py b/tests/tomography/test_tomography_cpu_recon.py new file mode 100644 index 00000000..0ebf7752 --- /dev/null +++ b/tests/tomography/test_tomography_cpu_recon.py @@ -0,0 +1,44 @@ +"""CPU INR reconstruction smoke test. + +The GPU INR tests (``test_tomography_inr.py``) are gated behind ``requires_gpu``, so on +CPU-only CI the entire learned-reconstruction loop in ``Tomography.reconstruct`` is never +exercised. This runs a tiny INR reconstruction on CPU with ``num_workers=0`` -- valid since +``DDPMixin.setup_dataloader`` now only sets ``multiprocessing_context`` when +``num_workers > 0`` -- so that path is covered in CI. + +It is skipped when CUDA is present: object models that go through ``setup_distributed`` must +be built on CUDA when a CUDA device exists (the CPU path is only valid with no CUDA device), +and on GPU machines the ``requires_gpu`` tests already cover this path. Marked ``slow`` so it +runs under ``--runslow``. +""" + +import numpy as np +import pytest +import torch + +from quantem.tomography.tomography_lite import TomographyLiteINR + +pytestmark = [ + pytest.mark.slow, + pytest.mark.skipif( + torch.cuda.is_available(), + reason="CPU INR path is only valid without CUDA; GPU machines use the requires_gpu tests", + ), +] + + +def test_cpu_inr_reconstruct_reduces_loss(): + rng = np.random.default_rng(0) + n = 12 + series = (rng.random((5, n, n)) * 10).astype(np.float32) + angles = np.linspace(-60, 60, 5).astype(np.float32) + + tomo = TomographyLiteINR.from_dataset(tilt_series=series, tilt_angles=angles, device="cpu") + tomo.reconstruct(num_iter=2, num_workers=0, batch_size=64, learn_pose=True) + + losses = tomo.epoch_losses + assert len(losses) == 2 + assert losses[-1] < losses[0] + view = tomo.obj_model.obj_view + assert view.shape == (1, n, n, n) + assert np.isfinite(view).all() diff --git a/tests/tomography/test_tomography_inr.py b/tests/tomography/test_tomography_inr.py new file mode 100644 index 00000000..fbd5f1a1 --- /dev/null +++ b/tests/tomography/test_tomography_inr.py @@ -0,0 +1,96 @@ +"""End-to-end INR / KPlanes (tensor-decomposition) reconstruction. + +These exercise the full learned-reconstruction path (model + pose optimisation, autocast, +spawned dataloader workers), so they are gated behind ``requires_gpu`` and ``slow`` and only +run locally with ``--runslow``. Reconstructions are capped at 4 iterations; the assertion is +loss-decreases plus finite output, not convergence quality. + +The ``num_workers=2`` is required, not incidental: ``setup_dataloader`` hard-codes +``multiprocessing_context="spawn"``, which is invalid with ``num_workers=0``. +""" + +import numpy as np +import pytest +import torch + +from quantem.tomography.dataset_models import TomographyINRDataset +from quantem.tomography.object_models import ObjectTensorDecomp +from quantem.tomography.tomography import Tomography +from quantem.tomography.tomography_lite import TomographyLiteConv, TomographyLiteINR + +from .conftest import make_tilt_series, requires_gpu + +pytestmark = [requires_gpu, pytest.mark.slow] + +DEVICE = "cuda:0" + + +@pytest.fixture(scope="module") +def small_phantom(): + vol = torch.zeros(1, 24, 24, 24) + vol[0, 6:18, 6:14, 8:16] = 1.0 + angles = np.linspace(-60, 60, 7).astype(np.float32) + series = make_tilt_series(vol[0], angles) + return series, angles + + +class TestLiteINR: + def test_reconstruct_reduces_loss(self, small_phantom): + series, angles = small_phantom + tomo = TomographyLiteINR.from_dataset( + tilt_series=series, tilt_angles=angles, device=DEVICE + ) + tomo.reconstruct(num_iter=4, num_workers=2, batch_size=256, learn_pose=True) + losses = tomo.epoch_losses + assert len(losses) == 4 + assert losses[-1] < losses[0] + view = tomo.obj_model.obj_view + assert view.shape == (1, 24, 24, 24) + assert np.isfinite(view).all() + + +class TestLiteConv: + def test_smoke(self, small_phantom): + series, angles = small_phantom + tomo = TomographyLiteConv.from_dataset( + tilt_series=series, tilt_angles=angles, device=DEVICE + ) + tomo.reconstruct(num_iter=3, mode="sirt") + assert tomo.num_epochs == 3 + assert np.isfinite(tomo.obj_model.obj.detach().cpu().numpy()).all() + + +class TestKPlanes: + def test_pplr_reconstruct_reduces_loss(self, small_phantom): + from quantem.core.ml.models.kplanes import KPlanesTILTED + from quantem.core.ml.optimizer_mixin import OptimizerParams, SchedulerParams + + series, angles = small_phantom + n = series.shape[1] + model = KPlanesTILTED( + M_features=2, resolution=(n, n, n), multiscale_res_multipliers=[1], T=2 + ) + obj = ObjectTensorDecomp.from_model(model, shape=(n, n, n), device=DEVICE) + dset = TomographyINRDataset.from_data(series, angles) + tomo = Tomography.from_models(dset=dset, obj_model=obj, device=DEVICE, verbose=False) + tomo.reconstruct( + optimizer_params={ + "object": { + "grids": OptimizerParams.Adam(lr=1e-2), + "sigma_net": OptimizerParams.Adam(lr=1e-3), + "so3": OptimizerParams.Adam(lr=1e-2), + }, + "pose": OptimizerParams.Adam(lr=1e-2), + }, + scheduler_params={ + "object": SchedulerParams.CosineAnnealing(T_max=4), + "pose": SchedulerParams.CosineAnnealing(T_max=4), + }, + num_iter=4, + batch_size=256, + num_samples_per_ray=20, + num_workers=2, + ) + losses = tomo.epoch_losses + assert len(losses) == 4 + assert losses[-1] < losses[0] diff --git a/tests/tomography/test_tomography_opt.py b/tests/tomography/test_tomography_opt.py new file mode 100644 index 00000000..bae00ad9 --- /dev/null +++ b/tests/tomography/test_tomography_opt.py @@ -0,0 +1,220 @@ +"""Tests for the tomography optimizer / scheduler wiring (``TomographyOpt``). + +This is the surface the PPLR ``OptimizerParamsType`` / ``SchedulerParamsType`` refactor +touched. In particular, ``test_set_optimizers_builds_object_and_pose`` and the PPLR test +regression-guard the pose-optimizer path: ``TomographyDatasetBase.get_optimization_parameters`` +must return a ``dict[str, list[tensor]]`` (it previously returned a ``list`` and crashed +``set_optimizer`` with ``TypeError: unhashable type: 'dict'``). + +Construction only -- no forward passes -- so these run on CPU under CI. +""" + +import numpy as np +import pytest + +from quantem.core.ml.optimizer_mixin import OptimizerParams, SchedulerParams +from quantem.tomography.dataset_models import TomographyINRDataset +from quantem.tomography.object_models import ObjectINR, ObjectTensorDecomp +from quantem.tomography.tomography import Tomography + +from .conftest import requires_torch + + +def _tilts(nang=5, n=12): + rng = np.random.default_rng(0) + angles = np.linspace(-60, 60, nang).astype(np.float32) + stack = rng.random((nang, n, n)).astype(np.float32) + return stack, angles + + +def _inr_tomography(device): + from quantem.core.ml.inr import HSiren + + model = HSiren(in_features=3, out_features=1, hidden_layers=1, hidden_features=8) + obj = ObjectINR.from_model(model, shape=(16, 16, 16), device=device) + stack, angles = _tilts() + dset = TomographyINRDataset.from_data(stack, angles) + return Tomography.from_models(dset=dset, obj_model=obj, device=device, verbose=False) + + +def _td_tomography(device): + from quantem.core.ml.models.kplanes import KPlanesTILTED + + model = KPlanesTILTED( + M_features=2, resolution=(16, 16, 16), multiscale_res_multipliers=[1], T=2 + ) + obj = ObjectTensorDecomp.from_model(model, shape=(16, 16, 16), device=device) + stack, angles = _tilts() + dset = TomographyINRDataset.from_data(stack, angles) + return Tomography.from_models(dset=dset, obj_model=obj, device=device, verbose=False) + + +@requires_torch +class TestOptimizerParams: + def test_setter_getter_roundtrip(self, torch_device): + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + assert set(tomo.optimizer_params.keys()) == {"object", "pose"} + + def test_unknown_key_raises(self, torch_device): + tomo = _inr_tomography(torch_device) + with pytest.raises(ValueError): + tomo.optimizer_params = {"banana": OptimizerParams.Adam(lr=1e-3)} + + def test_set_optimizers_builds_object_and_pose(self, torch_device): + """Regression guard: the pose path must not raise (see module docstring).""" + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + assert set(tomo.optimizers.keys()) == {"object", "pose"} + + def test_current_lrs(self, torch_device): + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + lrs = tomo.get_current_lrs() + assert set(lrs.keys()) == {"object", "pose"} + assert lrs["object"] == pytest.approx(1e-3) + assert lrs["pose"] == pytest.approx(1e-2) + + def test_remove_optimizer(self, torch_device): + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + tomo.remove_optimizer("object") + assert "object" not in tomo.optimizers + assert "pose" in tomo.optimizers + + def test_pplr_object_groups(self, torch_device): + """Per-parameter LR: object optimizer carries one torch param group per key.""" + tomo = _td_tomography(torch_device) + tomo.optimizer_params = { + "object": { + "grids": OptimizerParams.Adam(lr=1e-2), + "sigma_net": OptimizerParams.Adam(lr=1e-3), + "so3": OptimizerParams.Adam(lr=1e-2), + }, + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + assert len(tomo.optimizers["object"].param_groups) == 3 + assert "pose" in tomo.optimizers + + def test_step_optimizers_steps_each_once(self, torch_device, monkeypatch): + """Regression: with object+pose, each optimizer must step exactly once per call. + + ``step_optimizers`` previously looped over both keys and stepped *both* optimizers on + every pass, so each took two Adam steps per batch. + """ + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + assert set(tomo.optimizers.keys()) == {"object", "pose"} # both optimizers live + + counts = {"object": 0, "pose": 0} + monkeypatch.setattr( + tomo.obj_model, + "step_optimizer", + lambda: counts.__setitem__("object", counts["object"] + 1), + ) + monkeypatch.setattr( + tomo.dset, "step_optimizer", lambda: counts.__setitem__("pose", counts["pose"] + 1) + ) + tomo.step_optimizers() + assert counts == {"object": 1, "pose": 1} + + def test_zero_grad_all_zeros_each_once(self, torch_device, monkeypatch): + """Companion to the step regression: zero_grad_all must touch each optimizer once.""" + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + counts = {"object": 0, "pose": 0} + monkeypatch.setattr( + tomo.obj_model, + "zero_optimizer_grad", + lambda: counts.__setitem__("object", counts["object"] + 1), + ) + monkeypatch.setattr( + tomo.dset, + "zero_optimizer_grad", + lambda: counts.__setitem__("pose", counts["pose"] + 1), + ) + tomo.zero_grad_all() + assert counts == {"object": 1, "pose": 1} + + +@requires_torch +class TestOptHelpers: + def test_get_default_lr_object_and_pose(self, torch_device): + tomo = _inr_tomography(torch_device) + assert isinstance(tomo._get_default_lr("object"), float) + assert isinstance(tomo._get_default_lr("pose"), float) + + def test_get_default_lr_unknown_raises(self, torch_device): + tomo = _inr_tomography(torch_device) + with pytest.raises(ValueError): + tomo._get_default_lr("banana") + + def test_remove_optimizer_unknown_raises(self, torch_device): + tomo = _inr_tomography(torch_device) + with pytest.raises(ValueError): + tomo.remove_optimizer("banana") + + def test_current_lrs_zero_without_optimizers(self, torch_device): + tomo = _inr_tomography(torch_device) + assert tomo.get_current_lrs() == {"object": 0.0, "pose": 0.0} + + +@requires_torch +class TestSchedulerParams: + def test_scheduler_setter_getter(self, torch_device): + tomo = _inr_tomography(torch_device) + tomo.scheduler_params = { + "object": SchedulerParams.CosineAnnealing(T_max=10), + "pose": SchedulerParams.CosineAnnealing(T_max=10), + } + assert set(tomo.scheduler_params.keys()) == {"object", "pose"} + + def test_set_schedulers_builds(self, torch_device): + tomo = _inr_tomography(torch_device) + tomo.optimizer_params = { + "object": OptimizerParams.Adam(lr=1e-3), + "pose": OptimizerParams.Adam(lr=1e-2), + } + tomo.set_optimizers() + tomo.scheduler_params = { + "object": SchedulerParams.CosineAnnealing(T_max=10), + "pose": SchedulerParams.CosineAnnealing(T_max=10), + } + tomo.set_schedulers(tomo.scheduler_params, num_iter=10) + assert set(tomo.schedulers.keys()) == {"object", "pose"} + + def test_bad_scheduler_type_raises(self, torch_device): + tomo = _inr_tomography(torch_device) + with pytest.raises(TypeError): + tomo.obj_model.scheduler_params = 123 + + def test_setter_does_not_mutate_caller_dict(self, torch_device): + """Regression: the setter must not inject missing keys into the caller's dict.""" + tomo = _inr_tomography(torch_device) + d = {"object": SchedulerParams.CosineAnnealing(T_max=10)} + tomo.scheduler_params = d + assert set(d.keys()) == {"object"} # "pose" must not have been added to the input diff --git a/tests/tomography/test_utils.py b/tests/tomography/test_utils.py new file mode 100644 index 00000000..1fa6a47c --- /dev/null +++ b/tests/tomography/test_utils.py @@ -0,0 +1,78 @@ +"""Tests for ``quantem.tomography.utils``: 1D total-variation loss and the +differentiable ZXZ rotation operators. All CPU.""" + +import pytest +import torch + +from quantem.tomography.utils import ( + differentiable_rotz_vectorized, + rot_ZXZ, + tv_loss_1d, +) + + +class TestTVLoss1D: + def test_constant_input_is_zero(self): + assert tv_loss_1d(torch.ones(10)) == 0.0 + + def test_known_value_mean(self): + # diffs are [1, 1, 1], abs-mean = 1.0 + x = torch.tensor([0.0, 1.0, 2.0, 3.0]) + assert torch.isclose(tv_loss_1d(x, reduction="mean"), torch.tensor(1.0)) + + def test_known_value_sum(self): + x = torch.tensor([0.0, 1.0, 2.0, 3.0]) + assert torch.isclose(tv_loss_1d(x, reduction="sum"), torch.tensor(3.0)) + + def test_reduction_none_shape(self): + x = torch.zeros(2, 5) + out = tv_loss_1d(x, reduction="none") + assert out.shape == (2, 4) + + def test_bad_reduction_raises(self): + with pytest.raises(ValueError): + tv_loss_1d(torch.zeros(4), reduction="median") + + +def _block_volume(n: int = 16) -> torch.Tensor: + """(1, n, n, n) volume with an off-centre block so rotations are detectable.""" + vol = torch.zeros(1, n, n, n) + vol[0, 4:12, 4:10, 5:11] = 1.0 + return vol + + +class TestRotations: + def test_zero_rotation_is_identity(self): + vol = _block_volume() + out = rot_ZXZ(vol, 0.0, 0.0, 0.0, device="cpu") + assert torch.max(torch.abs(out - vol)) < 1e-4 + + def test_rotation_preserves_mass(self): + vol = _block_volume() + rotated = rot_ZXZ(vol, 0.0, 30.0, 0.0, device="cpu") + rel_err = abs(float(rotated.sum()) - float(vol.sum())) / float(vol.sum()) + assert rel_err < 0.02 + + def test_accepts_python_float_and_tensor_angle(self): + vol = _block_volume() + out_float = rot_ZXZ(vol, 0.0, 25.0, 0.0, device="cpu") + out_tensor = rot_ZXZ( + vol, + torch.tensor(0.0), + torch.tensor(25.0), + torch.tensor(0.0), + device="cpu", + ) + assert torch.allclose(out_float, out_tensor, atol=1e-5) + + def test_rotation_changes_volume(self): + vol = _block_volume() + rotated = rot_ZXZ(vol, 0.0, 90.0, 0.0, device="cpu") + assert torch.max(torch.abs(rotated - vol)) > 0.1 + + def test_gradient_flows_through_rotation(self): + vol = _block_volume().requires_grad_(True) + out = differentiable_rotz_vectorized(vol, torch.tensor(20.0)) + out.sum().backward() + assert vol.grad is not None + assert torch.isfinite(vol.grad).all() diff --git a/tests/utils/test_filter.py b/tests/utils/test_filter.py new file mode 100644 index 00000000..1c43cd0c --- /dev/null +++ b/tests/utils/test_filter.py @@ -0,0 +1,20 @@ +import torch + +from quantem.core.utils.filter import filter_hot_pixels + + +def test_filter_hot_pixels_replaces_stuck_detector_pixels_with_local_median(): + """A microscopist running 4D-STEM sees most detector pixels read out at low + counts (~1-100), but a few are stuck near saturation (~60000) regardless + of incident intensity. After `filter_hot_pixels`, those stuck pixels + should drop back into the local bulk regime (<=100). + """ + ds = torch.randint(1, 101, size=(64, 64, 32, 32), dtype=torch.int32) + # Assume these 3 places have hot pixels that we later want to remove + hot_coords = [(5, 7), (18, 24), (29, 3)] + for r, c in hot_coords: + ds[:, :, r, c] = 60000 + filtered = filter_hot_pixels(ds.numpy()) + dp_max = filtered.max(axis=(0, 1)) + # no pixels should have value 101 + assert dp_max.max() < 101, f"hot pixels still present, dp_max max={dp_max.max()}" diff --git a/tests/visualization/test_visualization_utils.py b/tests/visualization/test_visualization_utils.py index c3a1f048..1617f0a4 100644 --- a/tests/visualization/test_visualization_utils.py +++ b/tests/visualization/test_visualization_utils.py @@ -14,8 +14,8 @@ add_scalebar_to_ax, array_to_rgba, bilinear_histogram_2d, + combine_arrays_to_rgba, estimate_scalebar_length, - list_of_arrays_to_rgba, turbo_black, ) @@ -85,14 +85,14 @@ def test_array_to_rgba_shape_mismatch(self, sample_array): array_to_rgba(sample_array, wrong_shape) -class TestListOfArraysToRGBA: - def test_list_of_arrays_to_rgba(self, sample_arrays): - rgba = list_of_arrays_to_rgba(sample_arrays) +class TestCombineArraysToRGBA: + def test_combine_arrays_to_rgba(self, sample_arrays): + rgba = combine_arrays_to_rgba(sample_arrays) assert rgba.shape == (*sample_arrays[0].shape, 4) assert np.all(rgba[..., 3] == 1) # alpha channel should be 1 - def test_list_of_arrays_to_rgba_with_chroma_boost(self, sample_arrays): - rgba = list_of_arrays_to_rgba(sample_arrays, chroma_boost=2.0) + def test_combine_arrays_to_rgba_with_chroma_boost(self, sample_arrays): + rgba = combine_arrays_to_rgba(sample_arrays, chroma_boost=2.0) assert rgba.shape == (*sample_arrays[0].shape, 4) @@ -297,3 +297,11 @@ def test_bilinear_histogram_2d_with_custom_statistic(self): hist = bilinear_histogram_2d(shape, x, y, weight, statistic="mean") assert hist.shape == shape assert np.all(~np.isnan(hist[hist > 0])) # Only check non-zero values for NaN + + def test_bilinear_histogram_2d_accepts_column_vectors(self): + shape = (8, 8) + x = np.array([[1.0], [2.0], [3.0]]) + y = np.array([[1.5], [2.5], [3.5]]) + weight = np.array([[2.0], [3.0], [4.0]]) + hist = bilinear_histogram_2d(shape, x, y, weight) + assert hist.shape == shape diff --git a/uv.lock b/uv.lock index 7ab7af26..f1c50c06 100644 --- a/uv.lock +++ b/uv.lock @@ -15,52 +15,52 @@ members = [ [[package]] name = "absl-py" -version = "2.3.1" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/2a/c93173ffa1b39c1d0395b7e842bbdc62e556ca9d8d3b5572926f3e4ca752/absl_py-2.3.1.tar.gz", hash = "sha256:a97820526f7fbfd2ec1bce83f3f25e3a14840dac0d8e02a0b71cd75db3f77fc9", size = 116588, upload-time = "2025-07-03T09:31:44.05Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/4f/d79676ab82f2e42fc3611618139f13a9c4c31d0cff4b486982047679a802/absl_py-2.5.0.tar.gz", hash = "sha256:0c996f25c0490700fadabe6351630f6111534fa0ae252cc6d2014ea3b141135f", size = 118119, upload-time = "2026-07-03T10:57:48.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410, upload-time = "2026-07-03T10:57:46.735Z" }, ] [[package]] name = "alembic" -version = "1.18.1" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] name = "anywidget" -version = "0.9.21" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ipywidgets" }, { name = "psygnal" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/31/0491d707c674b34267f55d96d6a7148e55e7b6718a271686232cf295fbe2/anywidget-0.11.0.tar.gz", hash = "sha256:6695fbef9449cf8c27f421b96c5837aa37f909ec1f60cfa33add333e1b70b169", size = 426999, upload-time = "2026-04-27T23:42:09.576Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c2/8fec8e8e2eb920cc2280f569144080cd58622a2eda83bfa4c0c354a63264/anywidget-0.11.0-py3-none-any.whl", hash = "sha256:c574d9acc6503ad27b37a9acea48f957a8ba7c9c9876cfcb37898931c098ce9d", size = 317341, upload-time = "2026-04-27T23:42:08.356Z" }, ] [[package]] @@ -139,54 +139,54 @@ wheels = [ [[package]] name = "async-lru" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041, upload-time = "2026-01-17T22:52:18.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, ] [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "bleach" -version = "6.3.0" +version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, ] [package.optional-dependencies] @@ -196,11 +196,11 @@ css = [ [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] @@ -284,87 +284,103 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { 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" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -383,7 +399,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorspacious" }, { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/ca/39152b506133881be74bd46cced27ec10d9bbc85db5bdfc22c8f8e4011f9/cmasher-1.9.2.tar.gz", hash = "sha256:6c90c2cec87146e3210d3e7f2321fceee38ac7d87c136cd0de25160a14f57ff5", size = 520860, upload-time = "2024-11-19T11:17:02.084Z" } wheels = [ @@ -416,7 +433,8 @@ name = "colorspacious" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/e4/aa41ae14c5c061205715006c8834496d86ec7500f1edda5981f0f0190cc6/colorspacious-1.1.2.tar.gz", hash = "sha256:5e9072e8cdca889dac445c35c9362a22ccf758e97b00b79ff0d5a7ba3e11b618", size = 688573, upload-time = "2018-04-08T04:27:30.83Z" } wheels = [ @@ -437,7 +455,8 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -516,89 +535,86 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, - { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, - { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, - { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, - { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, - { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, - { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, - { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, - { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, - { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, - { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, - { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, - { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, - { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, - { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, - { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, - { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, - { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, - { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, - { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, - { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, - { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [package.optional-dependencies] @@ -606,6 +622,74 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { 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]] +name = "cuda-pathfinder" +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/53/8fc9b0cdc5b7f62746e6a01b85b6461e5ae27f871010a5fcf8fa6950766d/cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0", size = 52972, upload-time = "2026-06-30T00:58:04.34Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime" }, +] +cufft = [ + { name = "nvidia-cufft" }, +] +cufile = [ + { name = "nvidia-cufile" }, +] +cupti = [ + { name = "nvidia-cuda-cupti" }, +] +curand = [ + { name = "nvidia-curand" }, +] +cusolver = [ + { name = "nvidia-cusolver" }, +] +cusparse = [ + { name = "nvidia-cusparse" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc" }, +] +nvtx = [ + { name = "nvidia-nvtx" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -617,7 +701,7 @@ wheels = [ [[package]] name = "dask" -version = "2026.1.1" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -629,48 +713,49 @@ dependencies = [ { name = "pyyaml" }, { name = "toolz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/46/61ecde57bac647ca7eb6ffef8dcd90af6c1c649020874cd7fd8738003d62/dask-2026.1.1.tar.gz", hash = "sha256:12b1dbb0d6e92f287feb4076871600b2fba3a843d35ff214776ada5e9e7a1529", size = 10994732, upload-time = "2026-01-16T12:35:30.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/ca/58434f10ebb45d2ddc6edd6e2988abcd38da1ab1897a5f6f402711a594e9/dask-2026.6.0.tar.gz", hash = "sha256:ae3436bd31ebce2be75edf952bd1fc687a1f11ec03fe8b1bec2903d222344a45", size = 11544529, upload-time = "2026-06-11T17:48:43.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/4b/9cc373120658a2516aa5f6dcdde631c95d714b876d29ad8f8e009d793f3f/dask-2026.1.1-py3-none-any.whl", hash = "sha256:146b0ef2918eb581e06139183a88801b4a8c52d7c37758a91f8c3b75c54b0e15", size = 1481492, upload-time = "2026-01-16T12:35:22.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl", hash = "sha256:1539859071065dca379ca592ff76e911cd7965dc466da0040354ab466179189b", size = 1488995, upload-time = "2026-06-11T17:48:41.008Z" }, ] [package.optional-dependencies] array = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [[package]] name = "debugpy" -version = "1.8.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/75/9e12d4d42349b817cd545b89247696c67917aab907012ae5b64bbfea3199/debugpy-1.8.19.tar.gz", hash = "sha256:eea7e5987445ab0b5ed258093722d5ecb8bb72217c5c9b1e21f64efe23ddebdb", size = 1644590, upload-time = "2025-12-15T21:53:28.044Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/e2/48531a609b5a2aa94c6b6853afdfec8da05630ab9aaa96f1349e772119e9/debugpy-1.8.19-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:c5dcfa21de1f735a4f7ced4556339a109aa0f618d366ede9da0a3600f2516d8b", size = 2207620, upload-time = "2025-12-15T21:53:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/1b/d4/97775c01d56071969f57d93928899e5616a4cfbbf4c8cc75390d3a51c4a4/debugpy-1.8.19-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:806d6800246244004625d5222d7765874ab2d22f3ba5f615416cf1342d61c488", size = 3170796, upload-time = "2025-12-15T21:53:38.513Z" }, - { url = "https://files.pythonhosted.org/packages/8d/7e/8c7681bdb05be9ec972bbb1245eb7c4c7b0679bb6a9e6408d808bc876d3d/debugpy-1.8.19-cp311-cp311-win32.whl", hash = "sha256:783a519e6dfb1f3cd773a9bda592f4887a65040cb0c7bd38dde410f4e53c40d4", size = 5164287, upload-time = "2025-12-15T21:53:40.857Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a8/aaac7ff12ddf5d68a39e13a423a8490426f5f661384f5ad8d9062761bd8e/debugpy-1.8.19-cp311-cp311-win_amd64.whl", hash = "sha256:14035cbdbb1fe4b642babcdcb5935c2da3b1067ac211c5c5a8fdc0bb31adbcaa", size = 5188269, upload-time = "2025-12-15T21:53:42.359Z" }, - { url = "https://files.pythonhosted.org/packages/4a/15/d762e5263d9e25b763b78be72dc084c7a32113a0bac119e2f7acae7700ed/debugpy-1.8.19-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:bccb1540a49cde77edc7ce7d9d075c1dbeb2414751bc0048c7a11e1b597a4c2e", size = 2549995, upload-time = "2025-12-15T21:53:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/a7/88/f7d25c68b18873b7c53d7c156ca7a7ffd8e77073aa0eac170a9b679cf786/debugpy-1.8.19-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:e9c68d9a382ec754dc05ed1d1b4ed5bd824b9f7c1a8cd1083adb84b3c93501de", size = 4309891, upload-time = "2025-12-15T21:53:45.26Z" }, - { url = "https://files.pythonhosted.org/packages/c5/4f/a65e973aba3865794da65f71971dca01ae66666132c7b2647182d5be0c5f/debugpy-1.8.19-cp312-cp312-win32.whl", hash = "sha256:6599cab8a783d1496ae9984c52cb13b7c4a3bd06a8e6c33446832a5d97ce0bee", size = 5286355, upload-time = "2025-12-15T21:53:46.763Z" }, - { url = "https://files.pythonhosted.org/packages/d8/3a/d3d8b48fec96e3d824e404bf428276fb8419dfa766f78f10b08da1cb2986/debugpy-1.8.19-cp312-cp312-win_amd64.whl", hash = "sha256:66e3d2fd8f2035a8f111eb127fa508469dfa40928a89b460b41fd988684dc83d", size = 5328239, upload-time = "2025-12-15T21:53:48.868Z" }, - { url = "https://files.pythonhosted.org/packages/71/3d/388035a31a59c26f1ecc8d86af607d0c42e20ef80074147cd07b180c4349/debugpy-1.8.19-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:91e35db2672a0abaf325f4868fcac9c1674a0d9ad9bb8a8c849c03a5ebba3e6d", size = 2538859, upload-time = "2025-12-15T21:53:50.478Z" }, - { url = "https://files.pythonhosted.org/packages/4a/19/c93a0772d0962294f083dbdb113af1a7427bb632d36e5314297068f55db7/debugpy-1.8.19-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:85016a73ab84dea1c1f1dcd88ec692993bcbe4532d1b49ecb5f3c688ae50c606", size = 4292575, upload-time = "2025-12-15T21:53:51.821Z" }, - { url = "https://files.pythonhosted.org/packages/5c/56/09e48ab796b0a77e3d7dc250f95251832b8bf6838c9632f6100c98bdf426/debugpy-1.8.19-cp313-cp313-win32.whl", hash = "sha256:b605f17e89ba0ecee994391194285fada89cee111cfcd29d6f2ee11cbdc40976", size = 5286209, upload-time = "2025-12-15T21:53:53.602Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4e/931480b9552c7d0feebe40c73725dd7703dcc578ba9efc14fe0e6d31cfd1/debugpy-1.8.19-cp313-cp313-win_amd64.whl", hash = "sha256:c30639998a9f9cd9699b4b621942c0179a6527f083c72351f95c6ab1728d5b73", size = 5328206, upload-time = "2025-12-15T21:53:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b9/cbec520c3a00508327476c7fce26fbafef98f412707e511eb9d19a2ef467/debugpy-1.8.19-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:1e8c4d1bd230067bf1bbcdbd6032e5a57068638eb28b9153d008ecde288152af", size = 2537372, upload-time = "2025-12-15T21:53:57.318Z" }, - { url = "https://files.pythonhosted.org/packages/88/5e/cf4e4dc712a141e10d58405c58c8268554aec3c35c09cdcda7535ff13f76/debugpy-1.8.19-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d40c016c1f538dbf1762936e3aeb43a89b965069d9f60f9e39d35d9d25e6b809", size = 4268729, upload-time = "2025-12-15T21:53:58.712Z" }, - { url = "https://files.pythonhosted.org/packages/82/a3/c91a087ab21f1047db328c1d3eb5d1ff0e52de9e74f9f6f6fa14cdd93d58/debugpy-1.8.19-cp314-cp314-win32.whl", hash = "sha256:0601708223fe1cd0e27c6cce67a899d92c7d68e73690211e6788a4b0e1903f5b", size = 5286388, upload-time = "2025-12-15T21:54:00.687Z" }, - { url = "https://files.pythonhosted.org/packages/17/b8/bfdc30b6e94f1eff09f2dc9cc1f9cd1c6cde3d996bcbd36ce2d9a4956e99/debugpy-1.8.19-cp314-cp314-win_amd64.whl", hash = "sha256:8e19a725f5d486f20e53a1dde2ab8bb2c9607c40c00a42ab646def962b41125f", size = 5327741, upload-time = "2025-12-15T21:54:02.148Z" }, - { url = "https://files.pythonhosted.org/packages/25/3e/e27078370414ef35fafad2c06d182110073daaeb5d3bf734b0b1eeefe452/debugpy-1.8.19-py2.py3-none-any.whl", hash = "sha256:360ffd231a780abbc414ba0f005dad409e71c78637efe8f2bd75837132a41d38", size = 5292321, upload-time = "2025-12-15T21:54:16.024Z" }, +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { 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" }, ] [[package]] name = "decorator" -version = "5.2.1" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] @@ -693,11 +778,11 @@ wheels = [ [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] @@ -732,11 +817,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.29.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ee/29c668c50888588c432a702f7c2e8ee8a0c9e5286028d91f170308d6b2e9/filelock-3.29.5.tar.gz", hash = "sha256:6e6034c57a00a020e767f2614a5539863f056de7e7991d6d1473aef7ff73f156", size = 68927, upload-time = "2026-07-03T03:50:31.818Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e3/f1fae3647d170919c2cf2a898e77e7d1a4e5c7cae0aed7bb4bd3f5ebff6f/filelock-3.29.5-py3-none-any.whl", hash = "sha256:8af830889ba3a0ffcefbd6c7d2af8a54012058103771f2e10848222f476a1693", size = 45073, upload-time = "2026-07-03T03:50:30.445Z" }, ] [[package]] @@ -765,51 +850,51 @@ wheels = [ [[package]] name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { 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" }, ] [[package]] @@ -823,11 +908,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.1.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] [[package]] @@ -862,95 +947,116 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, - { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, - { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, - { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, - { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, - { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, - { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, - { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, - { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, - { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, - { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] name = "grpcio" -version = "1.76.0" +version = "1.82.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b6/19/e29d3979b420b92d516ee97f0bff4fb88cbb3d724791318f50beb55db549/grpcio-1.82.0.tar.gz", hash = "sha256:bfe3247e0bb598585ce26565730970d21bccf3c9dc547dc6703a1a3c7888e8e1", size = 13184479, upload-time = "2026-07-06T04:22:54.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ed/dfba8843c9e0cbf5b3ad33998fab400a6290e29432c5a2c94e684ebbfadf/grpcio-1.82.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:64be5bea5f32b1b13c81d8f322bd49ab22d388b87b5c146050c4faf2769c2036", size = 6181469, upload-time = "2026-07-06T04:21:04.208Z" }, + { url = "https://files.pythonhosted.org/packages/ce/41/fe3cc2de8ccdf0b6a8a9939d731c8bdab37f80c96581237de359baa37bbe/grpcio-1.82.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:03c0f3085e30e51aa4592a2a0b8ee518c9fb1593eb0368f57d7ccfdda2a1cff1", size = 11971108, upload-time = "2026-07-06T04:21:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/27/49/896d665121bd0a806d62a5bacf93c0db2f7097b29bc52bfab76141589f68/grpcio-1.82.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc428d9825f1f83e61c65c8e6fb662384105022231938d6775e1b6cfb2bf517a", size = 6760133, upload-time = "2026-07-06T04:21:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/78/1a/49739ab1be3f66106f54af46da599f2be9fdb79d4fc52e4d5a43a73cfc92/grpcio-1.82.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4a9389c1b3568b1d6b8c1d85d43eea701814e59280dd955ea0a2d2d65b90cb24", size = 7484373, upload-time = "2026-07-06T04:21:11.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a9/53cc88d792b318ec8ed924791cde109096a4174e6e2c115231ece5b69a9a/grpcio-1.82.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:848774370f0783f88e1fa9888f972fa61ab1ec985fffe2668f53bccfa51ef6cb", size = 6924263, upload-time = "2026-07-06T04:21:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/f3/23/d4ea910fcfbd62ad4a7efde95f0fbfec9fe99595be9b58a1a6d67c57c4ff/grpcio-1.82.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:743390c02fb9b565351fc4429b3385c84d851626df11d91a537636b02eaa67df", size = 7531845, upload-time = "2026-07-06T04:21:17.674Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a6/bc6503929c332ae9c74ccf74c57fb67c6b2d9e6ed27bfddb0888d812df52/grpcio-1.82.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b53959e9af6618b10b46dba4ac82706c4ee90443e95a28004c014c2532d7e053", size = 8568207, upload-time = "2026-07-06T04:21:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a5/7529b811efd1cb3181bc789d22ec9403b27aa3d3d8acb0b122987036143a/grpcio-1.82.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8045b89d863b5a271e65413361f75dddd70eeab6bf79f2b0e0eaeeb0900d8d24", size = 7938767, upload-time = "2026-07-06T04:21:22.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/1fa12730f95458fba4319b7935fc4c02a604404ea27f74756c51bf73a263/grpcio-1.82.0-cp311-cp311-win32.whl", hash = "sha256:24310aaf1f96a5327fc0d8e00fc98f0e9f90be8e09cc51becd2553d4b823d286", size = 4256371, upload-time = "2026-07-06T04:21:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/60/10/71202ae4c1cade358a07436c010c145210bc2a730a731e5d297a2dac4f6e/grpcio-1.82.0-cp311-cp311-win_amd64.whl", hash = "sha256:ddcd5f8db890c5c822e4c0b89d641b1db6a0c7255fff02535cbe77c4dabf450f", size = 5009634, upload-time = "2026-07-06T04:21:27.282Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e6/9ce68bc431224177b6f506cfb4896a742119c51255143069970955b6510b/grpcio-1.82.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:03cce11532da292215cd8e5f444de91982e39dfb41a916e0e0f91a5c128588ea", size = 6144680, upload-time = "2026-07-06T04:21:29.808Z" }, + { url = "https://files.pythonhosted.org/packages/59/93/00ecf28e1be7a70b4b4d148d3a551b6c9a37024a669c3650a2c374c64026/grpcio-1.82.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:01336fe31d1ea5d5154b0d803eb3584e69fb96e0e657acc87bd4d48d6abc9587", size = 11952213, upload-time = "2026-07-06T04:21:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/9be6bf4cddb5b5f39c0748cdf5639525cd4116a157c8f3802c9217e54882/grpcio-1.82.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe56f1bea709ddb2531fbd510a2dd6040e993985507c3b2bf3404507aee989b1", size = 6710770, upload-time = "2026-07-06T04:21:35.382Z" }, + { url = "https://files.pythonhosted.org/packages/00/97/62c0969e993673ebef16d526db2504f38bc4c73cf2593c28746791ab7956/grpcio-1.82.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:77a5d8fe331376c7aaa4e06e903a43bd144de4f98c6e414e13cbc5d64e5aa61e", size = 7450671, upload-time = "2026-07-06T04:21:37.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/1f/d83d9fbaaef2b6ebbf70a6e467000f13f92b4cf0b84c561c162b43128439/grpcio-1.82.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ce80ea66c803398fde9c5b1fd925920c9742da7f10f8b85acac0adac3e4d7e0", size = 6886855, upload-time = "2026-07-06T04:21:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/36/5ed2e28b8c5f00599c7d5e94d5f82c32cf73a6fa42d13c2900cb37c861ec/grpcio-1.82.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4665dc8c9ec30acff455e2b15c47b825f18647c555483aa1ccf670adead8adcc", size = 7501322, upload-time = "2026-07-06T04:21:43.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/69113709e14e24289940d6aef067b797c73c8c96f580683af7d5f09b22b0/grpcio-1.82.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4405d19ecfc7a8caa9043ff5a651e1871b54fc620f917de5dede7e6fb78e9e14", size = 8536896, upload-time = "2026-07-06T04:21:46.387Z" }, + { url = "https://files.pythonhosted.org/packages/30/f6/08bd6eb89a558f0f59dacaef747afda7c21e2c2ecf138ae2ec533dea8aeb/grpcio-1.82.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e580c19178609799660f52b1e1eb09248969be20dc44caaa4dcaa3e8450dd9", size = 7913890, upload-time = "2026-07-06T04:21:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a1/04b467e2ffc93f8e50b660bc6f47c3c8e9ba7603104a741e3d5663a261d4/grpcio-1.82.0-cp312-cp312-win32.whl", hash = "sha256:dbbd83dbaa856b387a4eba74ec3add7f594f090d3b4d390d829dd5834816aae4", size = 4240969, upload-time = "2026-07-06T04:21:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/15d3601384d5937e8d9fbe6d8e7b8171008d649744dd7b6c864932937ec9/grpcio-1.82.0-cp312-cp312-win_amd64.whl", hash = "sha256:73176d270699d76a9dc3753f25e01c19fb98f830f826a506e917d83629f1b39b", size = 5001575, upload-time = "2026-07-06T04:21:54.839Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c8/7aace81e7cd12c6ffccf0d47ac389a3f19e5280e9ab04d301d02855ecd25/grpcio-1.82.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:841bcaa24882921d41cd7a82118f9a766edcf8807c2f486e7deeb0ff5ea36181", size = 6146066, upload-time = "2026-07-06T04:21:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/10/17/b448f2265e4927188425c0b09fb943c39b50f7f53fbead644b44ccdf834b/grpcio-1.82.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d4bb7219c39c735e6573ae3c33aeafb2a4e1f1cc93a762f7899c283defaed365", size = 11948617, upload-time = "2026-07-06T04:22:00.066Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a3/fba54e50fffc9f74b1ffd5eb4e47310e6650557ef136a165f1fe7df03a65/grpcio-1.82.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:254230d2cb773a87380858d0fc74653b4761bf2013c83fe1c5d77ed8f241fc9d", size = 6714591, upload-time = "2026-07-06T04:22:04.04Z" }, + { url = "https://files.pythonhosted.org/packages/29/6f/f0ec3f1a61a746dc7037a737ba4cbe60959645311ac8542bb1ad4e72ae8b/grpcio-1.82.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:16d4f215344057eb21038319b05a0c531306230c17b075fa8461a944581006dd", size = 7454986, upload-time = "2026-07-06T04:22:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/b6/11/9d6ce94465d6a7f92c413430b3e77f0879b39427a217ffb3d23585df4bb3/grpcio-1.82.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a7c283b459151a2e84df32b28c31562ab3e7f624bccffce176c5608565b0212f", size = 6888623, upload-time = "2026-07-06T04:22:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/51/1a/b887adb7abb69081c4f24bc66c3612975e87caf5f7c6aa3916bb82d42e5f/grpcio-1.82.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96a3be4d2a482ff004c036f6391f33553b36a7627138d1ca3ecc1e70d55d8af5", size = 7505067, upload-time = "2026-07-06T04:22:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/90f11c0f227ac653cff769e05c7f279da945e134c9351a1c37d475714686/grpcio-1.82.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bc481e6c7e6c8721797f2ab764092c843fd5cea06d4c7e9f4c3e3b7ecf331eb0", size = 8535382, upload-time = "2026-07-06T04:22:14.453Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3d/de8569386685213135dfbaab58e221add11858485524cf7d5987efc6d70b/grpcio-1.82.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:680f633bc788652222cb90a568ec374edcd91e1fe7715a7c248ece8e1032c2bd", size = 7910708, upload-time = "2026-07-06T04:22:17.994Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/3950b32369763818ae89ef76d52e84bd034d3a0ab46bf315669c913b32e2/grpcio-1.82.0-cp313-cp313-win32.whl", hash = "sha256:640b4715b9c206e5176e46601aa1422c47c779f642a869dfd27062e8fde13dfb", size = 4240351, upload-time = "2026-07-06T04:22:20.626Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5c/c7b9a48efe973883f5707a311f87772a4c307a22ec80d6f3c851846a0a02/grpcio-1.82.0-cp313-cp313-win_amd64.whl", hash = "sha256:8523e81bd23f83e607aed28fbd8244b2be4aeb34e084fcef1bd1867709085c2a", size = 5000985, upload-time = "2026-07-06T04:22:23.365Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/abc5efe11b73eefa0183531032eef2a53f33aeced6484b0d9da559c12ed6/grpcio-1.82.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:136969867c14fcc743fa39b12d7da133cae7da4f8e0e7767b6b8b7e75a8c24fd", size = 6146898, upload-time = "2026-07-06T04:22:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e4/d01ee88ceac221436dce1584ae1f046bd44d0dffc5a92e95aa34857c4516/grpcio-1.82.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a7e246cd216662f513ae79ea3afadde6afe8a659b48a4170bae9a896e69ac254", size = 11954897, upload-time = "2026-07-06T04:22:29.114Z" }, + { url = "https://files.pythonhosted.org/packages/99/b9/2a35540ec08afc08859c35afd1a8a51e2585f39b12cf4eee68dcb1fa973d/grpcio-1.82.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:85c2a4e9e8a30d73d41e547a5101e57a1ed2093ec4b2daf6847c344adba00523", size = 6723102, upload-time = "2026-07-06T04:22:32.146Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ba/af71af59943b5d08879617a1d97495d45c53e43f3c6ef16f820c64e107f9/grpcio-1.82.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a3a7802328e0d20e6ac458ff1974a8096cfaa3ca84dea0903235f641f1d703c1", size = 7454545, upload-time = "2026-07-06T04:22:34.776Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7e/258aae12b68910140725873657469ff166f8968bdb0674e12d6452b1812c/grpcio-1.82.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9331e6b91b26cffd63fb70a545f3c2cac8dc1b434b4436f43915c23e1e22f265", size = 6889585, upload-time = "2026-07-06T04:22:37.411Z" }, + { url = "https://files.pythonhosted.org/packages/5e/04/cfada8930306b9101cfb405b33ecb3b72abce51d725f900f167ffbc3146b/grpcio-1.82.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:670ab93a0059e707de53b9e715304b9f566b6defee80e5490cdd15820ce032f6", size = 7514162, upload-time = "2026-07-06T04:22:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/955f95989338857a6d73ee838b4d9e8198d50972a70ca83ec22322396f4c/grpcio-1.82.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbfe2e321bb30b84799677d6e8469896e487b494e9e4fbc9b8cc4425fe054d44", size = 8536163, upload-time = "2026-07-06T04:22:42.801Z" }, + { url = "https://files.pythonhosted.org/packages/06/62/826da0b0f01c7ba3f7ea0185968f551290deb28968d98b1edf604c895db8/grpcio-1.82.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2472b72a55da8570e089f1ca22f34a350d86c109be2755f6ee1cca5ebbdd9b54", size = 7912573, upload-time = "2026-07-06T04:22:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/d8824a66ac1ac7fbcdf63806f1a8725d95426654e859afa4606070c78740/grpcio-1.82.0-cp314-cp314-win32.whl", hash = "sha256:c9feb986151c2726789599b3672c1c9faa1d0824da921dc3f33a8cb1f93e2861", size = 4321824, upload-time = "2026-07-06T04:22:48.866Z" }, + { url = "https://files.pythonhosted.org/packages/da/08/02e581cc0bbb4c7b842f85e66a945b46d5bcc5927575c1086edfbc3360e7/grpcio-1.82.0-cp314-cp314-win_amd64.whl", hash = "sha256:b1eeb0480d86965263f8c8ae7ee5360c284d22176a196aab02fce7fba8d89dd8", size = 5141112, upload-time = "2026-07-06T04:22:51.443Z" }, ] [[package]] @@ -964,61 +1070,71 @@ wheels = [ [[package]] name = "h5py" -version = "3.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/fd/8349b48b15b47768042cff06ad6e1c229f0a4bd89225bf6b6894fea27e6d/h5py-3.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5aaa330bcbf2830150c50897ea5dcbed30b5b6d56897289846ac5b9e529ec243", size = 3434135, upload-time = "2025-10-16T10:33:47.954Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b0/1c628e26a0b95858f54aba17e1599e7f6cd241727596cc2580b72cb0a9bf/h5py-3.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c970fb80001fffabb0109eaf95116c8e7c0d3ca2de854e0901e8a04c1f098509", size = 2870958, upload-time = "2025-10-16T10:33:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e3/c255cafc9b85e6ea04e2ad1bba1416baa1d7f57fc98a214be1144087690c/h5py-3.15.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80e5bb5b9508d5d9da09f81fd00abbb3f85da8143e56b1585d59bc8ceb1dba8b", size = 4504770, upload-time = "2025-10-16T10:33:54.357Z" }, - { url = "https://files.pythonhosted.org/packages/8b/23/4ab1108e87851ccc69694b03b817d92e142966a6c4abd99e17db77f2c066/h5py-3.15.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b849ba619a066196169763c33f9f0f02e381156d61c03e000bb0100f9950faf", size = 4700329, upload-time = "2025-10-16T10:33:57.616Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e4/932a3a8516e4e475b90969bf250b1924dbe3612a02b897e426613aed68f4/h5py-3.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f6c841efd4e6e5b7e82222eaf90819927b6d256ab0f3aca29675601f654f3c", size = 4152456, upload-time = "2025-10-16T10:34:00.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0a/f74d589883b13737021b2049ac796328f188dbb60c2ed35b101f5b95a3fc/h5py-3.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca8a3a22458956ee7b40d8e39c9a9dc01f82933e4c030c964f8b875592f4d831", size = 4617295, upload-time = "2025-10-16T10:34:04.154Z" }, - { url = "https://files.pythonhosted.org/packages/23/95/499b4e56452ef8b6c95a271af0dde08dac4ddb70515a75f346d4f400579b/h5py-3.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:550e51131376889656feec4aff2170efc054a7fe79eb1da3bb92e1625d1ac878", size = 2882129, upload-time = "2025-10-16T10:34:06.886Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bb/cfcc70b8a42222ba3ad4478bcef1791181ea908e2adbd7d53c66395edad5/h5py-3.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:b39239947cb36a819147fc19e86b618dcb0953d1cd969f5ed71fc0de60392427", size = 2477121, upload-time = "2025-10-16T10:34:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:316dd0f119734f324ca7ed10b5627a2de4ea42cc4dfbcedbee026aaa361c238c", size = 3399089, upload-time = "2025-10-16T10:34:12.135Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51469890e58e85d5242e43aab29f5e9c7e526b951caab354f3ded4ac88e7b76", size = 2847803, upload-time = "2025-10-16T10:34:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/00/69/ba36273b888a4a48d78f9268d2aee05787e4438557450a8442946ab8f3ec/h5py-3.15.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a33bfd5dfcea037196f7778534b1ff7e36a7f40a89e648c8f2967292eb6898e", size = 4914884, upload-time = "2025-10-16T10:34:18.452Z" }, - { url = "https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25c8843fec43b2cc368aa15afa1cdf83fc5e17b1c4e10cd3771ef6c39b72e5ce", size = 5109965, upload-time = "2025-10-16T10:34:21.853Z" }, - { url = "https://files.pythonhosted.org/packages/81/3d/d28172116eafc3bc9f5991b3cb3fd2c8a95f5984f50880adfdf991de9087/h5py-3.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a308fd8681a864c04423c0324527237a0484e2611e3441f8089fd00ed56a8171", size = 4561870, upload-time = "2025-10-16T10:34:26.69Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/393a7226024238b0f51965a7156004eaae1fcf84aa4bfecf7e582676271b/h5py-3.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f4a016df3f4a8a14d573b496e4d1964deb380e26031fc85fb40e417e9131888a", size = 5037161, upload-time = "2025-10-16T10:34:30.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:59b25cf02411bf12e14f803fef0b80886444c7fe21a5ad17c6a28d3f08098a1e", size = 2874165, upload-time = "2025-10-16T10:34:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/2d02b10a66747c54446e932171dd89b8b4126c0111b440e6bc05a7c852ec/h5py-3.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:61d5a58a9851e01ee61c932bbbb1c98fe20aba0a5674776600fb9a361c0aa652", size = 2458214, upload-time = "2025-10-16T10:34:35.733Z" }, - { url = "https://files.pythonhosted.org/packages/88/b3/40207e0192415cbff7ea1d37b9f24b33f6d38a5a2f5d18a678de78f967ae/h5py-3.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8440fd8bee9500c235ecb7aa1917a0389a2adb80c209fa1cc485bd70e0d94a5", size = 3376511, upload-time = "2025-10-16T10:34:38.596Z" }, - { url = "https://files.pythonhosted.org/packages/31/96/ba99a003c763998035b0de4c299598125df5fc6c9ccf834f152ddd60e0fb/h5py-3.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab2219dbc6fcdb6932f76b548e2b16f34a1f52b7666e998157a4dfc02e2c4123", size = 2826143, upload-time = "2025-10-16T10:34:41.342Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c2/fc6375d07ea3962df7afad7d863fe4bde18bb88530678c20d4c90c18de1d/h5py-3.15.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8cb02c3a96255149ed3ac811eeea25b655d959c6dd5ce702c9a95ff11859eb5", size = 4908316, upload-time = "2025-10-16T10:34:44.619Z" }, - { url = "https://files.pythonhosted.org/packages/d9/69/4402ea66272dacc10b298cca18ed73e1c0791ff2ae9ed218d3859f9698ac/h5py-3.15.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:121b2b7a4c1915d63737483b7bff14ef253020f617c2fb2811f67a4bed9ac5e8", size = 5103710, upload-time = "2025-10-16T10:34:48.639Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f6/11f1e2432d57d71322c02a97a5567829a75f223a8c821764a0e71a65cde8/h5py-3.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59b0d63b318bf3cc06687def2b45afd75926bbc006f7b8cd2b1a231299fc8599", size = 4556042, upload-time = "2025-10-16T10:34:51.841Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/3eda3ef16bfe7a7dbc3d8d6836bbaa7986feb5ff091395e140dc13927bcc/h5py-3.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e02fe77a03f652500d8bff288cbf3675f742fc0411f5a628fa37116507dc7cc0", size = 5030639, upload-time = "2025-10-16T10:34:55.257Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ea/fbb258a98863f99befb10ed727152b4ae659f322e1d9c0576f8a62754e81/h5py-3.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:dea78b092fd80a083563ed79a3171258d4a4d307492e7cf8b2313d464c82ba52", size = 2864363, upload-time = "2025-10-16T10:34:58.099Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c9/35021cc9cd2b2915a7da3026e3d77a05bed1144a414ff840953b33937fb9/h5py-3.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:c256254a8a81e2bddc0d376e23e2a6d2dc8a1e8a2261835ed8c1281a0744cd97", size = 2449570, upload-time = "2025-10-16T10:35:00.473Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2c/926eba1514e4d2e47d0e9eb16c784e717d8b066398ccfca9b283917b1bfb/h5py-3.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f4fb0567eb8517c3ecd6b3c02c4f4e9da220c8932604960fd04e24ee1254763", size = 3380368, upload-time = "2025-10-16T10:35:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/65/4b/d715ed454d3baa5f6ae1d30b7eca4c7a1c1084f6a2edead9e801a1541d62/h5py-3.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:954e480433e82d3872503104f9b285d369048c3a788b2b1a00e53d1c47c98dd2", size = 2833793, upload-time = "2025-10-16T10:35:05.623Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d4/ef386c28e4579314610a8bffebbee3b69295b0237bc967340b7c653c6c10/h5py-3.15.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd125c131889ebbef0849f4a0e29cf363b48aba42f228d08b4079913b576bb3a", size = 4903199, upload-time = "2025-10-16T10:35:08.972Z" }, - { url = "https://files.pythonhosted.org/packages/33/5d/65c619e195e0b5e54ea5a95c1bb600c8ff8715e0d09676e4cce56d89f492/h5py-3.15.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28a20e1a4082a479b3d7db2169f3a5034af010b90842e75ebbf2e9e49eb4183e", size = 5097224, upload-time = "2025-10-16T10:35:12.808Z" }, - { url = "https://files.pythonhosted.org/packages/30/30/5273218400bf2da01609e1292f562c94b461fcb73c7a9e27fdadd43abc0a/h5py-3.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8df5267f545b4946df8ca0d93d23382191018e4cda2deda4c2cedf9a010e13", size = 4551207, upload-time = "2025-10-16T10:35:16.24Z" }, - { url = "https://files.pythonhosted.org/packages/d3/39/a7ef948ddf4d1c556b0b2b9559534777bccc318543b3f5a1efdf6b556c9c/h5py-3.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99d374a21f7321a4c6ab327c4ab23bd925ad69821aeb53a1e75dd809d19f67fa", size = 5025426, upload-time = "2025-10-16T10:35:19.831Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d8/7368679b8df6925b8415f9dcc9ab1dab01ddc384d2b2c24aac9191bd9ceb/h5py-3.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:9c73d1d7cdb97d5b17ae385153472ce118bed607e43be11e9a9deefaa54e0734", size = 2865704, upload-time = "2025-10-16T10:35:22.658Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" }, +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { 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" }, + { 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]] name = "hdf5plugin" -version = "6.0.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/4f/9130151e3aa475b3e4e9a611bf608107fe5c72d277d74c4cf36f164b7c81/hdf5plugin-6.0.0.tar.gz", hash = "sha256:847ed9e96b451367a110f0ba64a3b260d38d64bbf3f25751858d3b56e094cfe0", size = 66372085, upload-time = "2025-10-08T18:16:28.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/64/0fc6b68e5bc671e7b81d67b930fbed3a4e8a2a92dc4af0f7282b2a2ff988/hdf5plugin-7.0.0.tar.gz", hash = "sha256:e6e6b1f8b0c4d2ca87e616ddc31d08330b36207e466357040f95269e5e0401c8", size = 68284761, upload-time = "2026-06-25T20:59:40.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/13/15017f6210bfea843316d62f0f121e364e17bb129444ed803a256a213036/hdf5plugin-6.0.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:a59fbd5d4290a8a5334d82ccb4c6b9bfc7aaf586de7fedb88762e8601bc05fd4", size = 13339413, upload-time = "2025-10-08T18:16:10.656Z" }, - { url = "https://files.pythonhosted.org/packages/40/bf/d1f3765fb879820d7331e30e860b684f5b78d3ec17324e8f54130cbe560b/hdf5plugin-6.0.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d301f4b9295872bacf277c70628d4c5e965ee47db762d8fde2d4849f201b9897", size = 42858563, upload-time = "2025-10-08T18:16:14.106Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/37d0b84fbbf26bf0d6a99a8f98bcd82bb6d437dc8cabee259fb3d7506ec7/hdf5plugin-6.0.0-py3-none-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:78b082ea355fe46bf5b396024de1fb662a1aaf9a5e11861ad61a5a2a6316d59d", size = 45126124, upload-time = "2025-10-08T18:16:17.992Z" }, - { url = "https://files.pythonhosted.org/packages/ed/2f/1046d464ad1db29a4f6c70ba4e19b39baa8a6542c719eaa4e765108f07f1/hdf5plugin-6.0.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e0524d18ddc41c0cf2e1bb2e529d4e154c286f6a1bd85f3d44019d2a17574a", size = 44857273, upload-time = "2025-10-08T18:16:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/75478bdfee85533777de4204373f563aa7a1074355300743c3aedc33cac5/hdf5plugin-6.0.0-py3-none-win_amd64.whl", hash = "sha256:99866f90be1ceac5519e6e038669564be326c233618d59ba1f38c9dd8c32099e", size = 3379316, upload-time = "2025-10-08T18:16:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/03/5a/00d0f491d420d491b5134ee044cd8ead5103382d88f4c8aee623258a6348/hdf5plugin-7.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:e0ff0a81e6319575ffc8bf230b8df43f3f19f6fe96843e113e04c5ba9f7f3141", size = 6941923, upload-time = "2026-06-25T20:59:24.517Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/b28f4102e619c262b29bfcd13ccf6799e26f9ff113d2fdce19050ae29448/hdf5plugin-7.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:97ea4ff6114223c5e8ccce7e23cc6cd398b58c02f4e988e967aeea914fcb8030", size = 6259223, upload-time = "2026-06-25T20:59:26.246Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/67263173ff61c49800eec4f16b4d84e6a39170be8d4871d4eb0d540b71ee/hdf5plugin-7.0.0-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f3ba9f4e2a370340b45e7042d1b1b1577ab259c1ddf00956264836fa33052ef", size = 42789778, upload-time = "2026-06-25T20:59:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/75/d2/66673e2d0ef8499d08dd7061caa194e4ddec12df73ab512431c60538f675/hdf5plugin-7.0.0-py3-none-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a4cb2207ec3ac538fc728e4596e9e49aed6c4487a641071fb370c04a361e7bf", size = 45295544, upload-time = "2026-06-25T20:59:31.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0b/855e50e27eab8338c71c3157672c065cd2a2ab38887b3ea4bf128cbd89a1/hdf5plugin-7.0.0-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ad4ab0d3367699d132e61b1cc382a0c640a7dba56536e5105508205ebbe8762", size = 45012013, upload-time = "2026-06-25T20:59:35.029Z" }, + { url = "https://files.pythonhosted.org/packages/4e/14/32cf2aae083c74b95678875e11d25d0cb9e51a33d27f4218eb9aeacfcd70/hdf5plugin-7.0.0-py3-none-win_amd64.whl", hash = "sha256:2e052af8d7848e8bac92646584617503a08bb9b466cfa810a49ecd93e89b7ffa", size = 3523827, upload-time = "2026-06-25T20:59:37.758Z" }, ] [[package]] @@ -1051,45 +1167,46 @@ wheels = [ [[package]] name = "identify" -version = "2.6.16" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "imageio" -version = "2.37.2" +version = "2.37.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, ] [[package]] name = "importlib-metadata" -version = "8.7.1" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, ] [[package]] @@ -1103,7 +1220,7 @@ wheels = [ [[package]] name = "ipykernel" -version = "7.1.0" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appnope", marker = "sys_platform == 'darwin'" }, @@ -1113,21 +1230,21 @@ dependencies = [ { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, + { name = "nest-asyncio2" }, { name = "packaging" }, { name = "psutil" }, { name = "pyzmq" }, { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a4/4948be6eb88628505b83a1f2f40d90254cab66abf2043b3c40fa07dfce0f/ipykernel-7.1.0.tar.gz", hash = "sha256:58a3fc88533d5930c3546dc7eac66c6d288acde4f801e2001e65edc5dc9cf0db", size = 174579, upload-time = "2025-10-27T09:46:39.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, ] [[package]] name = "ipython" -version = "9.9.0" +version = "9.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1137,14 +1254,15 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/92/162cfaee4ccf370465c5af1ce36a9eacec1becb552f2033bb3584e6f640a/ipython-9.9.0-py3-none-any.whl", hash = "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b", size = 621431, upload-time = "2026-01-05T12:36:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, ] [[package]] @@ -1189,14 +1307,14 @@ wheels = [ [[package]] name = "jedi" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -1213,20 +1331,20 @@ wheels = [ [[package]] name = "json5" -version = "0.13.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" }, ] [[package]] name = "jsonpointer" -version = "3.0.0" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] [[package]] @@ -1269,9 +1387,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jupyter-builder" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/45/d0df8b43c10a61529c0f4a8af5e19ebe108f0c3af8f57e0fc358969907af/jupyter_builder-1.0.2.tar.gz", hash = "sha256:6155d78a5325010532a6419ffcba89eac643fd1aa56ea83115e661924d6f6aab", size = 968638, upload-time = "2026-06-12T02:33:25.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/b6/c418e0b3256f67c04933566b80bfce947350682db92c4b786a8653db32d6/jupyter_builder-1.0.2-py3-none-any.whl", hash = "sha256:b024f65d36e1d530542db597b00dd513261aa59842e0d0fbbb1015a9f1935e9c", size = 910789, upload-time = "2026-06-12T02:33:23.317Z" }, +] + [[package]] name = "jupyter-client" -version = "8.8.0" +version = "8.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-core" }, @@ -1279,10 +1410,11 @@ dependencies = [ { name = "pyzmq" }, { name = "tornado" }, { name = "traitlets" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, ] [[package]] @@ -1300,7 +1432,7 @@ wheels = [ [[package]] name = "jupyter-events" -version = "0.12.0" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema", extra = ["format-nongpl"] }, @@ -1312,26 +1444,26 @@ dependencies = [ { name = "rfc3986-validator" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, ] [[package]] name = "jupyter-lsp" -version = "2.3.0" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, ] [[package]] name = "jupyter-server" -version = "2.17.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1354,9 +1486,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, ] [[package]] @@ -1374,26 +1506,26 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.5.2" +version = "4.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, { name = "httpx" }, { name = "ipykernel" }, { name = "jinja2" }, + { name = "jupyter-builder" }, { name = "jupyter-core" }, { name = "jupyter-lsp" }, { name = "jupyter-server" }, { name = "jupyterlab-server" }, { name = "notebook-shim" }, { name = "packaging" }, - { name = "setuptools" }, { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/dc/2c8c4ff1aee27ac999ba04c373c5d0d7c6c181b391640d7b916b884d5985/jupyterlab-4.5.2.tar.gz", hash = "sha256:c80a6b9f6dace96a566d590c65ee2785f61e7cd4aac5b4d453dcc7d0d5e069b7", size = 23990371, upload-time = "2026-01-12T12:27:08.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/2a/d6af53bfd45a43a5bfe7e40ba47ee7a8921a807daf4bb708e3a295bbb54d/jupyterlab-4.6.1.tar.gz", hash = "sha256:75315982ed28427edaa62bb85eadb5105e4043a757643c910efd787fe6ed0837", size = 28179125, upload-time = "2026-06-29T12:48:45.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/78/7e455920f104ef2aa94a4c0d2b40e5b44334ee7057eae1aa1fb97b9631ad/jupyterlab-4.5.2-py3-none-any.whl", hash = "sha256:76466ebcfdb7a9bb7e2fbd6459c0e2c032ccf75be673634a84bee4b3e6b13ab6", size = 12385807, upload-time = "2026-01-12T12:27:03.923Z" }, + { url = "https://files.pythonhosted.org/packages/5a/81/90ac6cc31d248e83a0d1eab343a5e6e68bc783d3f74fbe61640f42a61da4/jupyterlab-4.6.1-py3-none-any.whl", hash = "sha256:85a58546c831f3dce6cf919468c26874c9065e99c42279fb4abb8e1b552a98bb", size = 17164660, upload-time = "2026-06-29T12:48:41.21Z" }, ] [[package]] @@ -1434,92 +1566,108 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] [[package]] @@ -1533,28 +1681,27 @@ wheels = [ [[package]] name = "lazy-loader" -version = "0.4" +version = "0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, ] [[package]] name = "lightning-utilities" -version = "0.15.2" +version = "0.15.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, - { name = "setuptools" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/39/6fc58ca81492db047149b4b8fd385aa1bfb8c28cd7cacb0c7eb0c44d842f/lightning_utilities-0.15.2.tar.gz", hash = "sha256:cdf12f530214a63dacefd713f180d1ecf5d165338101617b4742e8f22c032e24", size = 31090, upload-time = "2025-08-06T13:57:39.242Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/73/3d757cb3fc16f0f9794dd289bcd0c4a031d9cf54d8137d6b984b2d02edf3/lightning_utilities-0.15.2-py3-none-any.whl", hash = "sha256:ad3ab1703775044bbf880dbf7ddaaac899396c96315f3aa1779cec9d618a9841", size = 29431, upload-time = "2025-08-06T13:57:38.046Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, ] [[package]] @@ -1568,23 +1715,23 @@ wheels = [ [[package]] name = "mako" -version = "1.3.10" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] name = "markdown" -version = "3.10" +version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] @@ -1663,87 +1810,88 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, { name = "cycler" }, { name = "fonttools" }, { name = "kiwisolver" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a2/78f662f1b18968531f67d3fcde1b7ea8496920bacd4f16ddb5b79d112e46/matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef", size = 9436261, upload-time = "2026-06-12T02:27:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/044f1de43901310202f4c79acf4f141be53b2ca8d8380e2fcefb3d523a75/matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233", size = 9264669, upload-time = "2026-06-12T02:27:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/f0b4f9ba7ec14a7af8151f3ad71ecfe3561e6ba38cfab1db3681ba4ca112/matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45", size = 10021076, upload-time = "2026-06-12T02:27:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/4d679c6dcd594a156542080ac907ddccf7b09ca11655c4b28eca8e9ee5da/matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055", size = 10828999, upload-time = "2026-06-12T02:27:42.433Z" }, + { url = "https://files.pythonhosted.org/packages/07/74/0a3683802037d8cd013144d77c247219b47f2aabace6fdde74faa12bacf7/matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3", size = 10913103, upload-time = "2026-06-12T02:27:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/970fcbf381e82ec66fdf5da8ea76e2e9240f61a24011ce9fd1d42c37ac2d/matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4", size = 9310945, upload-time = "2026-06-12T02:27:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/14/4e/6e7cfed23611265ded53806852343b5c59339e506e84c474a9b5afc3b249/matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7", size = 8999304, upload-time = "2026-06-12T02:27:48.798Z" }, + { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, + { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c2/f5da6cd37ed6871f5c9b3c0507ddb69f14d6c36fac4541e4e0c60cb8cdfc/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62", size = 9434094, upload-time = "2026-06-12T02:29:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/56f66906e0f87a0c6d0d0acbd34dbc9432b1931d8f26ef618bd6f92932a9/matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd", size = 9262183, upload-time = "2026-06-12T02:29:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/c4ecab06b7ea36a570c4f3bd2d48d1799fd5d9174470e45c2194199431e7/matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64", size = 10015653, upload-time = "2026-06-12T02:29:13.251Z" }, ] [[package]] name = "matplotlib-inline" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] name = "mistune" -version = "3.2.0" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/5f/007786743f962224423753b78f7d7acb0f2ade46d1604f2e0fa2bedf9020/mistune-3.3.2.tar.gz", hash = "sha256:e12ee4f1e74336e91aa1141e35f913b337c40bdf7c0cc49f21fb853a27e8b62f", size = 111284, upload-time = "2026-06-23T00:29:28.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/44/43/894c2cbbcbdf53b57d1257a249811abe2ee9ab7ef76af301b40f1c054533/mistune-3.3.2-py3-none-any.whl", hash = "sha256:a678a56387d487db7368ede4647cb2ba1deff22ce61f92343e4ebe0ddfce4f2d", size = 61554, upload-time = "2026-06-23T00:29:27.088Z" }, ] [[package]] @@ -1757,7 +1905,7 @@ wheels = [ [[package]] name = "nbclient" -version = "0.10.4" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-client" }, @@ -1765,14 +1913,14 @@ dependencies = [ { name = "nbformat" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, ] [[package]] name = "nbconvert" -version = "7.16.6" +version = "7.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, @@ -1790,9 +1938,9 @@ dependencies = [ { name = "pygments" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525, upload-time = "2025-01-28T09:29:12.551Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, ] [[package]] @@ -1811,12 +1959,12 @@ wheels = [ ] [[package]] -name = "nest-asyncio" -version = "1.6.0" +name = "nest-asyncio2" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, ] [[package]] @@ -1854,7 +2002,8 @@ name = "numcodecs" version = "0.16.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/bd/8a391e7c356366224734efd24da929cc4796fff468bfb179fe1af6548535/numcodecs-0.16.5.tar.gz", hash = "sha256:0d0fb60852f84c0bd9543cc4d2ab9eefd37fc8efcc410acd4777e62a1d300318", size = 6276387, upload-time = "2025-11-21T02:49:48.986Z" } @@ -1883,233 +2032,310 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.1" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, - { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, - { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, - { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, - { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, - { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, - { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, - { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, - { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, - { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, - { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, - { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, - { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, - { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, - { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, - { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, - { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, - { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, - { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, - { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, - { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, - { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, - { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, - { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, - { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, - { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, - { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, - { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, - { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, - { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { 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" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] [[package]] -name = "nvidia-cublas-cu12" -version = "12.8.4.1" +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, ] [[package]] -name = "nvidia-cuda-cupti-cu12" -version = "12.8.90" +name = "nvidia-cuda-cupti" +version = "13.0.85" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, ] [[package]] -name = "nvidia-cuda-nvrtc-cu12" -version = "12.8.93" +name = "nvidia-cuda-nvrtc" +version = "13.0.88" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, ] [[package]] -name = "nvidia-cuda-runtime-cu12" -version = "12.8.90" +name = "nvidia-cuda-runtime" +version = "13.0.96" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, ] [[package]] -name = "nvidia-cudnn-cu12" -version = "9.10.2.21" +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cublas" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, ] [[package]] -name = "nvidia-cufft-cu12" -version = "11.3.3.83" +name = "nvidia-cufft" +version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, ] [[package]] -name = "nvidia-cufile-cu12" -version = "1.13.1.3" +name = "nvidia-cufile" +version = "1.15.1.6" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, ] [[package]] -name = "nvidia-curand-cu12" -version = "10.3.9.90" +name = "nvidia-curand" +version = "10.4.0.35" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, ] [[package]] -name = "nvidia-cusolver-cu12" -version = "11.7.3.90" +name = "nvidia-cusolver" +version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, ] [[package]] -name = "nvidia-cusparse-cu12" -version = "12.5.8.93" +name = "nvidia-cusparse" +version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, ] [[package]] -name = "nvidia-cusparselt-cu12" -version = "0.7.1" +name = "nvidia-cusparselt-cu13" +version = "0.8.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, ] [[package]] -name = "nvidia-nccl-cu12" -version = "2.27.5" +name = "nvidia-nccl-cu13" +version = "2.29.7" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, ] [[package]] -name = "nvidia-nvjitlink-cu12" -version = "12.8.93" +name = "nvidia-nvjitlink" +version = "13.0.88" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, ] [[package]] -name = "nvidia-nvshmem-cu12" -version = "3.3.20" +name = "nvidia-nvshmem-cu13" +version = "3.4.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5", size = 124657145, upload-time = "2025-08-04T20:25:19.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, ] [[package]] -name = "nvidia-nvtx-cu12" -version = "12.8.90" +name = "nvidia-nvtx" +version = "13.0.85" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { 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 = "optuna" -version = "4.7.0" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, { name = "colorlog" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pyyaml" }, { name = "sqlalchemy" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/b2/b5e12de7b4486556fe2257611b55dbabf30d0300bdb031831aa943ad20e4/optuna-4.7.0.tar.gz", hash = "sha256:d91817e2079825557bd2e97de2e8c9ae260bfc99b32712502aef8a5095b2d2c0", size = 479740, upload-time = "2026-01-19T05:45:52.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/aa/05f5e3f662cc96a4c478fc3446b8ed6359825a2b504ecb614a9ac84e4a4d/optuna-4.9.0.tar.gz", hash = "sha256:b322e5cbdf1655fb84c37646c4a7a1f391de1b47806bbe222e015825d0a82b87", size = 485834, upload-time = "2026-06-01T06:23:30.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/d1/6c8a4fbb38a9e3565f5c36b871262a85ecab3da48120af036b1e4937a15c/optuna-4.7.0-py3-none-any.whl", hash = "sha256:e41ec84018cecc10eabf28143573b1f0bde0ba56dba8151631a590ecbebc1186", size = 413894, upload-time = "2026-01-19T05:45:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f3/e5fcd5d9b15771ed6dc10e3a7eeddc672e418f4f4c4653d216cc1d857e2d/optuna-4.9.0-py3-none-any.whl", hash = "sha256:f52f3be6148654850c92a5860d398fd88ec6b2c84ab68d9c3d07dcff02e7afee", size = 425553, upload-time = "2026-06-01T06:23:28.804Z" }, ] [[package]] @@ -2123,11 +2349,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -2141,11 +2367,11 @@ wheels = [ [[package]] name = "parso" -version = "0.8.5" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] [[package]] @@ -2175,94 +2401,92 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] name = "pint" -version = "0.25.2" +version = "0.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flexcache" }, @@ -2270,18 +2494,18 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/74/bc3f671997158aef171194c3c4041e549946f4784b8690baa0626a0a164b/pint-0.25.2.tar.gz", hash = "sha256:85a45d1da8fe9c9f7477fed8aef59ad2b939af3d6611507e1a9cbdacdcd3450a", size = 254467, upload-time = "2025-11-06T22:08:09.184Z" } +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/ab/88/550d41e81e6d43335603a960cd9c75c1d88f9cf01bc9d4ee8e86290aba7d/pint-0.25.2-py3-none-any.whl", hash = "sha256:ca35ab1d8eeeb6f7d9942b3cb5f34ca42b61cdd5fb3eae79531553dcca04dda7", size = 306762, upload-time = "2025-11-06T22:08:07.745Z" }, + { 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" }, ] [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -2295,7 +2519,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -2304,18 +2528,18 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "prometheus-client" -version = "0.24.1" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, ] [[package]] @@ -2332,45 +2556,45 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.4" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, - { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, - { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, - { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, - { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] name = "psutil" -version = "7.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" }, - { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" }, - { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" }, - { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" }, - { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" }, - { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" }, - { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" }, +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { 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]] @@ -2422,34 +2646,34 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyparsing" -version = "3.3.1" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, + { 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 = "pytest" -version = "9.0.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2458,41 +2682,43 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "python-box" -version = "7.3.2" +version = "7.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/f7/635eed8c500adf26208e86e985bbffb6ff039cd8950e3a4749ceca904218/python_box-7.3.2.tar.gz", hash = "sha256:028b9917129e67f311932d93347b8a4f1b500d7a5a2870ee3c035f4e7b19403b", size = 45771, upload-time = "2025-01-16T19:10:05.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/0f/34e7ee0a72f1464b4c7a2e8bafb389f230477256af586bc82bcfad85295a/python_box-7.4.1.tar.gz", hash = "sha256:e412e36c25fca8223560516d53ef6c7993591c3b0ec8bb4ec582bf7defdd79f0", size = 49859, upload-time = "2026-02-21T16:21:16.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/3f/133619c00d8a9d4f86efd8626c0e4ec356c8b8dabac66da18dac5cfaf70c/python_box-7.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:32163b1cb151883de0da62b0cd3572610dc72ccf0762f2447baf1d2562e25bea", size = 1834122, upload-time = "2025-01-16T19:10:27.886Z" }, - { url = "https://files.pythonhosted.org/packages/b7/52/51b6081562daa864847692536260200b337ccb4176d1e5f626ae48a7d493/python_box-7.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:064cb59b41e25aaf7dbd39efe53151a5f6797cc1cb3c68610f0f21a9d406d67e", size = 4305556, upload-time = "2025-01-16T19:15:29.286Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e2/6cdc8649381ae14def88c3e2e93d5b8b17a622a95896e0d1c92861270b7d/python_box-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:488f0fba9a6416c3334b602366dcd92825adb0811e07e03753dfcf0ed79cd6f7", size = 1232328, upload-time = "2025-01-16T19:11:37.676Z" }, - { url = "https://files.pythonhosted.org/packages/45/68/0c2f289d8055d3e1b156ff258847f0e8f1010063e284cf5a612f09435575/python_box-7.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39009a2da5c20133718b24891a206592adbe09169856aedc450ad1600fc2e511", size = 1819681, upload-time = "2025-01-16T19:10:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/76b4d6d0e41edb676a229f032848a1ecea166890fa8d501513ea1a030f4d/python_box-7.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2a72e2f6fb97c7e472ff3272da207ecc615aa222e52e98352391428527c469", size = 4270424, upload-time = "2025-01-16T19:15:32.376Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6b/32484b2a3cd2fb5e5f56bfb53a4537d93a4d2014ccf7fc0c0017fa6f65e9/python_box-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9eead914b9fb7d98a1473f5027dcfe27d26b3a10ffa33b9ba22cf948a23cd280", size = 1211252, upload-time = "2025-01-16T19:11:00.248Z" }, - { url = "https://files.pythonhosted.org/packages/2f/39/8bec609e93dbc5e0d3ea26cfb5af3ca78915f7a55ef5414713462fedeb59/python_box-7.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1dfc3b9b073f3d7cad1fa90de98eaaa684a494d0574bbc0666f74fa8307fd6b6", size = 1804675, upload-time = "2025-01-16T19:10:23.281Z" }, - { url = "https://files.pythonhosted.org/packages/88/ae/baf3a8057d8129896a7e02619df43ea0d918fc5b2bb66eb6e2470595fbac/python_box-7.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca4685a7f764b5a71b6e08535ce2a96b7964bb63d8cb4df10f6bb7147b6c54b", size = 4265645, upload-time = "2025-01-16T19:15:34.087Z" }, - { url = "https://files.pythonhosted.org/packages/43/90/72367e03033c11a5e82676ee389b572bf136647ff4e3081557392b37e1ad/python_box-7.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e143295f74d47a9ab24562ead2375c9be10629599b57f2e86717d3fff60f82a9", size = 1206740, upload-time = "2025-01-16T19:11:30.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/13/8a990c6e2b6cc12700dce16f3cb383324e6d9a30f604eca22a2fdf84c923/python_box-7.3.2-py3-none-any.whl", hash = "sha256:fd7d74d5a848623f93b5221fd9fb00b8c00ff0e130fa87f396277aa188659c92", size = 29479, upload-time = "2025-01-16T19:10:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a8/c8bcd3ff0905ec549273ea3485e6b9f2039f57baab419123fb18f964f829/python_box-7.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3f76dad8be9d57d65a3edc792b952f7afe3991515aa6eba616cf5efb2fbb2e0c", size = 1870869, upload-time = "2026-02-21T16:21:34.16Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/9382766d388e258363a18a094e251d2624e3c524614c733d1afa989d9770/python_box-7.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c66582f41a94d46cb0896d468b0efebf9bc4c3a5634cd15373d871767c2e741d", size = 4494287, upload-time = "2026-02-21T16:26:03.131Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/b9d1d4550615f69f6f9c72767f026543442739e5c56adf6f844b50d88251/python_box-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:43c62f66d694eb6410f51eb2eb5726f9b466e6f685e5dc90b5cd11f7b3047362", size = 1321085, upload-time = "2026-02-21T16:22:01.093Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d9/d05f317b38b42253422d8483f5d7dc16d382c99ddc253e426639a0f2f235/python_box-7.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dfb91effff00d9e23486c4f0db3b19e03d602ebb7c9e20fc6a287c704fad2552", size = 1849441, upload-time = "2026-02-21T16:21:37.314Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/383eb3d658f36c6e531c8cf1e348ccb4b5031231df4aeb7742bb159a3166/python_box-7.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f977f00e715b030cee6ffef2322ff8ce100ffbf1dbcc4ef91099c75752d5f8", size = 4485153, upload-time = "2026-02-21T16:26:04.507Z" }, + { url = "https://files.pythonhosted.org/packages/65/f9/5de3c18415dd6f5286f00e6539c0ae3cceb1c6aaf28d1d5f17b0b568c97f/python_box-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ca9a18fd15326bc267e9cc7e0e6e3a0cb78d11507940f43f687adf7e156d882", size = 1295520, upload-time = "2026-02-21T16:22:26.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e9/48d1b1eb21efc3f82a31b037b6903c9139018f686d96d251faa4cb0d593a/python_box-7.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:85db37b43094bf6c4884b931fb149a7850db5ce331f6e191edf98b453e6cf2d6", size = 1845195, upload-time = "2026-02-21T16:21:46.235Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/48d38c855f277223caf3aa79518476f95abc07f04386940855b7bd3d95f6/python_box-7.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb204822c7638bd2dbed5c55d6ab264c6903c37d18dee5c45bdbda58b2e1e17a", size = 4468245, upload-time = "2026-02-21T16:26:05.701Z" }, + { url = "https://files.pythonhosted.org/packages/17/1d/7a1e04f37674399e0f3076cfe1fa358f6a51540ae98299a06f2c0424c471/python_box-7.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:615da3fafd41572aec1b905832555c0ea08b6fbc27cc917356e257a9a5721af7", size = 1295564, upload-time = "2026-02-21T16:22:36.547Z" }, + { url = "https://files.pythonhosted.org/packages/94/a2/771b5e526bba2214ac2d30e321209a66680c40788616a45cf01005e95204/python_box-7.4.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:33c6701faa51fd87f0dcc538873c0fad2b3a1cc3750eab85835cd071cadf1948", size = 1875508, upload-time = "2026-02-21T16:21:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5f/0e7ea7640ba60ff459ce37e340d816ac5e91b7a9a7c3c161f9dabe622be6/python_box-7.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:ae8c540a0457f52350211d24690211251912018e1e0c1857f50792729d6f562c", size = 1314304, upload-time = "2026-02-21T16:22:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/06/a6/5d3f3abf46b37aa44b1f6788d287c8b4f2319b55013191dddf25b9e6d62c/python_box-7.4.1-py3-none-any.whl", hash = "sha256:a3b0d84d003882fb6abe505b1b883b3a5dcbf226b0fe168d24bc5ff75d9826e5", size = 30402, upload-time = "2026-02-21T16:21:14.78Z" }, ] [[package]] @@ -2507,27 +2733,46 @@ 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" }, ] +[[package]] +name = "python-discovery" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, +] + [[package]] name = "python-json-logger" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] name = "pywinpty" -version = "3.0.2" +version = "3.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/bb/a7cc2967c5c4eceb6cc49cfe39447d4bfc56e6c865e7c2249b6eb978935f/pywinpty-3.0.2.tar.gz", hash = "sha256:1505cc4cb248af42cb6285a65c9c2086ee9e7e574078ee60933d5d7fa86fb004", size = 30669, upload-time = "2025-10-03T21:16:29.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a1/409c1651c9f874d598c10f51ff586c416625601df4bca315d08baec4c3e3/pywinpty-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:327790d70e4c841ebd9d0f295a780177149aeb405bca44c7115a3de5c2054b23", size = 2050304, upload-time = "2025-10-03T21:19:29.466Z" }, - { url = "https://files.pythonhosted.org/packages/02/4e/1098484e042c9485f56f16eb2b69b43b874bd526044ee401512234cf9e04/pywinpty-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:99fdd9b455f0ad6419aba6731a7a0d2f88ced83c3c94a80ff9533d95fa8d8a9e", size = 2050391, upload-time = "2025-10-03T21:19:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/fc/19/b757fe28008236a4a713e813283721b8a40aa60cd7d3f83549f2e25a3155/pywinpty-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:18f78b81e4cfee6aabe7ea8688441d30247b73e52cd9657138015c5f4ee13a51", size = 2050057, upload-time = "2025-10-03T21:19:26.732Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/cbae12ecf6f4fa4129c36871fd09c6bef4f98d5f625ecefb5e2449765508/pywinpty-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:663383ecfab7fc382cc97ea5c4f7f0bb32c2f889259855df6ea34e5df42d305b", size = 2049874, upload-time = "2025-10-03T21:18:53.923Z" }, - { url = "https://files.pythonhosted.org/packages/ca/15/f12c6055e2d7a617d4d5820e8ac4ceaff849da4cb124640ef5116a230771/pywinpty-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:28297cecc37bee9f24d8889e47231972d6e9e84f7b668909de54f36ca785029a", size = 2050386, upload-time = "2025-10-03T21:18:50.477Z" }, - { url = "https://files.pythonhosted.org/packages/de/24/c6907c5bb06043df98ad6a0a0ff5db2e0affcecbc3b15c42404393a3f72a/pywinpty-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:34b55ae9a1b671fe3eae071d86618110538e8eaad18fcb1531c0830b91a82767", size = 2049834, upload-time = "2025-10-03T21:19:25.688Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:af7a8720c78776ddd6259b71dd567944f766a6cd67f8d2887fbc4973967bacda", size = 2092466, upload-time = "2026-06-10T23:44:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fe/fe23e2229ffec0c10190cef5964f5c9b2dba179d23b69ae537b7ea90bcab/pywinpty-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:c2406f54f699eab75953fb75ce805f2ae55a33a957cd070890abd454fb4b7680", size = 818395, upload-time = "2026-06-10T23:41:56.93Z" }, + { url = "https://files.pythonhosted.org/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://files.pythonhosted.org/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, ] [[package]] @@ -2645,7 +2890,7 @@ wheels = [ [[package]] name = "quantem" -version = "0.1.8" +version = "0.1.9" source = { editable = "." } dependencies = [ { name = "cmasher" }, @@ -2654,18 +2899,21 @@ dependencies = [ { name = "h5py" }, { name = "hdf5plugin" }, { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "optuna" }, { name = "rosettasciio" }, { name = "scikit-image" }, - { name = "scipy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "tensorboard" }, { name = "torch" }, { name = "torchinfo" }, { name = "torchmetrics" }, { name = "torchvision" }, { name = "tqdm" }, - { name = "zarr" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [package.optional-dependencies] @@ -2733,10 +2981,23 @@ version = "0.0.1" source = { editable = "widget" } dependencies = [ { name = "anywidget" }, + { name = "matplotlib" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pillow" }, + { name = "torch" }, + { name = "traitlets" }, ] [package.metadata] -requires-dist = [{ name = "anywidget", specifier = ">=0.9.0" }] +requires-dist = [ + { name = "anywidget", specifier = ">=0.9.0" }, + { name = "matplotlib", specifier = ">=3.7.0" }, + { name = "numpy", specifier = ">=2.0.0" }, + { name = "pillow", specifier = ">=10.0.0" }, + { name = "torch", specifier = ">=2.0.0" }, + { name = "traitlets", specifier = ">=5.0.0" }, +] [[package]] name = "referencing" @@ -2754,7 +3015,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2762,9 +3023,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -2802,189 +3063,204 @@ wheels = [ [[package]] name = "rosettasciio" -version = "0.12.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dask", extra = ["array"] }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pint" }, { name = "python-box" }, { name = "python-dateutil" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/2f/cbcd0a75fce3bf955fb3317dae037a60bcdbe848c98105afaa25c31d1475/rosettasciio-0.12.0.tar.gz", hash = "sha256:e02575d451e9c3d301fcb68c319ce0728175df9940feff5b09b855e18945a093", size = 1180824, upload-time = "2025-12-29T17:33:06.459Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/34/0b9aa833f2dfdb8f22ad6fb337b71b638f9a937abb2796f8112bc507195f/rosettasciio-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c529232347e251abd07dd8b9f92f618994cdc289aca687a1df21e2846936a3d3", size = 817077, upload-time = "2025-12-29T17:32:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/15/2b/caac69f14e9ee9486901bca609fe66fb2d21ad144ccaa6e562f696115bc3/rosettasciio-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33bc1382770b74a1b4c5c9605656cf2f3a63e0409903bce3620a1d5d8401d270", size = 816215, upload-time = "2025-12-29T17:32:05.776Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9b/345d28ce5a802d82048de418a427b3239e4f9ff44553709eef7a3a2b5134/rosettasciio-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:931d5251c25f4db537573671723fddcb1938684ed3047425f2f35cc9c89bab2b", size = 1271765, upload-time = "2025-12-29T17:32:07.928Z" }, - { url = "https://files.pythonhosted.org/packages/c9/62/de7bd49e68760d4deda46d58f089e936e1a6bcd14dbd4e7acb4d9c7c1769/rosettasciio-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:754345908d083083d54aa24572290eb44632fa9ef0c6fca7d547a9e6754a2e88", size = 1276286, upload-time = "2025-12-29T17:32:09.831Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5c/d1f62362790d601b0749089ab30f6193e0a328af523b0569f36361deeda2/rosettasciio-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a57699c95226d6f71714629d23e58045c0ee3378e0aa00e8fe5fb0da88aff2ba", size = 805836, upload-time = "2025-12-29T17:32:12.107Z" }, - { url = "https://files.pythonhosted.org/packages/3c/75/f6f81c945da3c3b8d67c14d4ca26dda0ac3421ad9074bc54196019cfe119/rosettasciio-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:e1203f9960a2026122d2900062db1ea48700aaec4fa427ed731cb7f8b0433740", size = 794372, upload-time = "2025-12-29T17:32:13.931Z" }, - { url = "https://files.pythonhosted.org/packages/8b/42/48eebee13ace6e0c714433111ef7854b023f63ced48ccb38c26c4a325e8b/rosettasciio-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18608de5b37a197d15cf4236d045adfb398dbcd2bc0917a8a193e9a5c87dc871", size = 818054, upload-time = "2025-12-29T17:32:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/90/d6/0971527544fc27f59856700c6220aee2199bd631c51c4eb05bf3edd1e58d/rosettasciio-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2567a490f4be1ce7402551f21ed32f5f34dc5956286fa819594bdc899999d32f", size = 816403, upload-time = "2025-12-29T17:32:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/87/c9/81e6fceaeba01f6d3bf36de9e43145590acba43063f2566d45f11d9a0999/rosettasciio-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bf7ce36dbed455554892c8b84732ec25f3026da8374c56e1d491aac6efddf35", size = 1274114, upload-time = "2025-12-29T17:32:18.488Z" }, - { url = "https://files.pythonhosted.org/packages/88/cb/325acfd3255132574c6fa975362b03bf286db9b8dce0f57eaef9d4d2bf9c/rosettasciio-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45f74c2414704cba896c13c09102a960b1624b610f7a2e7a177d70ebea6bb2a6", size = 1278900, upload-time = "2025-12-29T17:32:19.97Z" }, - { url = "https://files.pythonhosted.org/packages/24/4e/b0bcce83693873e26fc7d104708b8b14f710562e6b414c28a8b35c9030d7/rosettasciio-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e7bfc6f23604ae5133db33241053c265d35b0649d85029e85b2c261796bd865", size = 806447, upload-time = "2025-12-29T17:32:21.349Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ac/aa09ee2a13608bdbed8049183a9f6983146de38f67d20fc91819f90fd7fc/rosettasciio-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:f15b9ab80c2ebd468c554b026bddd62cee02b9caa3d5ef20e354214c321e3ac0", size = 794087, upload-time = "2025-12-29T17:32:22.783Z" }, - { url = "https://files.pythonhosted.org/packages/72/ee/99d31a8514f82b47cebf3a4adad44c04e96c7d05d6821aaba5b737d6f819/rosettasciio-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0289c5f7bbb065880041f1fa1e777ddaf0dea37cc667c0da5d05cfbec6885a1c", size = 817296, upload-time = "2025-12-29T17:32:24.206Z" }, - { url = "https://files.pythonhosted.org/packages/61/26/83a280dfcd703cd2300e7ae4586dc0df2d24d2ed7a42693a355c9f3f875e/rosettasciio-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:35d6d8e8b947277fa6e5a829e0a367cdc1c8fbf29dccdfc667cec12dd1da8d3d", size = 815608, upload-time = "2025-12-29T17:32:26.191Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8e/87e07335406125cff5095d72cffd0360a9fe4c76067659c5ccac5ee712e7/rosettasciio-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f88523c38eba7c991d711668667aac5c146737c613bbf4e1325f3b0d174eedf", size = 1267905, upload-time = "2025-12-29T17:32:27.612Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/f10c0d7d8110cf14a51c47ddeba0fcd32633f87eefdd77d667da1e44c16d/rosettasciio-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97727411c3c7560d4349d39582e1888b7e5bda1dcda21d48e30e2047e2f6e45c", size = 1277855, upload-time = "2025-12-29T17:32:29.694Z" }, - { url = "https://files.pythonhosted.org/packages/30/0f/daaa1b14c9fb2b295ff52be0b13604d59d7ae4968cb5779abfd414d61ddb/rosettasciio-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7769f2caa80c761d7f30adaf5aa11da2dadf4149a0df8f0c939400e2ad924a1c", size = 806235, upload-time = "2025-12-29T17:32:31.571Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ce/89729079e3527cf7cd2bb5e90c0d466d0852ee5cc7267a7f45cc8cdc5482/rosettasciio-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:5f307f3abfc061499535313d55b6d630cead7e450040d72276923a98342df930", size = 793943, upload-time = "2025-12-29T17:32:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/33/5e/049b670a32e09cc788e085b503d083ab81f341837c2f1e46bd7ab4714d98/rosettasciio-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7ae3e37730b81cfb9b894a0e562693befa2ea44471c8c469ef82caf15c8ca42d", size = 821718, upload-time = "2025-12-29T17:32:35.3Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/4884bcb0e187246ec24aee3175d1b2294cfd97b42afee8ccd7ec33a4c8b2/rosettasciio-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:07288b70421dc7ca91ccf59c91f7d9675d425582eae499662f6c3a27f1562e7f", size = 821588, upload-time = "2025-12-29T17:32:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e8/b223f9b63614982b09f1c29b118466102580b95c518705db792866edb645/rosettasciio-0.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c810177218abfc74729f175856bf082bc435ad964ca5ceb1158a87fd23dbe992", size = 1281610, upload-time = "2025-12-29T17:32:38.621Z" }, - { url = "https://files.pythonhosted.org/packages/a4/53/f779609a7f0a887ea97233a00482b3034b262c982549e81337fdcc26bd8c/rosettasciio-0.12.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f9ab696534dc4cc5fd3b3eeac3758ab9c5260b09b4aea0cd94ea52decbede97", size = 1268743, upload-time = "2025-12-29T17:32:40.257Z" }, - { url = "https://files.pythonhosted.org/packages/34/b4/4ec99972f93b605a662ce09e56b2cc562051900492c8516dd935c0db3087/rosettasciio-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c6aecd74a7a0248843efc392a689a8dc68044f7183e2b8b9252c91335ee2f16f", size = 816540, upload-time = "2025-12-29T17:32:41.644Z" }, - { url = "https://files.pythonhosted.org/packages/73/0d/d1d99c3b9614a7525c082737f11c140671d690bdd9fc6a6085f1282bc713/rosettasciio-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30fe143cd4aede2260cdff388a5686ad4725a27207d095f0c35b6e1fa09e0663", size = 798650, upload-time = "2025-12-29T17:32:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/36/9f/98eaa0d086d6f569b40b8ec13b62d99c2ab6c60bf7a8a5d6999c45a18e6b/rosettasciio-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:268697caf043823c69e7c6c397db364a798abc1e3c31e56d48c4e1142cb85af7", size = 817569, upload-time = "2025-12-29T17:32:44.6Z" }, - { url = "https://files.pythonhosted.org/packages/18/60/09cb9b438107032251f49c07fad46539814adddfc58eece4790d3ab65a9f/rosettasciio-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fc66c00a4e3c4a564741e77296c120135c0a3fbc4b56640bc812298ca83b5a3c", size = 816443, upload-time = "2025-12-29T17:32:46.279Z" }, - { url = "https://files.pythonhosted.org/packages/61/90/be7c89b63f1cd5c702f23f0247b1a93040c867e37b3817004b89ec2b89a6/rosettasciio-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa6023d4f206c37d0e2a4ef12070a0ef03a44ca5a6fbc25ae5ee58be72f236da", size = 1265935, upload-time = "2025-12-29T17:32:47.788Z" }, - { url = "https://files.pythonhosted.org/packages/c5/08/6b9a0a11686d8e10f43da14262129b4b4eb02a7f0a6e8d681b8f6e308fa8/rosettasciio-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84adcd658b7be1b03b1f8f36b3f5690c43d87b20b8cc8723db561fbfe05510d1", size = 1273714, upload-time = "2025-12-29T17:32:49.394Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a5/50517ea5c856c64e6405ea9c550acd4cdea4b4f595144d73ad344bdca6cf/rosettasciio-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:59b51d45a888ee9361b5b5fb459adf7f5c0ac0e62f20610331e8af94ae5b695b", size = 805318, upload-time = "2025-12-29T17:32:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/48/23/90534c33567d1473dbf59efcb4ba27699d5471824e0036937cb699bbb0cf/rosettasciio-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:4623e8263af07174c3fbe02981d15920910ce2d7eaf989ddc100a74bc162b2f6", size = 793074, upload-time = "2025-12-29T17:32:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/62/ca/d6e04ac4b41e8f92e7754bc02f115a4bfa70a782815aacc2d5cad1a21b25/rosettasciio-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e445003a48000f6dd462c180b8cea7a54a0e5ce17fe0706228fac619b80cce8", size = 822221, upload-time = "2025-12-29T17:32:54.074Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a5/078548b2760ba2c529de3b4e677d48030d41092d011b4034cb0e2c23310b/rosettasciio-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:608aa659d42654dcd28eeb7076761a736fbbcb59876f8907d5160f9f2d7c5f5d", size = 821917, upload-time = "2025-12-29T17:32:57.044Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ee/88dcea135add00ef59d03138b0b8378028153b00f4c0374ebd86cd19fe58/rosettasciio-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5bccd360790e1c17bca31223f4e946eb4b19b55b9c1aae937365ed45839df0", size = 1282089, upload-time = "2025-12-29T17:32:58.535Z" }, - { url = "https://files.pythonhosted.org/packages/05/83/b4e55db04a54cd50ce85ad44604cef619e5a0e72b1eedf1f9d71a77fc0aa/rosettasciio-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04fa825a951b6fe11c4fca422e30e2d03156d04b197d0a75649db8562f4c7dcd", size = 1269179, upload-time = "2025-12-29T17:33:00.169Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c1/d3ce8ada006d8e0e608859c335c83069cbc1dbce548a07ab870d3d336ee9/rosettasciio-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f89eeea6a624179fc11924a904ad7f4699a332a1d7b13176e3e9c2ca116e00a", size = 817913, upload-time = "2025-12-29T17:33:01.779Z" }, - { url = "https://files.pythonhosted.org/packages/4c/08/d41bf9dbbfe60463ab68268c5458d644fcb2d7cab9453c958a53b577b9ce/rosettasciio-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:38aec6a42a0810bee065502467f941d563a51d236af762432c65e212bac27ccf", size = 797652, upload-time = "2025-12-29T17:33:03.331Z" }, - { url = "https://files.pythonhosted.org/packages/56/00/772baf64631f9ec440b3291b708a04a00442aad796625cc0f0c6f8a1c0c4/rosettasciio-0.12.0-py3-none-any.whl", hash = "sha256:3974e75b1502b4e974c227f0b80d35240011c445b6d63394482fd9e6afc5c9fb", size = 567619, upload-time = "2025-12-29T17:33:04.939Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/ca/dc7c3871b47e1bd89b7a11a34b7b68219504a03047788e52cff1ce823755/rosettasciio-0.14.0.tar.gz", hash = "sha256:4b21582f0d832f086e10236b1798b1c17e327dc6d610e845a8eddd4df817504f", size = 1245393, upload-time = "2026-05-27T15:29:54.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/bf/0ecc513c23c2a61f1497c9801cc4144e40a996099d36b71407673ad1a975/rosettasciio-0.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73c7bb64e6a456fd058807c9a698d50b6fa757985f0e505087b641b10dd50904", size = 876315, upload-time = "2026-05-27T15:28:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/5d/36/07adfd0a1c36d249f6294538b030bd1c0da29486e6a73d35e1adbf689144/rosettasciio-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8f5ac58e7ff8cf2113eb878a50c7ceeb12748433ecb6287b1045e0f97aa7916", size = 875404, upload-time = "2026-05-27T15:28:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/7df79374170885b2a542192a18f22d92a6a1a941dbc62dd4f5195ad26e57/rosettasciio-0.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a844a914f11a72d06426862721227f54587c3a72f5a6dc1477646fbbd41109c", size = 1330312, upload-time = "2026-05-27T15:29:01.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/d0/55cc1aa3e3bc621a638bd456282b4eee88be66eec969d480dd59acdfc85b/rosettasciio-0.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d66924bc504eb0af348ecf4223cff8052f45cb1f73bc47775d8b040f5c4d1d6f", size = 1334306, upload-time = "2026-05-27T15:29:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/603088354e78a828a67920c0d321e6ca979be73aca0719f7af8ffe35d7d9/rosettasciio-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:32ba0bd12da1a53345bf59853b8325dd1065038876f36466e0e71921042cfaf0", size = 864309, upload-time = "2026-05-27T15:29:03.912Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/9395d12ba2fca40290d9461512ca19c8d91c735aa3de6172855c1891920a/rosettasciio-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:43939dfa6dc92f7d39e55b11001285b62dfe136c03f90977aab1ed44f79b243a", size = 854003, upload-time = "2026-05-27T15:29:05.254Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/7df5ab31e0a8ab429d2debd5ea9246d4ebad4062fd9ca50b8f97f95a826d/rosettasciio-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:559269d18a179db96b28bda98b9458dc865ef6d82b0d59f0e4c277ceb275b8cb", size = 877346, upload-time = "2026-05-27T15:29:06.964Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/d3f6595107d730d52ecf0b5fe0f3f106718c82fe3b3f92a94d175d95d67a/rosettasciio-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:654336c91b75a0efa9a6a9112397a131771b7ee70e18c297394f200042b3a2fa", size = 875628, upload-time = "2026-05-27T15:29:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/65/ae/23894df831445e95a6dc9d915d500ff755e8c6241c32b62321d154b45398/rosettasciio-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed65d0c844a5e3623b88bb1ae035908e256113f0ec43f9b0acefc1489b7a6430", size = 1334614, upload-time = "2026-05-27T15:29:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/2e/50/108c50cd05f2ad3c3a105d7d1cafb596951859ebf596ca8f4132fa135dfc/rosettasciio-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c931d1a06f45f297c0a4de69cf4f477e65566adca63e9dd5e1976332140b06a7", size = 1340152, upload-time = "2026-05-27T15:29:11.586Z" }, + { url = "https://files.pythonhosted.org/packages/d4/43/04a92bdb09d4b12b8769a438bf048cc5220a6a49e33e81a4ff98064ac990/rosettasciio-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:27b7018bacbecbcaa8426650fa4fe22db24842f45ea9d3d5b9b4744218f2c628", size = 864789, upload-time = "2026-05-27T15:29:13.104Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0e/c51fed20238121afeb6b4bf5d9e78a474ec644906bdaf7330a2a667502b5/rosettasciio-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:708530b949084aca56a22ef80066ccfea3a242a83cc804ad2c6a0e8b7f52f248", size = 853639, upload-time = "2026-05-27T15:29:14.645Z" }, + { url = "https://files.pythonhosted.org/packages/c2/52/f2576629d0c194374fd8806232249c76808b0c9a64b4fd718648236a9ce2/rosettasciio-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d3d0b93d4df943085e59109d9c2f7905eedd0ae35ef7914f6c1e6a8441de141", size = 876444, upload-time = "2026-05-27T15:29:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/93/67/ec4d3991463f16ddae84bb4fa2b345567f1898fad9a73bbe9159deb07920/rosettasciio-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f70d1beaa0c552438b569b4ca383737155f96d4be501429d95dd258aff8b7272", size = 874764, upload-time = "2026-05-27T15:29:17.485Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/f8b16002613afe7fbcd51980d84172713aca218b1a1a1a7e1f6190c9c8b4/rosettasciio-0.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a96615c707782391bc082fe7c3de54b67e560af347274a4ce9881671547eb88", size = 1326408, upload-time = "2026-05-27T15:29:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/25609a0eeb730ff2451b6f8466b8df194ac15003c03396714c626fa2d7cd/rosettasciio-0.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cea0b240c8c87fae9137ae65496ec2e5208392f1f1058f3953b4f8a5a26d6e3", size = 1335956, upload-time = "2026-05-27T15:29:20.592Z" }, + { url = "https://files.pythonhosted.org/packages/72/67/f3a4bccf16b4a2813d1dcf7f4c5ff2ba6ccba31033cb73355d44b36ce437/rosettasciio-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:ad675d0f7dfe31d7864cd7fa4372076771110c82da0eb5b5471c26ccfe086995", size = 864658, upload-time = "2026-05-27T15:29:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/28/01/77624b05b41991af136ad87fb9ef6a7f4f417a66202fd8b6f1366ed38a75/rosettasciio-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:bf1c02213933cc82ae685d3444dc595e7c6ba6110ed0894963cc200a9bb567d2", size = 853524, upload-time = "2026-05-27T15:29:23.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/153d463328eb1566b25283f519756720274dcf18269b1f9d93038158743e/rosettasciio-0.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e1bdd98eb232a39807c5d8d1a4fb69f81f2616662c22fe27ba43c972f5ecc648", size = 880788, upload-time = "2026-05-27T15:29:25.064Z" }, + { url = "https://files.pythonhosted.org/packages/7e/42/277d00ad1144d51938a6abe46d58416e7bd366ef0a12b3bda411ea0754d2/rosettasciio-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6bf22187819fdf825bd80bc7a3ad8ad924c371e324c8d0ba60be7e0c2d61c58", size = 880606, upload-time = "2026-05-27T15:29:26.611Z" }, + { url = "https://files.pythonhosted.org/packages/91/24/b777a9e76cd1dce493e447b1fe96d7edefed0444b806bf1e61192111e2fc/rosettasciio-0.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db18d7832309bc6556319aaf28faa4992db0362ffd11851be07f70ff239e8afa", size = 1340574, upload-time = "2026-05-27T15:29:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/6c/56/8c0d56499fa6b98603dd34c24f91a5994f4313809dbaabc213937dffbb62/rosettasciio-0.14.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c2ce75139da7dc89f5b2bf04226975beafac76d59a758780e9363173042ea9a", size = 1327203, upload-time = "2026-05-27T15:29:30.268Z" }, + { url = "https://files.pythonhosted.org/packages/59/0a/ebfde6c65a0c311ce191a1be71177568fc7dcdf567e8ea4e154b454f8620/rosettasciio-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c88f8923dacc23ad85c679109cdb3e879b749de77c9ef8575ca9fbab70708aa5", size = 869838, upload-time = "2026-05-27T15:29:31.662Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ed/9bfbf1e151b2956e055dcd1f83277da555387afa1fd17c85ace4d3b0ac5e/rosettasciio-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2b98254384dc1b55d8377a048f6fe6609d42b538c30428f55d7d97c183b19d1d", size = 858169, upload-time = "2026-05-27T15:29:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/66/75/9dbc1e8bda660a6010091e0e898b326642559efdba580cdf49f2e236c1a1/rosettasciio-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8f5d568b1a759084ba72550d4d5278c8464055d4c10d4e21d57dba81acc5a9fb", size = 876737, upload-time = "2026-05-27T15:29:34.957Z" }, + { url = "https://files.pythonhosted.org/packages/3a/32/74c8c91d22cc11594f42222e0936ced5c042ece324ae73663e04921d3366/rosettasciio-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e14ed16e32e74767c4d2233722ebc1a726b13ba2afb77c22353df37d7706b7bf", size = 875572, upload-time = "2026-05-27T15:29:36.404Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/cd32f2d5589a41bcf6de8e48ac1fda403913df6ade7e56252cdc1de7242e/rosettasciio-0.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165bd0c416700de21d7b1da1f29f3e6d4aaac8c25003d1569ce4f6401111d4b4", size = 1324655, upload-time = "2026-05-27T15:29:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/b3/99/6e3d0bd825939e22a98f3f8e41b9cb7fe1ba16f7861d1b379a571b0b5d27/rosettasciio-0.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:107942ffa5302e2a3812571d3fead609949a28a20d25f53151f2baaed1c7f790", size = 1331210, upload-time = "2026-05-27T15:29:39.286Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e8/5004fbffc23a6d565628481e111b69335f928365c57d485807c99eaeaeef/rosettasciio-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2b8fd2f0423e0be811c662dbfb20d48b2c8479d8249ce04062d039b27b4040c", size = 863712, upload-time = "2026-05-27T15:29:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/8c16af430876a637e6e18c9317468cba7a69e47d712dd10a1c69d785b02d/rosettasciio-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:569b581cc909d0a755947c180388348ab1e00c5ab068fbdc289dc2a2e74864d8", size = 852806, upload-time = "2026-05-27T15:29:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/9e/48/af1981ca42b19c99a5bb03226bc03faf48137c5fc6393626e216d1f96f94/rosettasciio-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebb19e47a261d1d16cdfaf2104dd86af6d891fad82e6ee66eda76dab12fa4de8", size = 881288, upload-time = "2026-05-27T15:29:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/e1/e965ac6158117841b814bb6d086bf601cc0d7fb385c7844f52b885ce533d/rosettasciio-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d61dede6dc3f8365797afc2a9eb05eac8fe08a9ea4e995203c95dcf2b85c5104", size = 880931, upload-time = "2026-05-27T15:29:45.093Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4e/b5f24a072e5aae2b00facf4bab0870f6dc02b9b34c928f39bec5b7be4ace/rosettasciio-0.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0dedf178bd0a072e0c4bf46d9a55048c69664ec651a8739994f75070fe188d88", size = 1340469, upload-time = "2026-05-27T15:29:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/83/da/10a17fed897fc9de139078b05439961750ae4fd47e5a309df454b472cca8/rosettasciio-0.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a72fd270b4d17a0a0a20188a3354770113debea3bb56bd976e3c0ca52d05c7e", size = 1327680, upload-time = "2026-05-27T15:29:48.048Z" }, + { url = "https://files.pythonhosted.org/packages/14/f3/3a5e2c3fa67fbf2219a914c6342a91f2f7ba19554ffe2b654fd44e13685c/rosettasciio-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4a17300595ae1a83331ce4e015478341eac99cf8544f36c88f7c6de6e2662541", size = 869656, upload-time = "2026-05-27T15:29:49.836Z" }, + { url = "https://files.pythonhosted.org/packages/55/3c/5f189a330ba363f04ee73e25b37f8e08ae1266ad72e6c93d65166a7c7c6e/rosettasciio-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66ca5c8d08cd3caa5397b134f83803915807d8e799ad3ac2fda5b9607342371d", size = 857389, upload-time = "2026-05-27T15:29:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a3/5cf5424b16d68e8db9139d613570ef01825995d260343fdec704d3f646ba/rosettasciio-0.14.0-py3-none-any.whl", hash = "sha256:f64e045ced6827ba75e6b8b85c54812c0346eda6b76f08acded3d0f6e7fb5a27", size = 628709, upload-time = "2026-05-27T15:29:52.825Z" }, ] [[package]] name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] [[package]] name = "ruff" -version = "0.14.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, - { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, - { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, - { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, - { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, - { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, - { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, - { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -2995,11 +3271,14 @@ dependencies = [ { name = "imageio" }, { name = "lazy-loader" }, { name = "networkx" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pillow" }, - { name = "scipy" }, - { name = "tifffile" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "tifffile", version = "2026.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } wheels = [ @@ -3055,73 +3334,131 @@ wheels = [ [[package]] name = "scipy" -version = "1.17.0" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { 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]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/4b/c89c131aa87cad2b77a54eb0fb94d633a842420fa7e919dc2f922037c3d8/scipy-1.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:2abd71643797bd8a106dff97894ff7869eeeb0af0f7a5ce02e4227c6a2e9d6fd", size = 31381316, upload-time = "2026-01-10T21:24:33.42Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5f/a6b38f79a07d74989224d5f11b55267714707582908a5f1ae854cf9a9b84/scipy-1.17.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ef28d815f4d2686503e5f4f00edc387ae58dfd7a2f42e348bb53359538f01558", size = 27966760, upload-time = "2026-01-10T21:24:38.911Z" }, - { url = "https://files.pythonhosted.org/packages/c1/20/095ad24e031ee8ed3c5975954d816b8e7e2abd731e04f8be573de8740885/scipy-1.17.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:272a9f16d6bb4667e8b50d25d71eddcc2158a214df1b566319298de0939d2ab7", size = 20138701, upload-time = "2026-01-10T21:24:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/89/11/4aad2b3858d0337756f3323f8960755704e530b27eb2a94386c970c32cbe/scipy-1.17.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:7204fddcbec2fe6598f1c5fdf027e9f259106d05202a959a9f1aecf036adc9f6", size = 22480574, upload-time = "2026-01-10T21:24:47.266Z" }, - { url = "https://files.pythonhosted.org/packages/85/bd/f5af70c28c6da2227e510875cadf64879855193a687fb19951f0f44cfd6b/scipy-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc02c37a5639ee67d8fb646ffded6d793c06c5622d36b35cfa8fe5ececb8f042", size = 32862414, upload-time = "2026-01-10T21:24:52.566Z" }, - { url = "https://files.pythonhosted.org/packages/ef/df/df1457c4df3826e908879fe3d76bc5b6e60aae45f4ee42539512438cfd5d/scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac97a27520d66c12a34fd90a4fe65f43766c18c0d6e1c0a80f114d2260080e4", size = 35112380, upload-time = "2026-01-10T21:24:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/5f/bb/88e2c16bd1dd4de19d80d7c5e238387182993c2fb13b4b8111e3927ad422/scipy-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb7446a39b3ae0fe8f416a9a3fdc6fba3f11c634f680f16a239c5187bc487c0", size = 34922676, upload-time = "2026-01-10T21:25:04.287Z" }, - { url = "https://files.pythonhosted.org/packages/02/ba/5120242cc735f71fc002cff0303d536af4405eb265f7c60742851e7ccfe9/scipy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:474da16199f6af66601a01546144922ce402cb17362e07d82f5a6cf8f963e449", size = 37507599, upload-time = "2026-01-10T21:25:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/08629657ac6c0da198487ce8cd3de78e02cfde42b7f34117d56a3fe249dc/scipy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:255c0da161bd7b32a6c898e7891509e8a9289f0b1c6c7d96142ee0d2b114c2ea", size = 36380284, upload-time = "2026-01-10T21:25:15.632Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4a/465f96d42c6f33ad324a40049dfd63269891db9324aa66c4a1c108c6f994/scipy-1.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b0ac3ad17fa3be50abd7e69d583d98792d7edc08367e01445a1e2076005379", size = 24370427, upload-time = "2026-01-10T21:25:20.514Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, - { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, - { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, - { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, - { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, - { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, - { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, - { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, - { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, - { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, - { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, - { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, - { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, - { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, - { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, - { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, ] [[package]] @@ -3135,11 +3472,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "81.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] @@ -3153,53 +3490,59 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.2" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/f2/21d6ca70c3cf35d01ae9e01be534bf6b6b103c157a728082a5028350c310/soupsieve-2.8.2.tar.gz", hash = "sha256:78a66b0fdee2ab40b7199dc3e747ee6c6e231899feeaae0b9b98a353afd48fd8", size = 118601, upload-time = "2026-01-18T16:21:31.09Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/9a/b4450ccce353e2430621b3bb571899ffe1033d5cd72c9e065110f95b1a63/soupsieve-2.8.2-py3-none-any.whl", hash = "sha256:0f4c2f6b5a5fb97a641cf69c0bd163670a0e45e6d6c01a2107f93a6a6f93c51a", size = 37016, upload-time = "2026-01-18T16:21:29.7Z" }, + { 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 = "sqlalchemy" -version = "2.0.45" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, - { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, - { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, - { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, - { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, - { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, - { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, - { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, - { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, - { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, - { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, - { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, - { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, - { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, - { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, - { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, - { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, - { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] @@ -3230,13 +3573,14 @@ wheels = [ [[package]] name = "tensorboard" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "absl-py" }, { name = "grpcio" }, { name = "markdown" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pillow" }, { name = "protobuf" }, @@ -3245,7 +3589,7 @@ dependencies = [ { name = "werkzeug" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/cd2eec9642781a8f5b2fb9994e3933a7b259ab18e9d49aeede9b5acf6311/tensorboard-2.21.0-py3-none-any.whl", hash = "sha256:7279316dcb6bd5bc391d623dea841531299cde1887310e8133bc34a996d32255", size = 5516204, upload-time = "2026-06-29T20:48:04.472Z" }, ] [[package]] @@ -3274,80 +3618,99 @@ wheels = [ [[package]] name = "tifffile" -version = "2026.1.14" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.6.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/19/a41ab0dc1b314da952d99957289944c3b8b76021399c72693e4c1fddc6c3/tifffile-2026.1.14.tar.gz", hash = "sha256:a423c583e1eecd9ca255642d47f463efa8d7f2365a0e110eb0167570493e0c8c", size = 373639, upload-time = "2026-01-14T22:40:43.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/38/5e2ecef5af2f4fd4a89bb8d6240de9458bab4d51a4cbd97aeb3a0cd618e2/tifffile-2026.6.1.tar.gz", hash = "sha256:626c892c0e899d959b9438e7c0e1491dc154a7fead1f1f37a991724a50eceba9", size = 429694, upload-time = "2026-05-31T23:57:12.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/4d/3fd60d3a37b544cb59463add86e4dfbb485880225115341281906a7b140e/tifffile-2026.1.14-py3-none-any.whl", hash = "sha256:29cf4adb43562a4624fc959018ab1b44e0342015d3db4581b983fe40e05f5924", size = 232213, upload-time = "2026-01-14T22:40:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/59/208f71d70ddc6184f79b8c6d87d46eb7d7b12c19186a817dec9c9c3f3693/tifffile-2026.6.1-py3-none-any.whl", hash = "sha256:0d7382d2769b855b81ce358528e2b40c16d48aa39031746efa81215205332a8d", size = 267108, upload-time = "2026-05-31T23:57:10.597Z" }, ] [[package]] name = "tinycss2" -version = "1.4.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { 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]] @@ -3361,58 +3724,46 @@ wheels = [ [[package]] name = "torch" -version = "2.9.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, { name = "networkx" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, { name = "sympy" }, - { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "triton", marker = "sys_platform == 'linux'" }, { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/15/db/c064112ac0089af3d2f7a2b5bfbabf4aa407a78b74f87889e524b91c5402/torch-2.9.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:62b3fd888277946918cba4478cf849303da5359f0fb4e3bfb86b0533ba2eaf8d", size = 104220430, upload-time = "2025-11-12T15:20:31.705Z" }, - { url = "https://files.pythonhosted.org/packages/56/be/76eaa36c9cd032d3b01b001e2c5a05943df75f26211f68fae79e62f87734/torch-2.9.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d033ff0ac3f5400df862a51bdde9bad83561f3739ea0046e68f5401ebfa67c1b", size = 899821446, upload-time = "2025-11-12T15:20:15.544Z" }, - { url = "https://files.pythonhosted.org/packages/47/cc/7a2949e38dfe3244c4df21f0e1c27bce8aedd6c604a587dd44fc21017cb4/torch-2.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d06b30a9207b7c3516a9e0102114024755a07045f0c1d2f2a56b1819ac06bcb", size = 110973074, upload-time = "2025-11-12T15:21:39.958Z" }, - { url = "https://files.pythonhosted.org/packages/1e/ce/7d251155a783fb2c1bb6837b2b7023c622a2070a0a72726ca1df47e7ea34/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:52347912d868653e1528b47cafaf79b285b98be3f4f35d5955389b1b95224475", size = 74463887, upload-time = "2025-11-12T15:20:36.611Z" }, - { url = "https://files.pythonhosted.org/packages/0f/27/07c645c7673e73e53ded71705045d6cb5bae94c4b021b03aa8d03eee90ab/torch-2.9.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:da5f6f4d7f4940a173e5572791af238cb0b9e21b1aab592bd8b26da4c99f1cd6", size = 104126592, upload-time = "2025-11-12T15:20:41.62Z" }, - { url = "https://files.pythonhosted.org/packages/19/17/e377a460603132b00760511299fceba4102bd95db1a0ee788da21298ccff/torch-2.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:27331cd902fb4322252657f3902adf1c4f6acad9dcad81d8df3ae14c7c4f07c4", size = 899742281, upload-time = "2025-11-12T15:22:17.602Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1a/64f5769025db846a82567fa5b7d21dba4558a7234ee631712ee4771c436c/torch-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:81a285002d7b8cfd3fdf1b98aa8df138d41f1a8334fd9ea37511517cedf43083", size = 110940568, upload-time = "2025-11-12T15:21:18.689Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ab/07739fd776618e5882661d04c43f5b5586323e2f6a2d7d84aac20d8f20bd/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:c0d25d1d8e531b8343bea0ed811d5d528958f1dcbd37e7245bc686273177ad7e", size = 74479191, upload-time = "2025-11-12T15:21:25.816Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/8fc5e828d050bddfab469b3fe78e5ab9a7e53dda9c3bdc6a43d17ce99e63/torch-2.9.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c29455d2b910b98738131990394da3e50eea8291dfeb4b12de71ecf1fdeb21cb", size = 104135743, upload-time = "2025-11-12T15:21:34.936Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/6d3f80e6918213babddb2a37b46dbb14c15b14c5f473e347869a51f40e1f/torch-2.9.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:524de44cd13931208ba2c4bde9ec7741fd4ae6bfd06409a604fc32f6520c2bc9", size = 899749493, upload-time = "2025-11-12T15:24:36.356Z" }, - { url = "https://files.pythonhosted.org/packages/a6/47/c7843d69d6de8938c1cbb1eba426b1d48ddf375f101473d3e31a5fc52b74/torch-2.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:545844cc16b3f91e08ce3b40e9c2d77012dd33a48d505aed34b7740ed627a1b2", size = 110944162, upload-time = "2025-11-12T15:21:53.151Z" }, - { url = "https://files.pythonhosted.org/packages/28/0e/2a37247957e72c12151b33a01e4df651d9d155dd74d8cfcbfad15a79b44a/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5be4bf7496f1e3ffb1dd44b672adb1ac3f081f204c5ca81eba6442f5f634df8e", size = 74830751, upload-time = "2025-11-12T15:21:43.792Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f7/7a18745edcd7b9ca2381aa03353647bca8aace91683c4975f19ac233809d/torch-2.9.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:30a3e170a84894f3652434b56d59a64a2c11366b0ed5776fab33c2439396bf9a", size = 104142929, upload-time = "2025-11-12T15:21:48.319Z" }, - { url = "https://files.pythonhosted.org/packages/f4/dd/f1c0d879f2863ef209e18823a988dc7a1bf40470750e3ebe927efdb9407f/torch-2.9.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8301a7b431e51764629208d0edaa4f9e4c33e6df0f2f90b90e261d623df6a4e2", size = 899748978, upload-time = "2025-11-12T15:23:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/1f/9f/6986b83a53b4d043e36f3f898b798ab51f7f20fdf1a9b01a2720f445043d/torch-2.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2e1c42c0ae92bf803a4b2409fdfed85e30f9027a66887f5e7dcdbc014c7531db", size = 111176995, upload-time = "2025-11-12T15:22:01.618Z" }, - { url = "https://files.pythonhosted.org/packages/40/60/71c698b466dd01e65d0e9514b5405faae200c52a76901baf6906856f17e4/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:2c14b3da5df416cf9cb5efab83aa3056f5b8cd8620b8fde81b4987ecab730587", size = 74480347, upload-time = "2025-11-12T15:21:57.648Z" }, - { url = "https://files.pythonhosted.org/packages/48/50/c4b5112546d0d13cc9eaa1c732b823d676a9f49ae8b6f97772f795874a03/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1edee27a7c9897f4e0b7c14cfc2f3008c571921134522d5b9b5ec4ebbc69041a", size = 74433245, upload-time = "2025-11-12T15:22:39.027Z" }, - { url = "https://files.pythonhosted.org/packages/81/c9/2628f408f0518b3bae49c95f5af3728b6ab498c8624ab1e03a43dd53d650/torch-2.9.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:19d144d6b3e29921f1fc70503e9f2fc572cde6a5115c0c0de2f7ca8b1483e8b6", size = 104134804, upload-time = "2025-11-12T15:22:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/28/fc/5bc91d6d831ae41bf6e9e6da6468f25330522e92347c9156eb3f1cb95956/torch-2.9.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c432d04376f6d9767a9852ea0def7b47a7bbc8e7af3b16ac9cf9ce02b12851c9", size = 899747132, upload-time = "2025-11-12T15:23:36.068Z" }, - { url = "https://files.pythonhosted.org/packages/63/5d/e8d4e009e52b6b2cf1684bde2a6be157b96fb873732542fb2a9a99e85a83/torch-2.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:d187566a2cdc726fc80138c3cdb260970fab1c27e99f85452721f7759bbd554d", size = 110934845, upload-time = "2025-11-12T15:22:48.367Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b2/2d15a52516b2ea3f414643b8de68fa4cb220d3877ac8b1028c83dc8ca1c4/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb10896a1f7fedaddbccc2017ce6ca9ecaaf990f0973bdfcf405439750118d2c", size = 74823558, upload-time = "2025-11-12T15:22:43.392Z" }, - { url = "https://files.pythonhosted.org/packages/86/5c/5b2e5d84f5b9850cd1e71af07524d8cbb74cba19379800f1f9f7c997fc70/torch-2.9.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0a2bd769944991c74acf0c4ef23603b9c777fdf7637f115605a4b2d8023110c7", size = 104145788, upload-time = "2025-11-12T15:23:52.109Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8c/3da60787bcf70add986c4ad485993026ac0ca74f2fc21410bc4eb1bb7695/torch-2.9.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:07c8a9660bc9414c39cac530ac83b1fb1b679d7155824144a40a54f4a47bfa73", size = 899735500, upload-time = "2025-11-12T15:24:08.788Z" }, - { url = "https://files.pythonhosted.org/packages/db/2b/f7818f6ec88758dfd21da46b6cd46af9d1b3433e53ddbb19ad1e0da17f9b/torch-2.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c88d3299ddeb2b35dcc31753305612db485ab6f1823e37fb29451c8b2732b87e", size = 111163659, upload-time = "2025-11-12T15:23:20.009Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e3/750b3e3548635ceac03ba255daa26dbc7ed66ca3484dc4b4d955ab7f4501/torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae", size = 426379894, upload-time = "2026-06-17T21:06:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ca/ed24783da629ff3e640ba3f70a7639e9045d3d88b93ee6bc47b8a28a1f2c/torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3", size = 532169264, upload-time = "2026-06-17T21:08:17.65Z" }, + { url = "https://files.pythonhosted.org/packages/46/61/c63f0158446f3a98ea672b004d761b848911eba567ea4a624c7db5aadc04/torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3", size = 122953086, upload-time = "2026-06-17T21:08:27.69Z" }, + { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" }, + { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" }, + { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" }, + { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025, upload-time = "2026-06-17T21:07:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891, upload-time = "2026-06-17T21:05:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494, upload-time = "2026-06-17T21:06:22.72Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254, upload-time = "2026-06-17T21:06:33.199Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173, upload-time = "2026-06-17T21:05:06.603Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009, upload-time = "2026-06-17T21:04:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292, upload-time = "2026-06-17T21:05:17.526Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142, upload-time = "2026-06-17T21:05:27.061Z" }, ] [[package]] @@ -3426,124 +3777,124 @@ wheels = [ [[package]] name = "torchmetrics" -version = "1.8.2" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lightning-utilities" }, - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "torch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a2/c7f6ebf546f8f644edf0f999aa98ece106986a77a7b922316bf6414ff825/torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1", size = 983384, upload-time = "2026-03-09T17:41:19.756Z" }, ] [[package]] name = "torchvision" -version = "0.24.1" +version = "0.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pillow" }, { name = "torch" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/69/30f5f03752aa1a7c23931d2519b31e557f3f10af5089d787cddf3b903ecf/torchvision-0.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:056c525dc875f18fe8e9c27079ada166a7b2755cea5a2199b0bc7f1f8364e600", size = 1891436, upload-time = "2025-11-12T15:25:04.3Z" }, - { url = "https://files.pythonhosted.org/packages/0c/69/49aae86edb75fe16460b59a191fcc0f568c2378f780bb063850db0fe007a/torchvision-0.24.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1e39619de698e2821d71976c92c8a9e50cdfd1e993507dfb340f2688bfdd8283", size = 2387757, upload-time = "2025-11-12T15:25:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/1dfc3db98797b326f1d0c3f3bb61c83b167a813fc7eab6fcd2edb8c7eb9d/torchvision-0.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a0f106663e60332aa4fcb1ca2159ef8c3f2ed266b0e6df88de261048a840e0df", size = 8047682, upload-time = "2025-11-12T15:25:21.125Z" }, - { url = "https://files.pythonhosted.org/packages/fa/bb/cfc6a6f6ccc84a534ed1fdf029ae5716dd6ff04e57ed9dc2dab38bf652d5/torchvision-0.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:a9308cdd37d8a42e14a3e7fd9d271830c7fecb150dd929b642f3c1460514599a", size = 4037588, upload-time = "2025-11-12T15:25:14.402Z" }, - { url = "https://files.pythonhosted.org/packages/f0/af/18e2c6b9538a045f60718a0c5a058908ccb24f88fde8e6f0fc12d5ff7bd3/torchvision-0.24.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e48bf6a8ec95872eb45763f06499f87bd2fb246b9b96cb00aae260fda2f96193", size = 1891433, upload-time = "2025-11-12T15:25:03.232Z" }, - { url = "https://files.pythonhosted.org/packages/9d/43/600e5cfb0643d10d633124f5982d7abc2170dfd7ce985584ff16edab3e76/torchvision-0.24.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7fb7590c737ebe3e1c077ad60c0e5e2e56bb26e7bccc3b9d04dbfc34fd09f050", size = 2386737, upload-time = "2025-11-12T15:25:08.288Z" }, - { url = "https://files.pythonhosted.org/packages/93/b1/db2941526ecddd84884132e2742a55c9311296a6a38627f9e2627f5ac889/torchvision-0.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:66a98471fc18cad9064123106d810a75f57f0838eee20edc56233fd8484b0cc7", size = 8049868, upload-time = "2025-11-12T15:25:13.058Z" }, - { url = "https://files.pythonhosted.org/packages/69/98/16e583f59f86cd59949f59d52bfa8fc286f86341a229a9d15cbe7a694f0c/torchvision-0.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:4aa6cb806eb8541e92c9b313e96192c6b826e9eb0042720e2fa250d021079952", size = 4302006, upload-time = "2025-11-12T15:25:16.184Z" }, - { url = "https://files.pythonhosted.org/packages/e4/97/ab40550f482577f2788304c27220e8ba02c63313bd74cf2f8920526aac20/torchvision-0.24.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8a6696db7fb71eadb2c6a48602106e136c785642e598eb1533e0b27744f2cce6", size = 1891435, upload-time = "2025-11-12T15:25:28.642Z" }, - { url = "https://files.pythonhosted.org/packages/30/65/ac0a3f9be6abdbe4e1d82c915d7e20de97e7fd0e9a277970508b015309f3/torchvision-0.24.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:db2125c46f9cb25dc740be831ce3ce99303cfe60439249a41b04fd9f373be671", size = 2338718, upload-time = "2025-11-12T15:25:26.19Z" }, - { url = "https://files.pythonhosted.org/packages/10/b5/5bba24ff9d325181508501ed7f0c3de8ed3dd2edca0784d48b144b6c5252/torchvision-0.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f035f0cacd1f44a8ff6cb7ca3627d84c54d685055961d73a1a9fb9827a5414c8", size = 8049661, upload-time = "2025-11-12T15:25:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ec/54a96ae9ab6a0dd66d4bba27771f892e36478a9c3489fa56e51c70abcc4d/torchvision-0.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:16274823b93048e0a29d83415166a2e9e0bf4e1b432668357b657612a4802864", size = 4319808, upload-time = "2025-11-12T15:25:17.318Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f3/a90a389a7e547f3eb8821b13f96ea7c0563cdefbbbb60a10e08dda9720ff/torchvision-0.24.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e3f96208b4bef54cd60e415545f5200346a65024e04f29a26cd0006dbf9e8e66", size = 2005342, upload-time = "2025-11-12T15:25:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/a9/fe/ff27d2ed1b524078164bea1062f23d2618a5fc3208e247d6153c18c91a76/torchvision-0.24.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:f231f6a4f2aa6522713326d0d2563538fa72d613741ae364f9913027fa52ea35", size = 2341708, upload-time = "2025-11-12T15:25:25.08Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b9/d6c903495cbdfd2533b3ef6f7b5643ff589ea062f8feb5c206ee79b9d9e5/torchvision-0.24.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:1540a9e7f8cf55fe17554482f5a125a7e426347b71de07327d5de6bfd8d17caa", size = 8177239, upload-time = "2025-11-12T15:25:18.554Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2b/ba02e4261369c3798310483028495cf507e6cb3f394f42e4796981ecf3a7/torchvision-0.24.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d83e16d70ea85d2f196d678bfb702c36be7a655b003abed84e465988b6128938", size = 4251604, upload-time = "2025-11-12T15:25:34.069Z" }, - { url = "https://files.pythonhosted.org/packages/42/84/577b2cef8f32094add5f52887867da4c2a3e6b4261538447e9b48eb25812/torchvision-0.24.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cccf4b4fec7fdfcd3431b9ea75d1588c0a8596d0333245dafebee0462abe3388", size = 2005319, upload-time = "2025-11-12T15:25:23.827Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/ecb786bffe0159a3b49941a61caaae089853132f3cd1e8f555e3621f7e6f/torchvision-0.24.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1b495edd3a8f9911292424117544f0b4ab780452e998649425d1f4b2bed6695f", size = 2338844, upload-time = "2025-11-12T15:25:32.625Z" }, - { url = "https://files.pythonhosted.org/packages/51/99/a84623786a6969504c87f2dc3892200f586ee13503f519d282faab0bb4f0/torchvision-0.24.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab211e1807dc3e53acf8f6638df9a7444c80c0ad050466e8d652b3e83776987b", size = 8175144, upload-time = "2025-11-12T15:25:31.355Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ba/8fae3525b233e109317ce6a9c1de922ab2881737b029a7e88021f81e068f/torchvision-0.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:18f9cb60e64b37b551cd605a3d62c15730c086362b40682d23e24b616a697d41", size = 4234459, upload-time = "2025-11-12T15:25:19.859Z" }, - { url = "https://files.pythonhosted.org/packages/50/33/481602c1c72d0485d4b3a6b48c9534b71c2957c9d83bf860eb837bf5a620/torchvision-0.24.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec9d7379c519428395e4ffda4dbb99ec56be64b0a75b95989e00f9ec7ae0b2d7", size = 2005336, upload-time = "2025-11-12T15:25:27.225Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7f/372de60bf3dd8f5593bd0d03f4aecf0d1fd58f5bc6943618d9d913f5e6d5/torchvision-0.24.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:af9201184c2712d808bd4eb656899011afdfce1e83721c7cb08000034df353fe", size = 2341704, upload-time = "2025-11-12T15:25:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/36/9b/0f3b9ff3d0225ee2324ec663de0e7fb3eb855615ca958ac1875f22f1f8e5/torchvision-0.24.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9ef95d819fd6df81bc7cc97b8f21a15d2c0d3ac5dbfaab5cbc2d2ce57114b19e", size = 8177422, upload-time = "2025-11-12T15:25:37.357Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ab/e2bcc7c2f13d882a58f8b30ff86f794210b075736587ea50f8c545834f8a/torchvision-0.24.1-cp314-cp314t-win_amd64.whl", hash = "sha256:480b271d6edff83ac2e8d69bbb4cf2073f93366516a50d48f140ccfceedb002e", size = 4335190, upload-time = "2025-11-12T15:25:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/64/46/bc0ebd93282aeedc1759f054a252c6fadf14b42a0535db3233c85cce4ae5/torchvision-0.27.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ad8743a9c12c8c124ad0a1491e54c3ca0c749e91e374e3d92136060b22c9e0f4", size = 1852118, upload-time = "2026-06-17T21:09:32.448Z" }, + { url = "https://files.pythonhosted.org/packages/b2/00/752adc57b6aa8bb833f5b0672acb9538aa5535d64998b9d8dd48ee51fa80/torchvision-0.27.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a726707e4cbe438fcc507d787af7acf6bca52de30bf4b03579f1dfc0675da829", size = 7831256, upload-time = "2026-06-17T21:09:26.767Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8a/c474fb27faba02e84dc40e0ac9ea1aa828d6d3557a378f7d0a22468bb2a3/torchvision-0.27.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1d6a123009af59ad288459f579f67a65cbe8f59372dc7b97e41bc01a6a9b767", size = 7659995, upload-time = "2026-06-17T21:09:25.325Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7c/e254f8e242a921adc2cc62c11674fa8a16d33e0a1b6c6f5436cb91628ee7/torchvision-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3b57a984283896f15c9698562418282f828332886c77315bf269936e6ba0280", size = 3807497, upload-time = "2026-06-17T21:09:31.234Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/2e8fdc19e4f0bbe31d403a55d78318bcea4afcd3083e1e4700ef61ebb893/torchvision-0.27.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:448abfc3baba984da4577f737209e445da6be93e3b5f4799d90162bf61e3f485", size = 1852105, upload-time = "2026-06-17T21:09:33.695Z" }, + { url = "https://files.pythonhosted.org/packages/43/42/103fa8f9366cfd1329fe449d6b1a25a640c0c17862ed48f21c4af94af322/torchvision-0.27.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9edfb5a549fc2f30ccadb24eca907901e92e426c91a59316be6703a9360e5098", size = 7830902, upload-time = "2026-06-17T21:09:29.739Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/fa6052a42110a3657fc94073648da6171220469f4bf9f27e6a0b9378075c/torchvision-0.27.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae3d49e57c4abc8eafc1a1971f80fc4948a6268fa69340737ca4466936def080", size = 7664211, upload-time = "2026-06-17T21:09:17.206Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/27aca854da7e536a339f46bab1ef67823ac2ac97c59ab2b3203b373d46cf/torchvision-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b6e3aa98b7433506bbce1d0d05cb13ec787fc6eb8c5fbd998b26ce05f047543", size = 4079076, upload-time = "2026-06-17T21:09:15.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/bb/b21e0f598ca191bb2a9e9fda2fee37c06ad113313b43c6769dbefa0e921d/torchvision-0.27.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d60311a6d08df905f9656a3a312f0a8f55f0d46321bc737bad30a8dec9644309", size = 1852110, upload-time = "2026-06-17T21:09:22.577Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/d61171daa5d6cd5f9315f84f9ef947b047a9fdf283d53241327045a8dd6d/torchvision-0.27.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:08aa33bc8e062cca32aefa90ac714916c5a855cbe1ab4c6148fc0453eb40ca5a", size = 7789476, upload-time = "2026-06-17T21:09:13.105Z" }, + { url = "https://files.pythonhosted.org/packages/b8/dc/b21d7801562c23a770e7037989814582f22ca4db479204293561de4b62e8/torchvision-0.27.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:916448be4b19676677b0dbf47d08f68b7955ea0abec7fc79340c31e217a824ba", size = 7664256, upload-time = "2026-06-17T21:09:07.549Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b3/4386976ff77eda55f0aed504a288564f3ff8d170b6db49ee22e172eddfac/torchvision-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:18bc906235bfa901c135acd239f05b8c8ab90d502830cf1ef2cba3301e1f8a23", size = 4150710, upload-time = "2026-06-17T21:09:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/74/1d237c61f665bf46d02e15f67c9d40be42b1b634f87164b9cefd257450e7/torchvision-0.27.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9f5ef59ad60e695796eca6b64e97cb9b21b9d5463cac5ac0ef86cfb72b6e5db9", size = 1852112, upload-time = "2026-06-17T21:09:21.445Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/f0d772e7ed85891f084755bd5d7f6f7fd279992a02652c653c1c8429dd84/torchvision-0.27.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ab2f8047c2da5bf6742fec6da86840e5feaeb0cea76930d0536f3520df31e166", size = 7789751, upload-time = "2026-06-17T21:09:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/76/68/3febd41b6eef453a83fb7a0178446334fbb0405eb4b0c40b00efaf99a2dc/torchvision-0.27.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b44ef28ad1963f8cba5bf82f3564c454c74be300df9f79efa43f773312d17d6c", size = 7664350, upload-time = "2026-06-17T21:09:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/52/49/a23e199faf29e42a90f7d6b76437ade5d17e3185da3c64d368973ba8243e/torchvision-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:b3e9bc71854fddbf94ddb69ed8d88983945f3f28f78ee104214b0088669af66a", size = 4177297, upload-time = "2026-06-17T21:09:10.273Z" }, + { url = "https://files.pythonhosted.org/packages/ba/48/b3240eaf0fe3676dcf677ce8930ef477fe77d7f69ebe58ca8d0941384952/torchvision-0.27.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c2fd9902f23b56b6ac667213171672fb6c89287ff011918b04af053852a2c4eb", size = 1852118, upload-time = "2026-06-17T21:09:20.223Z" }, + { url = "https://files.pythonhosted.org/packages/73/01/6c8f3158994a9e5bb0c7b1bacc361d60e015ad79487af88fa4d7ce72c2b6/torchvision-0.27.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8abb6d5cacd56486ca2240e5580750e53ac559412e472ea6a3cee83231a77ca7", size = 7791242, upload-time = "2026-06-17T21:09:02.062Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/f66733fc411a9ce070c0d899c1ae562ff11654a0bc708511e23efe9d6872/torchvision-0.27.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d11da1ce8a5cc7fc527f2d5e0fe25efba93687897fe9339382b593910b1d1c6e", size = 7664934, upload-time = "2026-06-17T21:09:06.221Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/d6179812ec52b70a7a8f5e99fe7937895d28c535106df1ca0d03f5f51425/torchvision-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:12deaee20d0d9dec6302025d3f93354266befeb692f5c50bca0137b395598b9e", size = 4284412, upload-time = "2026-06-17T21:09:08.989Z" }, ] [[package]] name = "tornado" -version = "6.5.4" +version = "6.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, - { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, - { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] [[package]] name = "tqdm" -version = "4.67.1" +version = "4.68.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] [[package]] name = "traitlets" -version = "5.14.3" +version = "5.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] [[package]] name = "triton" -version = "3.5.1" +version = "3.7.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/72/ec90c3519eaf168f22cb1757ad412f3a2add4782ad3a92861c9ad135d886/triton-3.5.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61413522a48add32302353fdbaaf92daaaab06f6b5e3229940d21b5207f47579", size = 170425802, upload-time = "2025-11-11T17:40:53.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/50/9a8358d3ef58162c0a415d173cfb45b67de60176e1024f71fbc4d24c0b6d/triton-3.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2c6b915a03888ab931a9fd3e55ba36785e1fe70cbea0b40c6ef93b20fc85232", size = 170470207, upload-time = "2025-11-11T17:41:00.253Z" }, - { url = "https://files.pythonhosted.org/packages/27/46/8c3bbb5b0a19313f50edcaa363b599e5a1a5ac9683ead82b9b80fe497c8d/triton-3.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4346b6ebbd4fad18773f5ba839114f4826037c9f2f34e0148894cd5dd3dba", size = 170470410, upload-time = "2025-11-11T17:41:06.319Z" }, - { url = "https://files.pythonhosted.org/packages/37/92/e97fcc6b2c27cdb87ce5ee063d77f8f26f19f06916aa680464c8104ef0f6/triton-3.5.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d2c70127fca6a23e247f9348b8adde979d2e7a20391bfbabaac6aebc7e6a8", size = 170579924, upload-time = "2025-11-11T17:41:12.455Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e6/c595c35e5c50c4bc56a7bac96493dad321e9e29b953b526bbbe20f9911d0/triton-3.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0637b1efb1db599a8e9dc960d53ab6e4637db7d4ab6630a0974705d77b14b60", size = 170480488, upload-time = "2025-11-11T17:41:18.222Z" }, - { url = "https://files.pythonhosted.org/packages/16/b5/b0d3d8b901b6a04ca38df5e24c27e53afb15b93624d7fd7d658c7cd9352a/triton-3.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bac7f7d959ad0f48c0e97d6643a1cc0fd5786fe61cb1f83b537c6b2d54776478", size = 170582192, upload-time = "2025-11-11T17:41:23.963Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { 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]] @@ -3557,34 +3908,35 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { 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 = "virtualenv" -version = "20.36.1" +version = "21.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] [[package]] name = "wcwidth" -version = "0.2.14" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] @@ -3616,14 +3968,14 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.5" +version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] [[package]] @@ -3637,26 +3989,50 @@ wheels = [ [[package]] name = "zarr" -version = "3.1.5" +version = "3.1.6" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "donfig" }, + { name = "google-crc32c" }, + { name = "numcodecs" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/7c/ba8ca8cbe9dbef8e83a95fc208fed8e6686c98b4719aaa0aa7f3d31fe390/zarr-3.1.6-py3-none-any.whl", hash = "sha256:b5a82c5079d1c3d4ee8f06746fa3b9a98a7d804300fa3f4be154362a33e1207e", size = 295655, upload-time = "2026-03-23T17:25:17.189Z" }, +] + +[[package]] +name = "zarr" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", +] dependencies = [ { name = "donfig" }, { name = "google-crc32c" }, { name = "numcodecs" }, - { name = "numpy" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/76/7fa87f57c112c7b9c82f0a730f8b6f333e792574812872e2cd45ab604199/zarr-3.1.5.tar.gz", hash = "sha256:fbe0c79675a40c996de7ca08e80a1c0a20537bd4a9f43418b6d101395c0bba2b", size = 366825, upload-time = "2025-11-21T14:06:01.492Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/8d/aeb164004f87543b06ef54f885d02c342c31ceb274e2bbec470a98927621/zarr-3.2.1.tar.gz", hash = "sha256:71565b738a0e7e8ed226f0516eba8c6bb53440ad7669a8c48ebb3534a161d035", size = 675161, upload-time = "2026-05-05T12:37:22.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/15/bb13b4913ef95ad5448490821eee4671d0e67673342e4d4070854e5fe081/zarr-3.1.5-py3-none-any.whl", hash = "sha256:29cd905afb6235b94c09decda4258c888fcb79bb6c862ef7c0b8fe009b5c8563", size = 284067, upload-time = "2025-11-21T14:05:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/88/0a/469e2bd01be1490336e6c8707386845655d59261543315778a3ccc7e8019/zarr-3.2.1-py3-none-any.whl", hash = "sha256:f78cdd3d9687ad0e9f9cba2c5683b64f0c52589c19f685eeabe872e93cc0d2c7", size = 319617, upload-time = "2026-05-05T12:37:20.66Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] diff --git a/widget/js/colormaps.ts b/widget/js/colormaps.ts new file mode 100644 index 00000000..40a940b2 --- /dev/null +++ b/widget/js/colormaps.ts @@ -0,0 +1,1098 @@ +// ============================================================================ +// Color palettes (LUT control points) +// ============================================================================ + +const COLORMAP_POINTS: Record = { + inferno: [ + [0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], + [212, 72, 66], [245, 125, 21], [252, 193, 57], [252, 255, 164], + ], + viridis: [ + [68, 1, 84], [72, 36, 117], [65, 68, 135], [53, 95, 141], + [42, 120, 142], [33, 145, 140], [34, 168, 132], [68, 191, 112], + [122, 209, 81], [189, 223, 38], [253, 231, 37], + ], + plasma: [ + [13, 8, 135], [75, 3, 161], [126, 3, 168], [168, 34, 150], + [203, 70, 121], [229, 107, 93], [248, 148, 65], [253, 195, 40], [240, 249, 33], + ], + magma: [ + [0, 0, 4], [28, 16, 68], [79, 18, 123], [129, 37, 129], + [181, 54, 122], [229, 80, 100], [251, 135, 97], [254, 194, 135], [252, 253, 191], + ], + hot: [ + [0, 0, 0], [87, 0, 0], [173, 0, 0], [255, 0, 0], + [255, 87, 0], [255, 173, 0], [255, 255, 0], [255, 255, 128], [255, 255, 255], + ], + gray: [[0, 0, 0], [255, 255, 255]], + hsv: [ + [255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], + [0, 0, 255], [255, 0, 255], [255, 0, 0], + ], + turbo: [ + [48, 18, 59], [69, 55, 161], [66, 107, 230], [30, 162, 230], + [29, 212, 169], [79, 241, 89], [175, 240, 32], [244, 195, 12], + [248, 118, 11], [207, 46, 3], [122, 4, 2], + ], + RdBu: [ + [103, 0, 31], [178, 24, 43], [214, 96, 77], [244, 165, 130], + [253, 219, 199], [247, 247, 247], [209, 229, 240], [146, 197, 222], + [67, 147, 195], [33, 102, 172], [5, 48, 97], + ], +}; + +export const COLORMAP_NAMES = Object.keys(COLORMAP_POINTS); + +function createColormapLUT(points: number[][]): Uint8Array { + const lut = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + const t = (i / 255) * (points.length - 1); + const idx = Math.floor(t); + const frac = t - idx; + const p0 = points[Math.min(idx, points.length - 1)]; + const p1 = points[Math.min(idx + 1, points.length - 1)]; + lut[i * 3] = Math.round(p0[0] + frac * (p1[0] - p0[0])); + lut[i * 3 + 1] = Math.round(p0[1] + frac * (p1[1] - p0[1])); + lut[i * 3 + 2] = Math.round(p0[2] + frac * (p1[2] - p0[2])); + } + return lut; +} + +export const COLORMAPS: Record = Object.fromEntries( + Object.entries(COLORMAP_POINTS).map(([name, points]) => [name, createColormapLUT(points)]) +); + +// ============================================================================ +// CPU colormap (Float32 -> RGBA via 256-entry LUT) +// ============================================================================ + +/** Apply colormap LUT to float data, writing into an RGBA Uint8ClampedArray. */ +export function applyColormap( + data: Float32Array, + rgba: Uint8ClampedArray, + lut: Uint8Array, + vmin: number, + vmax: number, +): void { + const range = vmax > vmin ? vmax - vmin : 1; + const uniformData = !(vmax > vmin); + for (let i = 0; i < data.length; i++) { + const clipped = Math.max(vmin, Math.min(vmax, data[i])); + const v = uniformData ? 128 : Math.min(255, Math.floor(((clipped - vmin) / range) * 255)); + const j = i * 4; + const lutIdx = v * 3; + rgba[j] = lut[lutIdx]; + rgba[j + 1] = lut[lutIdx + 1]; + rgba[j + 2] = lut[lutIdx + 2]; + rgba[j + 3] = 255; + } +} + +/** Create an offscreen canvas with colormapped data. Returns null if context unavailable. */ +export function renderToOffscreen( + data: Float32Array, + width: number, + height: number, + lut: Uint8Array, + vmin: number, + vmax: number, +): HTMLCanvasElement | null { + const offscreen = document.createElement("canvas"); + offscreen.width = width; + offscreen.height = height; + const ctx = offscreen.getContext("2d"); + if (!ctx) return null; + const imgData = ctx.createImageData(width, height); + applyColormap(data, imgData.data, lut, vmin, vmax); + ctx.putImageData(imgData, 0, 0); + return offscreen; +} + +/** Render colormapped data to a reusable offscreen canvas + ImageData (avoids per-frame allocation). */ +export function renderToOffscreenReuse( + data: Float32Array, + lut: Uint8Array, + vmin: number, + vmax: number, + offscreen: HTMLCanvasElement, + imgData: ImageData, +): void { + applyColormap(data, imgData.data, lut, vmin, vmax); + offscreen.getContext("2d")!.putImageData(imgData, 0, 0); +} + +// ============================================================================ +// WebGPU-accelerated colormap engine +// ============================================================================ + +// 2D dispatch (16×16 workgroups) to stay within WebGPU's 65535 workgroup limit. +// 1D dispatch with wg=256 needs ceil(4096*4096/256)=65536 — exceeds the limit by 1. +// ============================================================================ +// WebGPU colormap engine (compute shader, ~300x faster than CPU loop on 4K data) +// ============================================================================ + +const COLORMAP_SHADER = /* wgsl */ ` +struct Params { + width: u32, + height: u32, + vmin: f32, + vmax: f32, + log_scale: u32, + _pad: u32, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var data: array; +@group(0) @binding(2) var lut: array; +@group(0) @binding(3) var rgba: array; + +@compute @workgroup_size(16, 16) +fn main(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= params.width || gid.y >= params.height) { return; } + let idx = gid.y * params.width + gid.x; + var val = data[idx]; + if (params.log_scale == 1u) { + val = log(1.0 + max(val, 0.0)); + } + let range = max(params.vmax - params.vmin, 1e-30); + let clipped = clamp(val, params.vmin, params.vmax); + let t = (clipped - params.vmin) / range; + let lutIdx = min(u32(t * 255.0), 255u); + let rgb = lut[lutIdx]; + // Simplified: LUT is already packed as R|(G<<8)|(B<<16), just add alpha + rgba[idx] = rgb | 0xFF000000u; +} +`; + +// Fullscreen-quad blit shader: reads RGBA u32 buffer, renders to canvas texture +const BLIT_SHADER = /* wgsl */ ` +struct BlitParams { width: u32, height: u32 }; +@group(0) @binding(0) var params: BlitParams; +@group(0) @binding(1) var rgba: array; + +struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f }; + +@vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut { + // Fullscreen triangle (3 vertices, covers entire clip space) + var out: VSOut; + let x = f32(i32(vi & 1u)) * 4.0 - 1.0; + let y = f32(i32(vi >> 1u)) * 4.0 - 1.0; + out.pos = vec4f(x, y, 0.0, 1.0); + out.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5); + return out; +} + +@fragment fn fs(in: VSOut) -> @location(0) vec4f { + let px = u32(in.uv.x * f32(params.width)); + let py = u32(in.uv.y * f32(params.height)); + let idx = py * params.width + px; + let packed = rgba[idx]; + let r = f32(packed & 0xFFu) / 255.0; + let g = f32((packed >> 8u) & 0xFFu) / 255.0; + let b = f32((packed >> 16u) & 0xFFu) / 255.0; + return vec4f(r, g, b, 1.0); +} +`; + +/** + * GPU-accelerated colormap engine. Holds persistent data buffers on GPU; + * histogram slider changes only update a small uniform — no data re-upload. + */ +type GPUSlot = { + dataBuffer: GPUBuffer; + rgbaBuffer: GPUBuffer; + readBuffer: GPUBuffer; + paramsBuffer: GPUBuffer; + histBinsBuffer: GPUBuffer; + histReadBuffer: GPUBuffer; + count: number; + width: number; + height: number; +}; + +export class GPUColormapEngine { + private device: GPUDevice; + private pipeline: GPUComputePipeline | null = null; + private blitPipeline: GPURenderPipeline | null = null; + // Per-image GPU state: persistent buffers (data, rgba, read, params, histogram) + private slots: GPUSlot[] = []; + private lutBuffer: GPUBuffer | null = null; + private currentLutName: string = ""; + + constructor(device: GPUDevice) { this.device = device; } + + private ensurePipeline(): void { + if (this.pipeline) return; + const module = this.device.createShaderModule({ code: COLORMAP_SHADER }); + this.pipeline = this.device.createComputePipeline({ + layout: "auto", + compute: { module, entryPoint: "main" }, + }); + } + + /** Upload LUT to GPU (only when colormap name changes). */ + uploadLUT(lutName: string, lut: Uint8Array): void { + if (this.currentLutName === lutName && this.lutBuffer) return; + this.ensurePipeline(); + if (this.lutBuffer) this.lutBuffer.destroy(); + // Pack RGB triplets into u32 for GPU (R in low bits) + const packed = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + packed[i] = lut[i * 3] | (lut[i * 3 + 1] << 8) | (lut[i * 3 + 2] << 16); + } + this.lutBuffer = this.device.createBuffer({ + size: packed.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.device.queue.writeBuffer(this.lutBuffer, 0, packed); + this.currentLutName = lutName; + } + + + /** Upload float32 image data for slot `idx`. Only call when data changes. */ + uploadData(idx: number, data: Float32Array, width?: number, height?: number): void { + this.ensurePipeline(); + while (this.slots.length <= idx) this.slots.push(null as never); + if (this.slots[idx]) { + this.slots[idx].dataBuffer.destroy(); + this.slots[idx].rgbaBuffer.destroy(); + this.slots[idx].readBuffer.destroy(); + this.slots[idx].paramsBuffer.destroy(); + this.slots[idx].histBinsBuffer.destroy(); + this.slots[idx].histReadBuffer.destroy(); + } + // Validate dimensions — if width*height doesn't match data length, derive from sqrt + // (catches stale closure values like width=1 from mount effects) + const validDims = width && height && width > 1 && height > 1 && width * height === data.length; + const w = validDims ? width : Math.round(Math.sqrt(data.length)); + const h = validDims ? height : Math.round(data.length / w); + const byteSize = data.byteLength; + const rgbaSize = data.length * 4; + const dataBuffer = this.device.createBuffer({ + size: byteSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.device.queue.writeBuffer(dataBuffer, 0, data.buffer as ArrayBuffer, data.byteOffset, data.byteLength); + const rgbaBuffer = this.device.createBuffer({ + size: rgbaSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + // Persistent read buffer — reused on every applySlots call (no create/destroy overhead) + const readBuffer = this.device.createBuffer({ + size: rgbaSize, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + // Persistent params buffer — reused (just writeBuffer on each call) + const paramsBuffer = this.device.createBuffer({ + size: 24, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + // Persistent histogram buffers (256 bins × 4 bytes = 1KB each) + const histBinsBuffer = this.device.createBuffer({ + size: 256 * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + const histReadBuffer = this.device.createBuffer({ + size: 256 * 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + this.slots[idx] = { dataBuffer, rgbaBuffer, readBuffer, paramsBuffer, histBinsBuffer, histReadBuffer, count: data.length, width: w, height: h }; + } + + // Params buffer: 24 bytes = { width: u32, height: u32, vmin: f32, vmax: f32, log_scale: u32, _pad: u32 } + private _writeParams(buf: ArrayBuffer, width: number, height: number, vmin: number, vmax: number, logScale: boolean): void { + const u = new Uint32Array(buf); + const f = new Float32Array(buf); + u[0] = width; + u[1] = height; + f[2] = vmin; + f[3] = vmax; + u[4] = logScale ? 1 : 0; + u[5] = 0; // pad + } + + /** + * Apply colormap to specific slot indices with per-image vmin/vmax. + * Uses persistent per-slot read buffers (no create/destroy overhead). + * Log scale is applied on GPU per pixel. + */ + async applySlots( + indices: number[], + ranges: { vmin: number; vmax: number }[], + logScale: boolean = false, + ): Promise<{ idx: number; rgba: Uint8ClampedArray }[]> { + if (!this.pipeline || !this.lutBuffer || indices.length === 0) return []; + + const activeSlots: { idx: number; slot: GPUSlot; count: number }[] = []; + const encoder = this.device.createCommandEncoder(); + const params = new ArrayBuffer(24); + + for (let k = 0; k < indices.length; k++) { + const i = indices[k]; + const slot = this.slots[i]; + if (!slot) continue; + const range = ranges[k] || { vmin: 0, vmax: 1 }; + + // Reuse persistent paramsBuffer — just write new values + this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); + this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); + + const bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: this.lutBuffer } }, + { binding: 3, resource: { buffer: slot.rgbaBuffer } }, + ], + }); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); + pass.end(); + + // Copy to persistent read buffer + encoder.copyBufferToBuffer(slot.rgbaBuffer, 0, slot.readBuffer, 0, slot.count * 4); + activeSlots.push({ idx: i, slot, count: slot.count }); + } + this.device.queue.submit([encoder.finish()]); + await Promise.all(activeSlots.map(s => s.slot.readBuffer.mapAsync(GPUMapMode.READ))); + + const results: { idx: number; rgba: Uint8ClampedArray }[] = []; + for (const s of activeSlots) { + const mapped = s.slot.readBuffer.getMappedRange(); + const rgba = new Uint8ClampedArray(s.count * 4); + rgba.set(new Uint8ClampedArray(mapped)); + s.slot.readBuffer.unmap(); + results.push({ idx: s.idx, rgba }); + } + + // applySlots is for callers that need raw RGBA arrays (not rendering to canvas) + // For rendering, use renderSlots which avoids the intermediate copy + return results; + } + + /** Apply colormap to ALL slots with shared vmin/vmax. */ + async apply(vmin: number, vmax: number, logScale: boolean = false): Promise { + const indices = this.slots.map((_, i) => i).filter(i => this.slots[i]); + const ranges = indices.map(() => ({ vmin, vmax })); + const results = await this.applySlots(indices, ranges, logScale); + // Return in slot order + const out: Uint8ClampedArray[] = []; + for (const r of results) out[r.idx] = r.rgba; + return out.filter(x => x); + } + + /** Apply colormap with per-image vmin/vmax. */ + async applyPerImage(ranges: { vmin: number; vmax: number }[], logScale: boolean = false): Promise { + const indices = this.slots.map((_, i) => i).filter(i => this.slots[i]); + const perSlotRanges = indices.map(i => ranges[i] || { vmin: 0, vmax: 1 }); + const results = await this.applySlots(indices, perSlotRanges, logScale); + const out: Uint8ClampedArray[] = []; + for (const r of results) out[r.idx] = r.rgba; + return out.filter(x => x); + } + + /** Apply colormap to a SINGLE slot (fast path for slider drag). */ + async applySingle(idx: number, vmin: number, vmax: number, logScale: boolean = false): Promise { + const results = await this.applySlots([idx], [{ vmin, vmax }], logScale); + return results.length > 0 ? results[0].rgba : null; + } + + /** + * GPU colormap → offscreen canvas in one pass (zero intermediate allocation). + * Writes from GPU mapped memory directly into ImageData, then putImageData. + * Eliminates the 768MB temp Uint8ClampedArray that applySlots allocates. + */ + async renderSlots( + indices: number[], + ranges: { vmin: number; vmax: number }[], + offscreens: (HTMLCanvasElement | null)[], + imgDatas: (ImageData | null)[], + logScale: boolean = false, + ): Promise { + if (!this.pipeline || !this.lutBuffer || indices.length === 0) return 0; + + const activeSlots: { k: number; idx: number; slot: GPUSlot }[] = []; + const encoder = this.device.createCommandEncoder(); + const params = new ArrayBuffer(24); + + for (let k = 0; k < indices.length; k++) { + const i = indices[k]; + const slot = this.slots[i]; + if (!slot || !offscreens[k] || !imgDatas[k]) continue; + const range = ranges[k] || { vmin: 0, vmax: 1 }; + + this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); + this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); + + const bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: this.lutBuffer } }, + { binding: 3, resource: { buffer: slot.rgbaBuffer } }, + ], + }); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); + pass.end(); + encoder.copyBufferToBuffer(slot.rgbaBuffer, 0, slot.readBuffer, 0, slot.count * 4); + activeSlots.push({ k, idx: i, slot }); + } + this.device.queue.submit([encoder.finish()]); + await Promise.all(activeSlots.map(s => s.slot.readBuffer.mapAsync(GPUMapMode.READ))); + + // Write directly from GPU mapped memory → ImageData → offscreen canvas + let rendered = 0; + for (const s of activeSlots) { + const mapped = s.slot.readBuffer.getMappedRange(); + const imgData = imgDatas[s.k]!; + imgData.data.set(new Uint8ClampedArray(mapped)); + s.slot.readBuffer.unmap(); + offscreens[s.k]!.getContext("2d")!.putImageData(imgData, 0, 0); + rendered++; + } + return rendered; + } + + private ensureBlitPipeline(format: GPUTextureFormat): void { + if (this.blitPipeline) return; + const module = this.device.createShaderModule({ code: BLIT_SHADER }); + this.blitPipeline = this.device.createRenderPipeline({ + layout: "auto", + vertex: { module, entryPoint: "vs" }, + fragment: { + module, entryPoint: "fs", + targets: [{ format }], + }, + primitive: { topology: "triangle-list" }, + }); + } + + /** + * Zero-copy GPU render: compute colormap + blit directly to WebGPU canvas textures. + * No mapAsync, no CPU copy, no putImageData. Target: <16ms for 60fps. + * + * Each canvas must have a 'webgpu' context (not '2d'). Call configureCanvas() first. + * Returns the number of images rendered. + */ + renderSlotsZeroCopy( + indices: number[], + ranges: { vmin: number; vmax: number }[], + contexts: (GPUCanvasContext | null)[], + logScale: boolean = false, + ): number { + if (!this.pipeline || !this.lutBuffer || indices.length === 0) return 0; + + // Get texture format from first valid context + const fmt = navigator.gpu.getPreferredCanvasFormat(); + this.ensureBlitPipeline(fmt); + if (!this.blitPipeline) return 0; + + const encoder = this.device.createCommandEncoder(); + const params = new ArrayBuffer(24); + let rendered = 0; + + for (let k = 0; k < indices.length; k++) { + const i = indices[k]; + const slot = this.slots[i]; + const ctx = contexts[k]; + if (!slot || !ctx) continue; + const range = ranges[k] || { vmin: 0, vmax: 1 }; + + // 1. Compute colormap (same as renderSlots) + this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); + this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); + + const computeGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: this.lutBuffer } }, + { binding: 3, resource: { buffer: slot.rgbaBuffer } }, + ], + }); + const computePass = encoder.beginComputePass(); + computePass.setPipeline(this.pipeline); + computePass.setBindGroup(0, computeGroup); + computePass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); + computePass.end(); + + // 2. Blit RGBA buffer → canvas texture (zero-copy render pass) + const blitParamsBuffer = this.device.createBuffer({ + size: 8, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + this.device.queue.writeBuffer(blitParamsBuffer, 0, new Uint32Array([slot.width, slot.height])); + + const blitGroup = this.device.createBindGroup({ + layout: this.blitPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: blitParamsBuffer } }, + { binding: 1, resource: { buffer: slot.rgbaBuffer } }, + ], + }); + + const texture = ctx.getCurrentTexture(); + const renderPass = encoder.beginRenderPass({ + colorAttachments: [{ + view: texture.createView(), + loadOp: "clear" as GPULoadOp, + storeOp: "store" as GPUStoreOp, + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }], + }); + renderPass.setPipeline(this.blitPipeline); + renderPass.setBindGroup(0, blitGroup); + renderPass.draw(3); // fullscreen triangle + renderPass.end(); + rendered++; + + // Note: blitParamsBuffer is a temporary — ideally per-slot persistent + // For now, acceptable overhead (8 bytes per image) + } + + this.device.queue.submit([encoder.finish()]); + if (rendered > 0) { + } + return rendered; + } + + /** + * GPU colormap → OffscreenCanvas → ImageBitmap (zero mapAsync). + * Compute shader writes RGBA, render pass blits to OffscreenCanvas texture, + * transferToImageBitmap() returns ImageBitmap for drawImage on 2D canvas. + * Eliminates the 35ms JS memcpy for 12×4K images. + */ + renderSlotsToImageBitmap( + indices: number[], + ranges: { vmin: number; vmax: number }[], + logScale: boolean = false, + ): ImageBitmap[] | null { + if (!this.pipeline || !this.lutBuffer || indices.length === 0) return null; + const fmt = navigator.gpu.getPreferredCanvasFormat(); + this.ensureBlitPipeline(fmt); + if (!this.blitPipeline) return null; + + const encoder = this.device.createCommandEncoder(); + const params = new ArrayBuffer(24); + const canvases: OffscreenCanvas[] = []; + + for (let k = 0; k < indices.length; k++) { + const i = indices[k]; + const slot = this.slots[i]; + if (!slot) { canvases.push(null as never); continue; } + const range = ranges[k] || { vmin: 0, vmax: 1 }; + + // Compute colormap + this._writeParams(params, slot.width, slot.height, range.vmin, range.vmax, logScale); + this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); + + const computeGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: this.lutBuffer } }, + { binding: 3, resource: { buffer: slot.rgbaBuffer } }, + ], + }); + const computePass = encoder.beginComputePass(); + computePass.setPipeline(this.pipeline); + computePass.setBindGroup(0, computeGroup); + computePass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); + computePass.end(); + + // Blit to OffscreenCanvas + const oc = new OffscreenCanvas(slot.width, slot.height); + const ctx = oc.getContext("webgpu") as GPUCanvasContext; + ctx.configure({ device: this.device, format: fmt, alphaMode: "opaque" }); + + const blitParamsBuffer = this.device.createBuffer({ + size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + this.device.queue.writeBuffer(blitParamsBuffer, 0, new Uint32Array([slot.width, slot.height])); + + const blitGroup = this.device.createBindGroup({ + layout: this.blitPipeline!.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: blitParamsBuffer } }, + { binding: 1, resource: { buffer: slot.rgbaBuffer } }, + ], + }); + + const texture = ctx.getCurrentTexture(); + const renderPass = encoder.beginRenderPass({ + colorAttachments: [{ + view: texture.createView(), + loadOp: "clear" as GPULoadOp, + storeOp: "store" as GPUStoreOp, + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }], + }); + renderPass.setPipeline(this.blitPipeline!); + renderPass.setBindGroup(0, blitGroup); + renderPass.draw(3); + renderPass.end(); + canvases.push(oc); + } + + this.device.queue.submit([encoder.finish()]); + + // transferToImageBitmap after GPU finishes (synchronous, no mapAsync) + const bitmaps: ImageBitmap[] = []; + for (const oc of canvases) { + if (oc) bitmaps.push(oc.transferToImageBitmap()); + else bitmaps.push(null as never); + } + return bitmaps; + } + + /** + * Configure a canvas for WebGPU zero-copy rendering. + * Returns the GPUCanvasContext, or null if WebGPU canvas is not supported. + */ + configureCanvas(canvas: HTMLCanvasElement, width: number, height: number): GPUCanvasContext | null { + try { + const ctx = canvas.getContext("webgpu") as GPUCanvasContext | null; + if (!ctx) return null; + ctx.configure({ + device: this.device, + format: navigator.gpu.getPreferredCanvasFormat(), + alphaMode: "opaque", + }); + canvas.width = width; + canvas.height = height; + return ctx; + } catch { + return null; + } + } + + /** Release all GPU resources. */ + destroy(): void { + for (const slot of this.slots) { + if (slot) { + slot.dataBuffer.destroy(); + slot.rgbaBuffer.destroy(); + slot.readBuffer.destroy(); + slot.paramsBuffer.destroy(); + slot.histBinsBuffer.destroy(); + slot.histReadBuffer.destroy(); + } + } + this.slots = []; + this.lutBuffer?.destroy(); + this.lutBuffer = null; + this.currentLutName = ""; + } + + /** Number of uploaded image slots. */ + get slotCount(): number { return this.slots.filter(s => s).length; } + + // ── GPU min/max reduction ── + + private rangePipeline: GPUComputePipeline | null = null; + private RANGE_WG_SIZE = 256; + + private ensureRangePipeline(): void { + if (this.rangePipeline) return; + // Two-pass parallel reduction: each workgroup reduces a chunk to one min/max pair. + // Output: array of [min, max] pairs (one per workgroup). JS reduces the partials. + const code = /* wgsl */ ` +@group(0) @binding(0) var data: array; +@group(0) @binding(1) var out: array; +@group(0) @binding(2) var count: u32; + +var sMin: array; +var sMax: array; + +@compute @workgroup_size(256) +fn reduce(@builtin(global_invocation_id) gid: vec3u, @builtin(local_invocation_id) lid: vec3u, @builtin(workgroup_id) wid: vec3u) { + let i = gid.x; + if (i < count) { + sMin[lid.x] = data[i]; + sMax[lid.x] = data[i]; + } else { + sMin[lid.x] = 3.4028235e+38; + sMax[lid.x] = -3.4028235e+38; + } + workgroupBarrier(); + + // Tree reduction in shared memory + for (var s = 128u; s > 0u; s >>= 1u) { + if (lid.x < s) { + sMin[lid.x] = min(sMin[lid.x], sMin[lid.x + s]); + sMax[lid.x] = max(sMax[lid.x], sMax[lid.x + s]); + } + workgroupBarrier(); + } + + if (lid.x == 0u) { + out[wid.x * 2u] = sMin[0]; + out[wid.x * 2u + 1u] = sMax[0]; + } +} +`; + const module = this.device.createShaderModule({ code }); + this.rangePipeline = this.device.createComputePipeline({ + layout: "auto", + compute: { module, entryPoint: "reduce" }, + }); + } + + /** + * Batch-compute min/max for multiple slots on GPU. + * Returns { min, max } per slot. One GPU submission for all slots. + */ + async computeRangeBatch(indices: number[]): Promise<{ min: number; max: number }[]> { + this.ensureRangePipeline(); + if (!this.rangePipeline || indices.length === 0) return []; + const WG = this.RANGE_WG_SIZE; + + const encoder = this.device.createCommandEncoder(); + const jobs: { idx: number; nGroups: number; outBuf: GPUBuffer; readBuf: GPUBuffer; countBuf: GPUBuffer }[] = []; + + for (const i of indices) { + const slot = this.slots[i]; + if (!slot) continue; + const N = slot.count; + const nGroups = Math.ceil(N / WG); + const outSize = nGroups * 2 * 4; // 2 floats (min, max) per workgroup + const outBuf = this.device.createBuffer({ size: outSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); + const readBuf = this.device.createBuffer({ size: outSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + const countBuf = this.device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + this.device.queue.writeBuffer(countBuf, 0, new Uint32Array([N])); + + const bg = this.device.createBindGroup({ + layout: this.rangePipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.dataBuffer } }, + { binding: 1, resource: { buffer: outBuf } }, + { binding: 2, resource: { buffer: countBuf } }, + ], + }); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.rangePipeline); + pass.setBindGroup(0, bg); + pass.dispatchWorkgroups(nGroups); + pass.end(); + encoder.copyBufferToBuffer(outBuf, 0, readBuf, 0, outSize); + jobs.push({ idx: i, nGroups, outBuf, readBuf, countBuf }); + } + + this.device.queue.submit([encoder.finish()]); + await Promise.all(jobs.map(j => j.readBuf.mapAsync(GPUMapMode.READ))); + + const results: { min: number; max: number }[] = []; + for (const j of jobs) { + const partials = new Float32Array(j.readBuf.getMappedRange().slice(0)); + j.readBuf.unmap(); + j.outBuf.destroy(); j.readBuf.destroy(); j.countBuf.destroy(); + // JS reduces partials: ~65K elements for 16M data = trivial + let dmin = Infinity, dmax = -Infinity; + for (let k = 0; k < j.nGroups; k++) { + if (partials[k * 2] < dmin) dmin = partials[k * 2]; + if (partials[k * 2 + 1] > dmax) dmax = partials[k * 2 + 1]; + } + results.push({ min: dmin, max: dmax }); + } + return results; + } + + // ── GPU histogram ── + + private histPipeline: GPUComputePipeline | null = null; + private histClearPipeline: GPUComputePipeline | null = null; + + private ensureHistPipeline(): void { + if (this.histPipeline) return; + const code = /* wgsl */ ` +struct HistParams { + width: u32, + height: u32, + dmin: f32, + dmax: f32, + log_scale: u32, + _pad: u32, +}; +@group(0) @binding(0) var params: HistParams; +@group(0) @binding(1) var data: array; +@group(0) @binding(2) var bins: array>; + +@compute @workgroup_size(16, 16) +fn histogram(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= params.width || gid.y >= params.height) { return; } + let idx = gid.y * params.width + gid.x; + var val = data[idx]; + if (params.log_scale == 1u) { val = log(1.0 + max(val, 0.0)); } + let range = max(params.dmax - params.dmin, 1e-30); + let t = clamp((val - params.dmin) / range, 0.0, 1.0); + let bin = min(u32(t * 256.0), 255u); + atomicAdd(&bins[bin], 1u); +} + +@compute @workgroup_size(256) +fn clear_bins(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x < 256u) { atomicStore(&bins[gid.x], 0u); } +} +`; + const module = this.device.createShaderModule({ code }); + this.histPipeline = this.device.createComputePipeline({ + layout: "auto", + compute: { module, entryPoint: "histogram" }, + }); + this.histClearPipeline = this.device.createComputePipeline({ + layout: "auto", + compute: { module, entryPoint: "clear_bins" }, + }); + } + + /** + * Compute a 256-bin histogram for slot `idx` on GPU. + * Returns normalized bins (0–1) matching `computeHistogramFromBytes`. + */ + async computeHistogram(idx: number, _logScale: boolean = false): Promise { + this.ensureHistPipeline(); + const slot = this.slots[idx]; + if (!slot || !this.histPipeline || !this.histClearPipeline) return new Array(256).fill(0); + + // Find data range (we need min/max for binning) + // For GPU efficiency, do a quick CPU scan — findDataRange is fast (<5ms for 16M) + // A full GPU min/max reduction would add complexity for minimal gain here. + // Note: when logScale is true, we need the log-transformed range. + + const binsBuffer = this.device.createBuffer({ + size: 256 * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + const readBuffer = this.device.createBuffer({ + size: 256 * 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + const paramsBuf = this.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + // We need min/max from the (possibly log-transformed) data for proper binning. + // Pass raw min/max = 0; the shader will use the actual data range. + // Actually, we need to know the range to bin correctly. Read it back from + // the data we already uploaded. For now, accept min/max as parameters. + // The caller (Show2D data effect) already computes findDataRange. + // So let's accept dmin/dmax as params. + + // This method needs dmin/dmax — return a version that takes them: + binsBuffer.destroy(); + readBuffer.destroy(); + paramsBuf.destroy(); + return new Array(256).fill(0); + } + + /** + * Batch-compute 256-bin histograms for multiple slots in ONE GPU submission. + * Uses persistent per-slot histogram buffers (zero create/destroy overhead). + * Returns normalized bins per image. + */ + async computeHistogramBatch( + indices: number[], + ranges: { min: number; max: number }[], + logScale: boolean = false, + ): Promise { + this.ensureHistPipeline(); + if (!this.histPipeline || !this.histClearPipeline || indices.length === 0) return []; + + const encoder = this.device.createCommandEncoder(); + const activeSlots: { k: number; slot: GPUSlot }[] = []; + const params = new ArrayBuffer(24); + + for (let k = 0; k < indices.length; k++) { + const i = indices[k]; + const slot = this.slots[i]; + if (!slot) continue; + const r = ranges[k] || { min: 0, max: 1 }; + if (r.min === r.max) continue; + + // Reuse persistent paramsBuffer for histogram (same layout as colormap params) + const pu = new Uint32Array(params); + const pf = new Float32Array(params); + pu[0] = slot.width; pu[1] = slot.height; + pf[2] = r.min; pf[3] = r.max; + pu[4] = logScale ? 1 : 0; pu[5] = 0; + this.device.queue.writeBuffer(slot.paramsBuffer, 0, params); + + // Clear bins (persistent buffer) + const clearGroup = this.device.createBindGroup({ + layout: this.histClearPipeline!.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: slot.histBinsBuffer } }, + ], + }); + const clearPass = encoder.beginComputePass(); + clearPass.setPipeline(this.histClearPipeline!); + clearPass.setBindGroup(0, clearGroup); + clearPass.dispatchWorkgroups(1); + clearPass.end(); + + // Histogram (persistent buffer) + const histGroup = this.device.createBindGroup({ + layout: this.histPipeline!.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: slot.histBinsBuffer } }, + ], + }); + const histPass = encoder.beginComputePass(); + histPass.setPipeline(this.histPipeline!); + histPass.setBindGroup(0, histGroup); + histPass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); + histPass.end(); + + encoder.copyBufferToBuffer(slot.histBinsBuffer, 0, slot.histReadBuffer, 0, 256 * 4); + activeSlots.push({ k, slot }); + } + + this.device.queue.submit([encoder.finish()]); + await Promise.all(activeSlots.map(s => s.slot.histReadBuffer.mapAsync(GPUMapMode.READ))); + + const results: number[][] = []; + for (const s of activeSlots) { + const rawBins = new Uint32Array(s.slot.histReadBuffer.getMappedRange().slice(0)); + s.slot.histReadBuffer.unmap(); + + let maxCount = 0; + for (let j = 0; j < 256; j++) if (rawBins[j] > maxCount) maxCount = rawBins[j]; + const norm = new Array(256); + for (let j = 0; j < 256; j++) norm[j] = maxCount > 0 ? rawBins[j] / maxCount : 0; + results.push(norm); + } + return results; + } + + /** + * Compute a 256-bin histogram for slot `idx` on GPU, given known data range. + * Returns normalized bins (0–1) matching `computeHistogramFromBytes`. + */ + async computeHistogramWithRange( + idx: number, dmin: number, dmax: number, logScale: boolean = false, + ): Promise { + this.ensureHistPipeline(); + const slot = this.slots[idx]; + if (!slot || !this.histPipeline || !this.histClearPipeline) return new Array(256).fill(0); + if (dmin === dmax) return new Array(256).fill(0); + + const binsBuffer = this.device.createBuffer({ + size: 256 * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + }); + const readBuffer = this.device.createBuffer({ + size: 256 * 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + const paramsBuf = this.device.createBuffer({ + size: 24, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + const params = new ArrayBuffer(24); + const pu = new Uint32Array(params); + const pf = new Float32Array(params); + pu[0] = slot.width; pu[1] = slot.height; + pf[2] = dmin; pf[3] = dmax; + pu[4] = logScale ? 1 : 0; pu[5] = 0; + this.device.queue.writeBuffer(paramsBuf, 0, params); + + const encoder = this.device.createCommandEncoder(); + + // Clear bins + const clearGroup = this.device.createBindGroup({ + layout: this.histClearPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: paramsBuf } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: binsBuffer } }, + ], + }); + const clearPass = encoder.beginComputePass(); + clearPass.setPipeline(this.histClearPipeline); + clearPass.setBindGroup(0, clearGroup); + clearPass.dispatchWorkgroups(1); + clearPass.end(); + + // Histogram + const histGroup = this.device.createBindGroup({ + layout: this.histPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: paramsBuf } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: binsBuffer } }, + ], + }); + const histPass = encoder.beginComputePass(); + histPass.setPipeline(this.histPipeline); + histPass.setBindGroup(0, histGroup); + histPass.dispatchWorkgroups(Math.ceil(slot.width / 16), Math.ceil(slot.height / 16)); + histPass.end(); + + encoder.copyBufferToBuffer(binsBuffer, 0, readBuffer, 0, 256 * 4); + this.device.queue.submit([encoder.finish()]); + + await readBuffer.mapAsync(GPUMapMode.READ); + const rawBins = new Uint32Array(readBuffer.getMappedRange().slice(0)); + readBuffer.unmap(); + binsBuffer.destroy(); + readBuffer.destroy(); + paramsBuf.destroy(); + + // Normalize (match CPU: divide by max count) + let maxCount = 0; + for (let i = 0; i < 256; i++) if (rawBins[i] > maxCount) maxCount = rawBins[i]; + const result = new Array(256); + if (maxCount > 0) { + for (let i = 0; i < 256; i++) result[i] = rawBins[i] / maxCount; + } else { + for (let i = 0; i < 256; i++) result[i] = 0; + } + return result; + } +} + +let gpuColormapEngine: GPUColormapEngine | null = null; + +/** Get or create the singleton GPU colormap engine. Returns null if WebGPU unavailable. */ +export async function getGPUColormapEngine(): Promise { + if (gpuColormapEngine) return gpuColormapEngine; + // Reuse the GPU device from fft + try { + const { getGPUDevice } = await import("./fft"); + const device = await getGPUDevice(); + if (!device) return null; + gpuColormapEngine = new GPUColormapEngine(device); + return gpuColormapEngine; + } catch { + return null; + } +} + +/** Query the GPU's max buffer size in bytes. Returns 0 if WebGPU unavailable. */ +export async function getGPUMaxBufferSize(): Promise { + try { + if (!navigator.gpu) return 0; + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) return 0; + return adapter.limits.maxStorageBufferBindingSize || adapter.limits.maxBufferSize || 0; + } catch { + return 0; + } +} diff --git a/widget/js/fft.ts b/widget/js/fft.ts new file mode 100644 index 00000000..b2a72ea6 --- /dev/null +++ b/widget/js/fft.ts @@ -0,0 +1,474 @@ +/// + +/** + * WebGPU FFT — shared 2D FFT with GPU acceleration and CPU fallback. + * Handles non-power-of-2 dimensions via zero-padding. + */ + +// ============================================================================ +// CPU FFT fallback +// ============================================================================ + +export function nextPow2(n: number): number { return Math.pow(2, Math.ceil(Math.log2(n))); } + +function fft1d(real: Float32Array, imag: Float32Array, inverse: boolean = false) { + const n = real.length; + if (n <= 1) return; + let j = 0; + for (let i = 0; i < n - 1; i++) { + if (i < j) { [real[i], real[j]] = [real[j], real[i]]; [imag[i], imag[j]] = [imag[j], imag[i]]; } + let k = n >> 1; + while (k <= j) { j -= k; k >>= 1; } + j += k; + } + const sign = inverse ? 1 : -1; + for (let len = 2; len <= n; len <<= 1) { + const halfLen = len >> 1; + const angle = (sign * 2 * Math.PI) / len; + const wReal = Math.cos(angle), wImag = Math.sin(angle); + for (let i = 0; i < n; i += len) { + let curReal = 1, curImag = 0; + for (let k = 0; k < halfLen; k++) { + const evenIdx = i + k, oddIdx = i + k + halfLen; + const tReal = curReal * real[oddIdx] - curImag * imag[oddIdx]; + const tImag = curReal * imag[oddIdx] + curImag * real[oddIdx]; + real[oddIdx] = real[evenIdx] - tReal; imag[oddIdx] = imag[evenIdx] - tImag; + real[evenIdx] += tReal; imag[evenIdx] += tImag; + const newReal = curReal * wReal - curImag * wImag; + curImag = curReal * wImag + curImag * wReal; curReal = newReal; + } + } + } + if (inverse) { for (let i = 0; i < n; i++) { real[i] /= n; imag[i] /= n; } } +} + +export function fft2d(real: Float32Array, imag: Float32Array, width: number, height: number, inverse: boolean = false) { + const paddedW = nextPow2(width), paddedH = nextPow2(height); + const needsPadding = paddedW !== width || paddedH !== height; + let workReal: Float32Array, workImag: Float32Array; + if (needsPadding) { + workReal = new Float32Array(paddedW * paddedH); workImag = new Float32Array(paddedW * paddedH); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + workReal[y * paddedW + x] = real[y * width + x]; workImag[y * paddedW + x] = imag[y * width + x]; + } + } else { workReal = real; workImag = imag; } + const rowReal = new Float32Array(paddedW), rowImag = new Float32Array(paddedW); + for (let y = 0; y < paddedH; y++) { + const offset = y * paddedW; + for (let x = 0; x < paddedW; x++) { rowReal[x] = workReal[offset + x]; rowImag[x] = workImag[offset + x]; } + fft1d(rowReal, rowImag, inverse); + for (let x = 0; x < paddedW; x++) { workReal[offset + x] = rowReal[x]; workImag[offset + x] = rowImag[x]; } + } + const colReal = new Float32Array(paddedH), colImag = new Float32Array(paddedH); + for (let x = 0; x < paddedW; x++) { + for (let y = 0; y < paddedH; y++) { colReal[y] = workReal[y * paddedW + x]; colImag[y] = workImag[y * paddedW + x]; } + fft1d(colReal, colImag, inverse); + for (let y = 0; y < paddedH; y++) { workReal[y * paddedW + x] = colReal[y]; workImag[y * paddedW + x] = colImag[y]; } + } + if (needsPadding) { + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + real[y * width + x] = workReal[y * paddedW + x]; imag[y * width + x] = workImag[y * paddedW + x]; + } + } +} + +export function fftshift(data: Float32Array, width: number, height: number): void { + const halfW = width >> 1, halfH = height >> 1; + const temp = new Float32Array(width * height); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + temp[((y + halfH) % height) * width + ((x + halfW) % width)] = data[y * width + x]; + } + data.set(temp); +} + +// ============================================================================ +// CPU FFT Web Worker — runs fft2d + fftshift + computeMagnitude off main thread +// ============================================================================ + +// Build worker source by stringifying the same fft1d/fft2d/fftshift defined +// above. Single source of truth: fix a bug once, both paths get it. Pure +// functions only (no module-state closures), so .toString() captures the full +// behavior. Use Function.name in the onmessage body so minified names still +// match (esbuild may rename `fft2d` -> `a`; the .name property tracks rename). +const FFT_WORKER_CODE = ` +${nextPow2.toString()} +${fft1d.toString()} +${fft2d.toString()} +${fftshift.toString()} +self.onmessage = function(e) { + const d = e.data; + ${fft2d.name}(d.real, d.imag, d.width, d.height, d.inverse); + ${fftshift.name}(d.real, d.width, d.height); + ${fftshift.name}(d.imag, d.width, d.height); + const n = d.real.length, mag = new Float32Array(n); + for (let i = 0; i < n; i++) mag[i] = Math.sqrt(d.real[i]*d.real[i] + d.imag[i]*d.imag[i]); + self.postMessage({ id: d.id, magnitude: mag, real: d.real, imag: d.imag }, [mag.buffer, d.real.buffer, d.imag.buffer]); +}; +`; + +let _fftWorker: Worker | null = null; +const _fftCallbacks = new Map void>(); +let _fftWorkerId = 0; + +function getFFTWorker(): Worker { + if (!_fftWorker) { + const blob = new Blob([FFT_WORKER_CODE], { type: 'application/javascript' }); + _fftWorker = new Worker(URL.createObjectURL(blob)); + _fftWorker.onmessage = (e: MessageEvent) => { + const cb = _fftCallbacks.get(e.data.id); + if (cb) { + _fftCallbacks.delete(e.data.id); + cb(e.data); + } + }; + } + return _fftWorker; +} + +/** + * CPU FFT in a Web Worker — does fft2d + fftshift + computeMagnitude off main thread. + * Transfers Float32Arrays to the worker (zero-copy) so the main thread is never blocked. + * The input arrays become detached after this call — pass copies if you need to keep them. + */ +export function fft2dAsync( + real: Float32Array, imag: Float32Array, + width: number, height: number, + inverse: boolean = false, +): Promise<{ magnitude: Float32Array; real: Float32Array; imag: Float32Array }> { + const worker = getFFTWorker(); + const id = ++_fftWorkerId; + return new Promise((resolve) => { + _fftCallbacks.set(id, resolve); + worker.postMessage( + { id, real, imag, width, height, inverse }, + [real.buffer, imag.buffer], + ); + }); +} + +// ============================================================================ +// WebGPU FFT — GPU-accelerated 2D FFT +// ============================================================================ + +// ============================================================================ +// WebGPU FFT (compute shader, GPU-resident) +// ============================================================================ + +const FFT_2D_SHADER = /* wgsl */` +fn cmul(a: vec2, b: vec2) -> vec2 { return vec2(a.x*b.x-a.y*b.y, a.x*b.y+a.y*b.x); } +fn twiddle(k: u32, N: u32, inverse: f32) -> vec2 { let angle = inverse * 2.0 * 3.14159265359 * f32(k) / f32(N); return vec2(cos(angle), sin(angle)); } +fn bitReverse(x: u32, log2N: u32) -> u32 { var result: u32 = 0u; var val = x; for (var i: u32 = 0u; i < log2N; i = i + 1u) { result = (result << 1u) | (val & 1u); val = val >> 1u; } return result; } +struct FFT2DParams { width: u32, height: u32, log2Size: u32, stage: u32, inverse: f32, isRowWise: u32, } +@group(0) @binding(0) var params: FFT2DParams; +@group(0) @binding(1) var data: array>; +fn getIndex(row: u32, col: u32) -> u32 { return row * params.width + col; } +@compute @workgroup_size(16, 16) fn bitReverseRows(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let col = gid.x; if (row >= params.height || col >= params.width) { return; } let rev = bitReverse(col, params.log2Size); if (col < rev) { let idx1 = getIndex(row, col); let idx2 = getIndex(row, rev); let temp = data[idx1]; data[idx1] = data[idx2]; data[idx2] = temp; } } +@compute @workgroup_size(16, 16) fn bitReverseCols(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let col = gid.x; if (row >= params.height || col >= params.width) { return; } let rev = bitReverse(row, params.log2Size); if (row < rev) { let idx1 = getIndex(row, col); let idx2 = getIndex(rev, col); let temp = data[idx1]; data[idx1] = data[idx2]; data[idx2] = temp; } } +@compute @workgroup_size(16, 16) fn butterflyRows(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let idx = gid.x; if (row >= params.height || idx >= params.width / 2u) { return; } let stage = params.stage; let halfSize = 1u << stage; let fullSize = halfSize << 1u; let group = idx / halfSize; let pos = idx % halfSize; let col_i = group * fullSize + pos; let col_j = col_i + halfSize; if (col_j >= params.width) { return; } let w = twiddle(pos, fullSize, params.inverse); let i = getIndex(row, col_i); let j = getIndex(row, col_j); let u = data[i]; let t = cmul(w, data[j]); data[i] = u + t; data[j] = u - t; } +@compute @workgroup_size(16, 16) fn butterflyCols(@builtin(global_invocation_id) gid: vec3) { let col = gid.x; let idx = gid.y; if (col >= params.width || idx >= params.height / 2u) { return; } let stage = params.stage; let halfSize = 1u << stage; let fullSize = halfSize << 1u; let group = idx / halfSize; let pos = idx % halfSize; let row_i = group * fullSize + pos; let row_j = row_i + halfSize; if (row_j >= params.height) { return; } let w = twiddle(pos, fullSize, params.inverse); let i = getIndex(row_i, col); let j = getIndex(row_j, col); let u = data[i]; let t = cmul(w, data[j]); data[i] = u + t; data[j] = u - t; } +@compute @workgroup_size(16, 16) fn normalize2D(@builtin(global_invocation_id) gid: vec3) { let row = gid.y; let col = gid.x; if (row >= params.height || col >= params.width) { return; } let idx = getIndex(row, col); let scale = 1.0 / f32(params.width * params.height); data[idx] = data[idx] * scale; }`; + +export class WebGPUFFT { + private device: GPUDevice; + private pipelines2D: { bitReverseRows: GPUComputePipeline; bitReverseCols: GPUComputePipeline; butterflyRows: GPUComputePipeline; butterflyCols: GPUComputePipeline; normalize: GPUComputePipeline } | null = null; + private initialized = false; + constructor(device: GPUDevice) { this.device = device; } + async init(): Promise { + if (this.initialized) return; + const module2D = this.device.createShaderModule({ code: FFT_2D_SHADER }); + this.pipelines2D = { + bitReverseRows: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'bitReverseRows' } }), + bitReverseCols: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'bitReverseCols' } }), + butterflyRows: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'butterflyRows' } }), + butterflyCols: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'butterflyCols' } }), + normalize: this.device.createComputePipeline({ layout: 'auto', compute: { module: module2D, entryPoint: 'normalize2D' } }) + }; + this.initialized = true; + } + async fft2D(realData: Float32Array, imagData: Float32Array, width: number, height: number, inverse: boolean = false): Promise<{ real: Float32Array, imag: Float32Array }> { + await this.init(); + const paddedWidth = nextPow2(width), paddedHeight = nextPow2(height); + const needsPadding = paddedWidth !== width || paddedHeight !== height; + const log2Width = Math.log2(paddedWidth), log2Height = Math.log2(paddedHeight); + const paddedSize = paddedWidth * paddedHeight, originalSize = width * height; + let workReal: Float32Array, workImag: Float32Array; + if (needsPadding) { + workReal = new Float32Array(paddedSize); workImag = new Float32Array(paddedSize); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { workReal[y * paddedWidth + x] = realData[y * width + x]; workImag[y * paddedWidth + x] = imagData[y * width + x]; } + } else { workReal = realData; workImag = imagData; } + const complexData = new Float32Array(paddedSize * 2); + for (let i = 0; i < paddedSize; i++) { complexData[i * 2] = workReal[i]; complexData[i * 2 + 1] = workImag[i]; } + const dataBuffer = this.device.createBuffer({ size: complexData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + this.device.queue.writeBuffer(dataBuffer, 0, complexData); + const paramsBuffer = this.device.createBuffer({ size: 24, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + const readBuffer = this.device.createBuffer({ size: complexData.byteLength, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + const inverseVal = inverse ? 1.0 : -1.0; + const workgroupsX = Math.ceil(paddedWidth / 16), workgroupsY = Math.ceil(paddedHeight / 16); + const runPass = (pipeline: GPUComputePipeline) => { + const bindGroup = this.device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: paramsBuffer } }, { binding: 1, resource: { buffer: dataBuffer } }] }); + const encoder = this.device.createCommandEncoder(); const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(workgroupsX, workgroupsY); pass.end(); + this.device.queue.submit([encoder.finish()]); + }; + const params = new ArrayBuffer(24); const paramsU32 = new Uint32Array(params); const paramsF32 = new Float32Array(params); + paramsU32[0] = paddedWidth; paramsU32[1] = paddedHeight; paramsU32[2] = log2Width; paramsU32[3] = 0; paramsF32[4] = inverseVal; paramsU32[5] = 1; + this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseRows); + for (let stage = 0; stage < log2Width; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyRows); } + paramsU32[2] = log2Height; paramsU32[3] = 0; paramsU32[5] = 0; + this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseCols); + for (let stage = 0; stage < log2Height; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyCols); } + if (inverse) runPass(this.pipelines2D!.normalize); + const encoder = this.device.createCommandEncoder(); encoder.copyBufferToBuffer(dataBuffer, 0, readBuffer, 0, complexData.byteLength); + this.device.queue.submit([encoder.finish()]); await readBuffer.mapAsync(GPUMapMode.READ); + const result = new Float32Array(readBuffer.getMappedRange().slice(0)); readBuffer.unmap(); + dataBuffer.destroy(); paramsBuffer.destroy(); readBuffer.destroy(); + if (needsPadding) { + const realResult = new Float32Array(originalSize), imagResult = new Float32Array(originalSize); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { realResult[y * width + x] = result[(y * paddedWidth + x) * 2]; imagResult[y * width + x] = result[(y * paddedWidth + x) * 2 + 1]; } + return { real: realResult, imag: imagResult }; + } + const realResult = new Float32Array(paddedSize), imagResult = new Float32Array(paddedSize); + for (let i = 0; i < paddedSize; i++) { realResult[i] = result[i * 2]; imagResult[i] = result[i * 2 + 1]; } + return { real: realResult, imag: imagResult }; + } + /** + * Batched 2D FFT: compute N forward FFTs with pipelined GPU submissions. + * All images must have the same dimensions. Each image gets its own + * submit (required because the params uniform changes per-pass), but + * all readbacks are batched into a single Promise.all at the end. + */ + async fft2DBatch( + images: { real: Float32Array; imag: Float32Array }[], + width: number, height: number, + ): Promise<{ real: Float32Array; imag: Float32Array }[]> { + await this.init(); + const n = images.length; + if (n === 0) return []; + const paddedWidth = nextPow2(width), paddedHeight = nextPow2(height); + const needsPadding = paddedWidth !== width || paddedHeight !== height; + const log2Width = Math.log2(paddedWidth), log2Height = Math.log2(paddedHeight); + const paddedSize = paddedWidth * paddedHeight; + const originalSize = width * height; + const byteSize = paddedSize * 2 * 4; + const workgroupsX = Math.ceil(paddedWidth / 16), workgroupsY = Math.ceil(paddedHeight / 16); + const inverseVal = -1.0; + + // Shared params buffer — safe because we submit per-image + const paramsBuffer = this.device.createBuffer({ size: 24, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + + const readBuffers: GPUBuffer[] = []; + const dataBuffers: GPUBuffer[] = []; + + // Submit all FFTs — GPU pipelines them internally + for (let i = 0; i < n; i++) { + const { real: realData, imag: imagData } = images[i]; + let workReal: Float32Array, workImag: Float32Array; + if (needsPadding) { + workReal = new Float32Array(paddedSize); workImag = new Float32Array(paddedSize); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + workReal[y * paddedWidth + x] = realData[y * width + x]; + workImag[y * paddedWidth + x] = imagData[y * width + x]; + } + } else { workReal = realData; workImag = imagData; } + + const complexData = new Float32Array(paddedSize * 2); + for (let j = 0; j < paddedSize; j++) { complexData[j * 2] = workReal[j]; complexData[j * 2 + 1] = workImag[j]; } + + const dataBuffer = this.device.createBuffer({ size: byteSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + this.device.queue.writeBuffer(dataBuffer, 0, complexData); + dataBuffers.push(dataBuffer); + + const readBuffer = this.device.createBuffer({ size: byteSize, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + readBuffers.push(readBuffer); + + // Run FFT passes — each runPass does writeBuffer+submit atomically + const runPass = (pipeline: GPUComputePipeline) => { + const bindGroup = this.device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: paramsBuffer } }, { binding: 1, resource: { buffer: dataBuffer } }], + }); + const enc = this.device.createCommandEncoder(); + const pass = enc.beginComputePass(); + pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(workgroupsX, workgroupsY); pass.end(); + this.device.queue.submit([enc.finish()]); + }; + + const params = new ArrayBuffer(24); + const paramsU32 = new Uint32Array(params); + const paramsF32 = new Float32Array(params); + + paramsU32[0] = paddedWidth; paramsU32[1] = paddedHeight; paramsU32[2] = log2Width; + paramsU32[3] = 0; paramsF32[4] = inverseVal; paramsU32[5] = 1; + this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseRows); + for (let stage = 0; stage < log2Width; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyRows); } + + paramsU32[2] = log2Height; paramsU32[3] = 0; paramsU32[5] = 0; + this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.bitReverseCols); + for (let stage = 0; stage < log2Height; stage++) { paramsU32[3] = stage; this.device.queue.writeBuffer(paramsBuffer, 0, params); runPass(this.pipelines2D!.butterflyCols); } + + // Copy to read buffer + const copyEnc = this.device.createCommandEncoder(); + copyEnc.copyBufferToBuffer(dataBuffer, 0, readBuffer, 0, byteSize); + this.device.queue.submit([copyEnc.finish()]); + } + + // Batched readback — one sync point for all images + await Promise.all(readBuffers.map(buf => buf.mapAsync(GPUMapMode.READ))); + + const results: { real: Float32Array; imag: Float32Array }[] = []; + for (let i = 0; i < n; i++) { + const result = new Float32Array(readBuffers[i].getMappedRange().slice(0)); + readBuffers[i].unmap(); + dataBuffers[i].destroy(); + readBuffers[i].destroy(); + + if (needsPadding) { + const realResult = new Float32Array(originalSize), imagResult = new Float32Array(originalSize); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + realResult[y * width + x] = result[(y * paddedWidth + x) * 2]; + imagResult[y * width + x] = result[(y * paddedWidth + x) * 2 + 1]; + } + results.push({ real: realResult, imag: imagResult }); + } else { + const realResult = new Float32Array(paddedSize), imagResult = new Float32Array(paddedSize); + for (let i2 = 0; i2 < paddedSize; i2++) { realResult[i2] = result[i2 * 2]; imagResult[i2] = result[i2 * 2 + 1]; } + results.push({ real: realResult, imag: imagResult }); + } + } + + paramsBuffer.destroy(); + return results; + } + + destroy(): void { this.initialized = false; } +} + +// ============================================================================ +// FFT pre-processing helpers +// ============================================================================ + +/** + * Apply 2D Hann window in-place to reduce spectral leakage in ROI FFT. + * + * When an ROI is cropped from an image, the sharp rectangular boundary acts as + * a rect window whose sinc sidelobes produce streak artifacts in the FFT, + * obscuring real spectral features (Bragg spots, lattice frequencies). + * The Hann window smoothly tapers data to zero at all edges, suppressing + * sidelobes by ~31 dB at the cost of a slightly wider main lobe. + * + * Separable: window2D = outer(hann_h, hann_w), applied as element-wise multiply. + * Symmetric formula: w(i) = 0.5*(1 - cos(2πi/(N-1))), matching np.hanning — + * both endpoints are exactly zero for seamless transition to zero-padded regions. + * (Periodic variant ÷N is for overlapping STFT windows, not for zero-padding.) + * + * IMPORTANT: Must be called on the crop at its native dimensions BEFORE + * zero-padding to power-of-2. Window-then-pad ensures no discontinuity at the + * crop/pad boundary. Pad-then-window applies the wrong taper and reintroduces + * leakage. Validated against np.hanning in test_widget_show2d.py. + */ +export function applyHannWindow2D(data: Float32Array, width: number, height: number): void { + const hannW = new Float32Array(width); + const hannH = new Float32Array(height); + const wDenom = width > 1 ? width - 1 : 1; + const hDenom = height > 1 ? height - 1 : 1; + for (let i = 0; i < width; i++) hannW[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / wDenom)); + for (let i = 0; i < height; i++) hannH[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / hDenom)); + for (let r = 0; r < height; r++) { + const hr = hannH[r]; + const offset = r * width; + for (let c = 0; c < width; c++) data[offset + c] *= hr * hannW[c]; + } +} + +// ============================================================================ +// FFT post-processing helpers +// ============================================================================ + +/** Compute magnitude from complex FFT output: sqrt(real² + imag²). */ +export function computeMagnitude(real: Float32Array, imag: Float32Array): Float32Array { + const mag = new Float32Array(real.length); + for (let i = 0; i < mag.length; i++) { + mag[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]); + } + return mag; +} + +/** Mask DC component (center pixel) and return 99.9% percentile-clipped range. Mutates `mag`. */ +export function autoEnhanceFFT( + mag: Float32Array, width: number, height: number, +): { min: number; max: number } { + const centerIdx = Math.floor(height / 2) * width + Math.floor(width / 2); + const neighbors = [ + mag[Math.max(0, centerIdx - 1)], + mag[Math.min(mag.length - 1, centerIdx + 1)], + mag[Math.max(0, centerIdx - width)], + mag[Math.min(mag.length - 1, centerIdx + width)], + ]; + mag[centerIdx] = neighbors.reduce((a, b) => a + b, 0) / 4; + // Use O(n) histogram approach instead of O(n log n) sort + const len = mag.length; + if (len === 0) return { min: 0, max: 0 }; + let dMin = Infinity, dMax = -Infinity; + for (let i = 0; i < len; i++) { + const v = mag[i]; + if (v < dMin) dMin = v; + if (v > dMax) dMax = v; + } + if (dMin === dMax) return { min: dMin, max: dMax }; + const NUM_BINS = 1024; + const bins = new Uint32Array(NUM_BINS); + const range = dMax - dMin; + const scale = (NUM_BINS - 1) / range; + for (let i = 0; i < len; i++) bins[Math.floor((mag[i] - dMin) * scale)]++; + // Find 99.9th percentile + const target = Math.ceil(len * 0.999); + let cumSum = 0; + let pMax = dMax; + for (let i = 0; i < NUM_BINS; i++) { + cumSum += bins[i]; + if (cumSum >= target) { pMax = dMin + (i / (NUM_BINS - 1)) * range; break; } + } + // If percentile collapsed to min (sparse spectra), fall back to actual max + if (pMax <= dMin) pMax = dMax; + return { min: dMin, max: pMax }; +} + +// ============================================================================ +// Singleton +// ============================================================================ + +let gpuFFT: WebGPUFFT | null = null; +let gpuDevice: GPUDevice | null = null; +let gpuInfo = "GPU"; + +export async function getGPUDevice(): Promise { + if (gpuDevice) return gpuDevice; + if (!navigator.gpu) return null; + try { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) return null; + try { + // @ts-ignore - requestAdapterInfo is not yet in all type definitions + const info = await adapter.requestAdapterInfo?.(); + if (info) { + gpuInfo = info.description || `${info.vendor} ${info.architecture || ""} ${info.device || ""}`.trim() || "Generic WebGPU Adapter"; + } + } catch (_e) { /* adapter info not available */ } + gpuDevice = await adapter.requestDevice(); + return gpuDevice; + } catch { return null; } +} + +export async function getWebGPUFFT(): Promise { + if (gpuFFT) return gpuFFT; + const device = await getGPUDevice(); + if (!device) { console.warn('WebGPU not supported, falling back to CPU FFT'); return null; } + try { + gpuFFT = new WebGPUFFT(device); + await gpuFFT.init(); + return gpuFFT; + } catch (e) { console.warn('WebGPU init failed:', e); return null; } +} + +export function getGPUInfo(): string { return gpuInfo; } diff --git a/widget/js/figure.ts b/widget/js/figure.ts new file mode 100644 index 00000000..b1fd3f2f --- /dev/null +++ b/widget/js/figure.ts @@ -0,0 +1,433 @@ +/** + * Shared scale bar, colorbar, and overlay utilities for all canvas-based widgets. + * Provides HiDPI-aware rendering with automatic unit conversion. + */ + +import { formatNumber } from "./format"; + +/** Round a physical value to a "nice" number (1, 2, 5, 10, 20, 50, ...) */ +export function roundToNiceValue(value: number): number { + if (value <= 0) return 1; + const magnitude = Math.pow(10, Math.floor(Math.log10(value))); + const normalized = value / magnitude; + if (normalized < 1.5) return magnitude; + if (normalized < 3.5) return 2 * magnitude; + if (normalized < 7.5) return 5 * magnitude; + return 10 * magnitude; +} + +/** Format scale bar label. Unit string is displayed verbatim - no conversion. */ +export function formatScaleLabel(value: number, unit: string): string { + const nice = roundToNiceValue(value); + return nice >= 1 ? `${Math.round(nice)} ${unit}` : `${nice.toFixed(2)} ${unit}`; +} + +const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + +/** + * Draw scale bar and zoom indicator on a high-DPI UI canvas. + * Renders crisp text/lines independent of the image resolution. + */ +export function drawScaleBarHiDPI( + canvas: HTMLCanvasElement, + dpr: number, + zoom: number, + pixelSize: number, + unit: string, + imageWidth: number, +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + const scaleX = cssWidth / imageWidth; + const effectiveZoom = zoom * scaleX; + + const targetBarPx = 60; + const barThickness = 5; + const fontSize = 16; + const margin = 12; + + const targetPhysical = (targetBarPx / effectiveZoom) * pixelSize; + const nicePhysical = roundToNiceValue(targetPhysical); + const barPx = (nicePhysical / pixelSize) * effectiveZoom; + + const barY = cssHeight - margin; + const barX = cssWidth - barPx - margin; + + ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + ctx.fillStyle = "white"; + ctx.fillRect(barX, barY, barPx, barThickness); + + const label = formatScaleLabel(nicePhysical, unit); + ctx.font = `${fontSize}px ${FONT}`; + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, barX + barPx / 2, barY - 4); + + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(`${zoom.toFixed(1)}×`, margin, cssHeight - margin + barThickness); + + ctx.restore(); +} + +/** + * Draw reciprocal-space scale bar on an FFT overlay canvas. + * Only draws when fftPixelSize > 0 (i.e. real-space calibration is available). + */ +export function drawFFTScaleBarHiDPI( + canvas: HTMLCanvasElement, + dpr: number, + fftZoom: number, + fftPixelSize: number, + imageWidth: number, + unit: string = "1/px", +) { + const ctx = canvas.getContext("2d"); + if (!ctx || fftPixelSize <= 0) return; + + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + const scaleX = cssWidth / imageWidth; + const effectiveZoom = fftZoom * scaleX; + + const targetBarPx = 60; + const barThickness = 5; + const fontSize = 16; + const margin = 12; + + const targetPhysical = (targetBarPx / effectiveZoom) * fftPixelSize; + const nicePhysical = roundToNiceValue(targetPhysical); + const barPx = (nicePhysical / fftPixelSize) * effectiveZoom; + + const barY = cssHeight - margin; + const barX = cssWidth - barPx - margin; + + ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + ctx.fillStyle = "white"; + ctx.fillRect(barX, barY, barPx, barThickness); + + const label = formatScaleLabel(nicePhysical, unit); + ctx.font = `${fontSize}px ${FONT}`; + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, barX + barPx / 2, barY - 4); + + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(`${fftZoom.toFixed(1)}×`, margin, cssHeight - margin + barThickness); + + ctx.restore(); +} + +/** + * Draw a vertical colorbar on a canvas context (already DPR-scaled by caller). + * Gradient strip on right edge with vmin/vmax labels and optional log indicator. + */ +export function drawColorbar( + ctx: CanvasRenderingContext2D, + cssW: number, + cssH: number, + lut: Uint8Array, + vmin: number, + vmax: number, + logScale: boolean, +) { + const barW = 12; + const barH = Math.round(cssH * 0.6); + const barX = cssW - barW - 12; + const barY = Math.round((cssH - barH) / 2); + + // Gradient strip (bottom=vmin, top=vmax) + for (let row = 0; row < barH; row++) { + const t = 1 - row / (barH - 1); + const lutIdx = Math.round(t * 255); + const r = lut[lutIdx * 3]; + const g = lut[lutIdx * 3 + 1]; + const b = lut[lutIdx * 3 + 2]; + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(barX, barY + row, barW, 1); + } + + // Border + ctx.strokeStyle = "rgba(255,255,255,0.5)"; + ctx.lineWidth = 1; + ctx.strokeRect(barX, barY, barW, barH); + + // Labels with drop shadow + ctx.shadowColor = "rgba(0, 0, 0, 0.7)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + ctx.font = `11px ${FONT}`; + ctx.fillStyle = "white"; + ctx.textAlign = "right"; + ctx.textBaseline = "bottom"; + ctx.fillText(formatNumber(vmax), barX - 4, barY + 6); + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(vmin), barX - 4, barY + barH - 4); + if (logScale) { + ctx.textBaseline = "middle"; + ctx.fillText("log", barX - 4, barY + barH / 2); + } +} + +// ============================================================================ +// Publication-quality figure export +// ============================================================================ + +export interface ExportFigureOptions { + /** Colormapped image canvas at native resolution (no zoom/pan). */ + imageCanvas: HTMLCanvasElement; + /** Figure title drawn above the image. */ + title?: string; + /** Colormap LUT (256 × 3 bytes) for the colorbar. */ + lut?: Uint8Array; + /** Data range for colorbar labels. */ + vmin?: number; + vmax?: number; + logScale?: boolean; + /** Pixel size in user-supplied unit (for scale bar computation). */ + pixelSize?: number; + /** Unit string for the scale bar label (e.g. "A", "nm", "mrad"). */ + pixelUnit?: string; + showColorbar?: boolean; + showScaleBar?: boolean; + /** Upscale factor for high-resolution output (default 4). Image pixels use nearest-neighbor for sharp edges. */ + scale?: number; + /** Callback to draw annotations (ROI, profile, markers) on the image. ctx is pre-translated to image origin and scaled. */ + drawAnnotations?: (ctx: CanvasRenderingContext2D) => void; +} + +/** + * Create a publication-quality figure canvas with title, scale bar, colorbar, + * and baked-in annotations. Returns an HTMLCanvasElement — caller can toBlob() + download. + */ +export function exportFigure(options: ExportFigureOptions): HTMLCanvasElement { + const { + imageCanvas, + title, + lut, + vmin = 0, + vmax = 1, + logScale = false, + pixelSize = 0, + pixelUnit = "pixels", + showColorbar = true, + showScaleBar = true, + scale: s = 4, + drawAnnotations, + } = options; + + const imgW = imageCanvas.width; + const imgH = imageCanvas.height; + + // Layout (in logical coordinates — scaled to canvas pixels by ctx.scale) + const pad = 20; + const titleH = title ? 28 : 0; + const titleGap = title ? 8 : 0; + const hasCb = showColorbar && lut && vmin !== vmax; + const cbWidth = hasCb ? 20 : 0; + const cbGap = hasCb ? 12 : 0; + const cbLabelW = hasCb ? 60 : 0; + + const totalW = pad + imgW + cbGap + cbWidth + cbLabelW + pad; + const totalH = pad + titleH + titleGap + imgH + pad; + + const canvas = document.createElement("canvas"); + canvas.width = totalW * s; + canvas.height = totalH * s; + const ctx = canvas.getContext("2d")!; + + // Scale all drawing operations + ctx.scale(s, s); + + // White background + ctx.fillStyle = "white"; + ctx.fillRect(0, 0, totalW, totalH); + + // Title + if (title) { + ctx.fillStyle = "black"; + ctx.font = `bold 18px ${FONT}`; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(title, pad, pad); + } + + const imgX = pad; + const imgY = pad + titleH + titleGap; + + // Image (nearest-neighbor for sharp pixels) + ctx.imageSmoothingEnabled = false; + ctx.drawImage(imageCanvas, imgX, imgY, imgW, imgH); + ctx.imageSmoothingEnabled = true; + + // Annotations + if (drawAnnotations) { + ctx.save(); + ctx.translate(imgX, imgY); + drawAnnotations(ctx); + ctx.restore(); + } + + // Scale bar (white with drop shadow, positioned at bottom-right of image) + if (showScaleBar && pixelSize > 0) { + const targetBarPx = Math.max(60, imgW * 0.15); + const barThickness = Math.max(4, Math.round(imgH * 0.012)); + const fontSize = Math.max(14, Math.round(imgH * 0.04)); + const margin = Math.max(12, Math.round(imgW * 0.03)); + + const targetPhysical = targetBarPx * pixelSize; + const nicePhysical = roundToNiceValue(targetPhysical); + const barPx = nicePhysical / pixelSize; + + const barY = imgY + imgH - margin; + const barX = imgX + imgW - barPx - margin; + + ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + ctx.fillStyle = "white"; + ctx.fillRect(barX, barY, barPx, barThickness); + + const label = formatScaleLabel(nicePhysical, pixelUnit); + ctx.font = `bold ${fontSize}px ${FONT}`; + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, barX + barPx / 2, barY - 4); + + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + } + + // Colorbar (vertical gradient strip to the right of image) + if (hasCb && lut) { + const cbX = imgX + imgW + cbGap; + const cbY = imgY; + const cbH = imgH; + + for (let row = 0; row < cbH; row++) { + const t = 1 - row / (cbH - 1); + const lutIdx = Math.round(t * 255); + const r = lut[lutIdx * 3]; + const g = lut[lutIdx * 3 + 1]; + const b = lut[lutIdx * 3 + 2]; + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(cbX, cbY + row, cbWidth, 1); + } + + ctx.strokeStyle = "black"; + ctx.lineWidth = 1; + ctx.strokeRect(cbX, cbY, cbWidth, cbH); + + ctx.fillStyle = "black"; + ctx.font = `12px ${FONT}`; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(vmax), cbX + cbWidth + 4, cbY); + ctx.textBaseline = "bottom"; + ctx.fillText(formatNumber(vmin), cbX + cbWidth + 4, cbY + cbH); + if (logScale) { + ctx.textBaseline = "middle"; + ctx.fillText("log", cbX + cbWidth + 4, cbY + cbH / 2); + } + } + + return canvas; +} + +/** + * Convert a canvas to a PDF blob by embedding JPEG data in a minimal PDF. + * Zero external dependencies — uses the DCTDecode filter (native JPEG in PDF). + */ +export async function canvasToPDF(canvas: HTMLCanvasElement, quality = 0.95): Promise { + const jpegBlob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b!), "image/jpeg", quality)); + const jpegBytes = new Uint8Array(await jpegBlob.arrayBuffer()); + const w = canvas.width; + const h = canvas.height; + + // Build PDF objects + const contentStream = `q ${w} 0 0 ${h} 0 0 cm /I0 Do Q`; + const objects: string[] = []; + const offsets: number[] = []; + + // Helper to track object positions + let pdf = "%PDF-1.4\n"; + + // Object 1: Catalog + offsets.push(pdf.length); + objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + pdf += objects[0]; + + // Object 2: Pages + offsets.push(pdf.length); + objects.push("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + pdf += objects[1]; + + // Object 3: Page + offsets.push(pdf.length); + objects.push(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Contents 4 0 R /Resources << /XObject << /I0 5 0 R >> >> >>\nendobj\n`); + pdf += objects[2]; + + // Object 4: Content stream + offsets.push(pdf.length); + objects.push(`4 0 obj\n<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream\nendobj\n`); + pdf += objects[3]; + + // Object 5: Image (JPEG) — build as binary + const imgHeader = `5 0 obj\n<< /Type /XObject /Subtype /Image /Width ${w} /Height ${h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpegBytes.length} >>\nstream\n`; + const imgFooter = "\nendstream\nendobj\n"; + + // Convert text part to bytes + const encoder = new TextEncoder(); + const headerBytes = encoder.encode(pdf + imgHeader); + const footerBytes = encoder.encode(imgFooter); + + // Build xref + const imgOffset = pdf.length; + offsets.push(imgOffset); + const afterImage = headerBytes.length + jpegBytes.length + footerBytes.length; + + const xrefOffset = afterImage; + let xref = `xref\n0 6\n0000000000 65535 f \n`; + for (let i = 0; i < offsets.length; i++) { + xref += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; + } + xref += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + const xrefBytes = encoder.encode(xref); + + // Combine all parts + const result = new Uint8Array(headerBytes.length + jpegBytes.length + footerBytes.length + xrefBytes.length); + result.set(headerBytes, 0); + result.set(jpegBytes, headerBytes.length); + result.set(footerBytes, headerBytes.length + jpegBytes.length); + result.set(xrefBytes, headerBytes.length + jpegBytes.length + footerBytes.length); + + return new Blob([result], { type: "application/pdf" }); +} diff --git a/widget/js/format.ts b/widget/js/format.ts new file mode 100644 index 00000000..31f2c4ca --- /dev/null +++ b/widget/js/format.ts @@ -0,0 +1,40 @@ +/** Convert anywidget DataView/ArrayBuffer to Uint8Array. */ +export function extractBytes(dataView: DataView | ArrayBuffer | Uint8Array): Uint8Array { + if (dataView instanceof Uint8Array) return dataView; + if (dataView instanceof ArrayBuffer) return new Uint8Array(dataView); + if (dataView && "buffer" in dataView) { + return new Uint8Array(dataView.buffer, dataView.byteOffset, dataView.byteLength); + } + return new Uint8Array(0); +} + +/** Extract Float32Array from anywidget DataView. Returns null if empty. */ +export function extractFloat32(dataView: DataView | ArrayBuffer | Uint8Array): Float32Array | null { + const bytes = extractBytes(dataView); + if (bytes.length === 0) return null; + return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4); +} + +/** Download a Blob as a file. */ +export function downloadBlob(blob: Blob, filename: string): void { + const link = document.createElement("a"); + link.download = filename; + const url = URL.createObjectURL(blob); + link.href = url; + link.click(); + // Defer revocation to ensure browser has time to start the download + setTimeout(() => URL.revokeObjectURL(url), 60000); +} + +/** Download a DataView as a file (e.g. GIF/ZIP from Python). */ +export function downloadDataView(dataView: DataView, filename: string, mimeType: string): void { + const buf = new Uint8Array(dataView.buffer as ArrayBuffer, dataView.byteOffset, dataView.byteLength); + downloadBlob(new Blob([buf as BlobPart], { type: mimeType }), filename); +} + +/** Format number with exponential notation for large/small values. */ +export function formatNumber(val: number, decimals: number = 2): string { + if (val === 0) return "0"; + if (Math.abs(val) >= 1000 || Math.abs(val) < 0.01) return val.toExponential(decimals); + return val.toFixed(decimals); +} diff --git a/widget/js/index.jsx b/widget/js/index.jsx deleted file mode 100644 index a3341f63..00000000 --- a/widget/js/index.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from "react"; -import * as ReactDOM from "react-dom/client"; - -function Widget({ model }) { - const [count, setCount] = React.useState(model.get("count")); - - React.useEffect(() => { - const onChange = () => setCount(model.get("count")); - model.on("change:count", onChange); - return () => model.off("change:count", onChange); - }, [model]); - - const handleClick = () => { - model.set("count", count + 1); - model.save_changes(); - }; - - return ( -
-

quantem.widget

-

Count: {count}

- -
- ); -} - -function render({ model, el }) { - const root = ReactDOM.createRoot(el); - root.render(); - return () => root.unmount(); -} - -export default { render }; diff --git a/widget/js/show2d/index.tsx b/widget/js/show2d/index.tsx new file mode 100644 index 00000000..be4a60e9 --- /dev/null +++ b/widget/js/show2d/index.tsx @@ -0,0 +1,4268 @@ +/** + * Show2D - Static 2D image viewer with gallery support. + * + * Features: + * - Single image or gallery mode with configurable columns + * - Scroll to zoom, double-click to reset + * - WebGPU-accelerated FFT with default 3x zoom + * - Equal-sized FFT and histogram panels + * - Click to select image in gallery mode + */ + +import * as React from "react"; +import { createRender, useModelState } from "@anywidget/react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Menu from "@mui/material/Menu"; +import Switch from "@mui/material/Switch"; +import Slider from "@mui/material/Slider"; +import Button from "@mui/material/Button"; +import Tooltip from "@mui/material/Tooltip"; +import { useTheme } from "../theme"; +import { drawScaleBarHiDPI, drawColorbar, roundToNiceValue, exportFigure, canvasToPDF } from "../figure"; +import JSZip from "jszip"; +import { extractFloat32, formatNumber, downloadBlob } from "../format"; +import { computeHistogramFromBytes, findDataRange, applyLogScale, percentileClip, sliderRange, computeStats } from "../stats"; + +function InfoTooltip({ text, theme = "dark" }: { text: React.ReactNode; theme?: "light" | "dark" }) { + const isDark = theme === "dark"; + const content = typeof text === "string" + ? {text} + : text; + return ( + + + + ); +} + +function KeyboardShortcuts({ items }: { items: [string, string][] }) { + return ( + + + {items.map(([key, desc], i) => ( + {key}{desc} + ))} + + + ); +} + +const upwardMenuProps = { + anchorOrigin: { vertical: "top" as const, horizontal: "left" as const }, + transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, + sx: { zIndex: 9999 }, +}; +import { getWebGPUFFT, WebGPUFFT, fft2d, fft2dAsync, fftshift, computeMagnitude, autoEnhanceFFT, nextPow2, applyHannWindow2D, getGPUInfo } from "../fft"; +import { COLORMAPS, COLORMAP_NAMES, renderToOffscreen, renderToOffscreenReuse, GPUColormapEngine, getGPUColormapEngine, getGPUMaxBufferSize } from "../colormaps"; + +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 20; + +const DPR = window.devicePixelRatio || 1; + +interface HistogramProps { + data: Float32Array | null; + precomputedBins?: number[] | null; // GPU-computed bins bypass computeHistogramFromBytes + vminPct: number; + vmaxPct: number; + onRangeChange: (min: number, max: number) => void; + width?: number; + height?: number; + theme?: "light" | "dark"; + dataMin?: number; + dataMax?: number; +} + +function Histogram({ data, precomputedBins, vminPct, vmaxPct, onRangeChange, width = 110, height = 40, theme = "dark", dataMin = 0, dataMax = 1 }: HistogramProps) { + const canvasRef = React.useRef(null); + const cpuBins = React.useMemo(() => precomputedBins ? null : computeHistogramFromBytes(data), [data, precomputedBins]); + const bins = precomputedBins || cpuBins || new Array(256).fill(0); + const isDark = theme === "dark"; + const colors = isDark ? { bg: "#1a1a1a", barActive: "#888", barInactive: "#444", border: "#333" } : { bg: "#f0f0f0", barActive: "#666", barInactive: "#bbb", border: "#ccc" }; + + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + ctx.fillStyle = colors.bg; + ctx.fillRect(0, 0, width, height); + const displayBins = 64; + const binRatio = Math.floor(bins.length / displayBins); + const reducedBins: number[] = []; + for (let i = 0; i < displayBins; i++) { + let sum = 0; + for (let j = 0; j < binRatio; j++) sum += bins[i * binRatio + j] || 0; + reducedBins.push(sum / binRatio); + } + const maxVal = Math.max(...reducedBins, 0.001); + const barWidth = width / displayBins; + const vminBin = Math.floor((vminPct / 100) * displayBins); + const vmaxBin = Math.floor((vmaxPct / 100) * displayBins); + for (let i = 0; i < displayBins; i++) { + const barHeight = (reducedBins[i] / maxVal) * (height - 2); + ctx.fillStyle = (i >= vminBin && i <= vmaxBin) ? colors.barActive : colors.barInactive; + ctx.fillRect(i * barWidth + 0.5, height - barHeight, Math.max(1, barWidth - 1), barHeight); + } + }, [bins, vminPct, vmaxPct, width, height, colors]); + + return ( + + + { const [newMin, newMax] = v as number[]; onRangeChange(Math.min(newMin, newMax - 1), Math.max(newMax, newMin + 1)); }} + min={0} max={100} size="small" valueLabelDisplay="auto" + valueLabelFormat={(pct) => { const val = dataMin + (pct / 100) * (dataMax - dataMin); return val >= 1000 ? val.toExponential(1) : val.toFixed(1); }} + sx={{ width, py: 0, "& .MuiSlider-thumb": { width: 8, height: 8 }, "& .MuiSlider-rail": { height: 2 }, "& .MuiSlider-track": { height: 2 }, "& .MuiSlider-valueLabel": { fontSize: 10, padding: "2px 4px" } }} + /> + {(() => { const v = dataMin + (vminPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()}{(() => { const v = dataMin + (vmaxPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()} + + ); +} + +// ============================================================================ +// Line profile sampling (bilinear interpolation along line) +// ============================================================================ +function sampleLineProfile(data: Float32Array, w: number, h: number, row0: number, col0: number, row1: number, col1: number): Float32Array { + const dc = col1 - col0; + const dr = row1 - row0; + const len = Math.sqrt(dc * dc + dr * dr); + const n = Math.max(2, Math.ceil(len)); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const c = col0 + t * dc; + const r = row0 + t * dr; + const ci = Math.floor(c), ri = Math.floor(r); + const cf = c - ci, rf = r - ri; + const c0c = Math.max(0, Math.min(w - 1, ci)); + const c1c = Math.max(0, Math.min(w - 1, ci + 1)); + const r0c = Math.max(0, Math.min(h - 1, ri)); + const r1c = Math.max(0, Math.min(h - 1, ri + 1)); + out[i] = data[r0c * w + c0c] * (1 - cf) * (1 - rf) + + data[r0c * w + c1c] * cf * (1 - rf) + + data[r1c * w + c0c] * (1 - cf) * rf + + data[r1c * w + c1c] * cf * rf; + } + return out; +} + +function pointToSegmentDistance(col: number, row: number, col0: number, row0: number, col1: number, row1: number): number { + const dc = col1 - col0; + const dr = row1 - row0; + const lenSq = dc * dc + dr * dr; + if (lenSq <= 1e-12) return Math.sqrt((col - col0) ** 2 + (row - row0) ** 2); + const tRaw = ((col - col0) * dc + (row - row0) * dr) / lenSq; + const t = Math.max(0, Math.min(1, tRaw)); + const projCol = col0 + t * dc; + const projRow = row0 + t * dr; + return Math.sqrt((col - projCol) ** 2 + (row - projRow) ** 2); +} + +// ============================================================================ +// FFT peak finder (snap to Bragg spot with sub-pixel centroid refinement) +// ============================================================================ +function findFFTPeak(mag: Float32Array, width: number, height: number, col: number, row: number, radius: number): { row: number; col: number } { + // Find brightest pixel in search window + const c0 = Math.max(0, Math.floor(col) - radius); + const r0 = Math.max(0, Math.floor(row) - radius); + const c1 = Math.min(width - 1, Math.floor(col) + radius); + const r1 = Math.min(height - 1, Math.floor(row) + radius); + let bestCol = Math.round(col), bestRow = Math.round(row), bestVal = -Infinity; + for (let ir = r0; ir <= r1; ir++) { + for (let ic = c0; ic <= c1; ic++) { + const val = mag[ir * width + ic]; + if (val > bestVal) { bestVal = val; bestCol = ic; bestRow = ir; } + } + } + // Sub-pixel refinement via weighted centroid in 3×3 window + const wc0 = Math.max(0, bestCol - 1), wc1 = Math.min(width - 1, bestCol + 1); + const wr0 = Math.max(0, bestRow - 1), wr1 = Math.min(height - 1, bestRow + 1); + let sumW = 0, sumWC = 0, sumWR = 0; + for (let ir = wr0; ir <= wr1; ir++) { + for (let ic = wc0; ic <= wc1; ic++) { + const w = mag[ir * width + ic]; + sumW += w; sumWC += w * ic; sumWR += w * ir; + } + } + if (sumW > 0) return { row: sumWR / sumW, col: sumWC / sumW }; + return { row: bestRow, col: bestCol }; +} + +const FFT_SNAP_RADIUS = 5; + +// ============================================================================ +// Types +// ============================================================================ +type ZoomState = { zoom: number; panX: number; panY: number }; + +// ============================================================================ +// Constants +// ============================================================================ +const SINGLE_IMAGE_TARGET = 500; +const GALLERY_IMAGE_TARGET = 300; +const DEFAULT_FFT_ZOOM = 2; +const PROFILE_COLORS = ["#4fc3f7", "#81c784", "#ffb74d", "#ce93d8", "#ef5350", "#ffd54f", "#90a4ae", "#a1887f"]; +type ROIItem = { row: number; col: number; shape: string; radius: number; radius_inner: number; width: number; height: number; color: string; line_width: number; highlight: boolean }; +const ROI_COLORS = ["#4fc3f7", "#81c784", "#ffb74d", "#ce93d8", "#ef5350", "#ffd54f", "#90a4ae", "#a1887f"]; +const RESIZE_HIT_AREA_PX = 10; + +function drawROI( + ctx: CanvasRenderingContext2D, + x: number, y: number, + shape: "circle" | "square" | "rectangle" | "annular", + radius: number, w: number, h: number, + activeColor: string, inactiveColor: string, + active: boolean = false, innerRadius: number = 0 +): void { + const strokeColor = active ? activeColor : inactiveColor; + ctx.strokeStyle = strokeColor; + if (shape === "circle") { + ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); + } else if (shape === "square") { + ctx.strokeRect(x - radius, y - radius, radius * 2, radius * 2); + } else if (shape === "rectangle") { + ctx.strokeRect(x - w / 2, y - h / 2, w, h); + } else if (shape === "annular") { + ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = active ? "#0ff" : inactiveColor; + ctx.beginPath(); ctx.arc(x, y, innerRadius, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = (active ? activeColor : inactiveColor) + "15"; + ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.arc(x, y, innerRadius, 0, Math.PI * 2, true); ctx.fill(); + ctx.strokeStyle = strokeColor; + } + if (active) { + ctx.beginPath(); + ctx.moveTo(x - 5, y); ctx.lineTo(x + 5, y); + ctx.moveTo(x, y - 5); ctx.lineTo(x, y + 5); + ctx.stroke(); + } +} + +// ============================================================================ +// Crop ROI region from raw float32 data for ROI-scoped FFT +// ============================================================================ +function cropROIRegion( + data: Float32Array, imgW: number, imgH: number, + roi: ROIItem, +): { cropped: Float32Array; cropW: number; cropH: number } | null { + const shape = roi.shape || "circle"; + let x0: number, y0: number, x1: number, y1: number; + + if (shape === "rectangle") { + const hw = roi.width / 2; + const hh = roi.height / 2; + x0 = Math.max(0, Math.floor(roi.col - hw)); + y0 = Math.max(0, Math.floor(roi.row - hh)); + x1 = Math.min(imgW, Math.ceil(roi.col + hw)); + y1 = Math.min(imgH, Math.ceil(roi.row + hh)); + } else { + const r = roi.radius; + x0 = Math.max(0, Math.floor(roi.col - r)); + y0 = Math.max(0, Math.floor(roi.row - r)); + x1 = Math.min(imgW, Math.ceil(roi.col + r)); + y1 = Math.min(imgH, Math.ceil(roi.row + r)); + } + + const cropW = x1 - x0; + const cropH = y1 - y0; + if (cropW < 2 || cropH < 2) return null; + + const cropped = new Float32Array(cropW * cropH); + + if (shape === "circle" || shape === "annular") { + const r = roi.radius; + const rSq = r * r; + for (let dy = 0; dy < cropH; dy++) { + for (let dx = 0; dx < cropW; dx++) { + const imgX = x0 + dx; + const imgY = y0 + dy; + const distSq = (imgX - roi.col) * (imgX - roi.col) + (imgY - roi.row) * (imgY - roi.row); + cropped[dy * cropW + dx] = distSq <= rSq ? data[imgY * imgW + imgX] : 0; + } + } + } else { + for (let dy = 0; dy < cropH; dy++) { + const srcOffset = (y0 + dy) * imgW + x0; + cropped.set(data.subarray(srcOffset, srcOffset + cropW), dy * cropW); + } + } + + return { cropped, cropW, cropH }; +} + +// ============================================================================ +// Main Component +// ============================================================================ +// Show4DSTEM-style UI constants +const typography = { + label: { fontSize: 11 }, + labelSmall: { fontSize: 10 }, + value: { fontSize: 10, fontFamily: "monospace" }, +}; +const SPACING = { XS: 4, SM: 8, MD: 12, LG: 16 }; +const controlRow = { + display: "flex", + alignItems: "center", + gap: `${SPACING.SM}px`, + px: 1, + py: 0.5, + width: "fit-content", +}; +const compactButton = { + fontSize: 10, + py: 0.25, + px: 1, + minWidth: 0, + "&.Mui-disabled": { + color: "#666", + borderColor: "#444", + }, +}; +const switchStyles = { + small: { "& .MuiSwitch-thumb": { width: 12, height: 12 }, "& .MuiSwitch-switchBase": { padding: "4px" } }, +}; +const sliderStyles = { + small: { py: 0, "& .MuiSlider-thumb": { width: 10, height: 10 }, "& .MuiSlider-rail": { height: 2 }, "& .MuiSlider-track": { height: 2 } }, +}; + +function Show2D() { + // Theme + const { themeInfo, colors: tc } = useTheme(); + const themeColors = { + ...tc, + accentGreen: themeInfo.theme === "dark" ? "#0f0" : "#1a7a1a", + }; + + const themedSelect = { + fontSize: 10, + bgcolor: themeColors.controlBg, + color: themeColors.text, + "& .MuiSelect-select": { py: 0.5 }, + "& .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.border }, + "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.accent }, + }; + + const themedMenuProps = { + ...upwardMenuProps, + PaperProps: { sx: { bgcolor: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` } }, + }; + + // Model state + const [nImages] = useModelState("n_images"); + const [width] = useModelState("width"); + const [height] = useModelState("height"); + const [frameBytes] = useModelState("frame_bytes"); + const [labels] = useModelState("labels"); + const [title] = useModelState("title"); + const [displayBinFactor] = useModelState("_display_bin_factor"); + const [, setGpuMaxBufferMB] = useModelState("_gpu_max_buffer_mb"); + const [widgetVersion] = useModelState("widget_version"); + const [cmap, setCmap] = useModelState("cmap"); + const [ncols] = useModelState("ncols"); + + // Display options + const [logScale, setLogScale] = useModelState("log_scale"); + const [autoContrast, setAutoContrast] = useModelState("auto_contrast"); + const [traitVmin] = useModelState("vmin"); + const [traitVmax] = useModelState("vmax"); + const [traitVmins] = useModelState<(number | null)[] | null>("vmins"); + const [traitVmaxs] = useModelState<(number | null)[] | null>("vmaxs"); + const [zoomRowTrait] = useModelState("zoom_row"); + const [zoomColTrait] = useModelState("zoom_col"); + const [diffMode, setDiffMode] = useModelState("diff_mode"); + const [diffReference] = useModelState("diff_reference"); + // Align removed — diff = A − B (no shift). Drift correction happens upstream. + const alignDy = 0; + const alignDx = 0; + + // Customization + const [canvasSizeTrait] = useModelState("size"); + const [smooth, setSmooth] = useModelState("smooth"); + const imageRenderingStyle = smooth ? "auto" : "pixelated"; + + // Scale bar + const [pixelSize] = useModelState("pixel_size"); + const [pixelUnit] = useModelState("pixel_unit"); + const [scaleBarVisible] = useModelState("scale_bar_visible"); + + // UI visibility + const [showControls] = useModelState("show_controls"); + const [showStats] = useModelState("show_stats"); + const [statsMean] = useModelState("stats_mean"); + const [statsMin] = useModelState("stats_min"); + const [statsMax] = useModelState("stats_max"); + const [statsStd] = useModelState("stats_std"); + + // Analysis Panels (FFT + Histogram) + const [showFft, setShowFft] = useModelState("show_fft"); + const [fftWindow, setFftWindow] = useModelState("fft_window"); + + // Selection + const [selectedIdx, setSelectedIdx] = useModelState("selected_idx"); + + // ROI + const [roiActive, setRoiActive] = useModelState("roi_active"); + const [roiList, setRoiList] = useModelState("roi_list"); + const [roiSelectedIdx, setRoiSelectedIdx] = useModelState("roi_selected_idx"); + const [imageRotations, setImageRotations] = useModelState("image_rotations"); + const [isDraggingROI, setIsDraggingROI] = React.useState(false); + const [isDraggingResize, setIsDraggingResize] = React.useState(false); + const [isDraggingResizeInner, setIsDraggingResizeInner] = React.useState(false); + const [isHoveringResize, setIsHoveringResize] = React.useState(false); + const [isHoveringResizeInner, setIsHoveringResizeInner] = React.useState(false); + const resizeAspectRef = React.useRef(null); + const [newRoiShape, setNewRoiShape] = React.useState<"circle" | "square" | "rectangle" | "annular">("square"); + const [exportAnchor, setExportAnchor] = React.useState(null); + const selectedRoi = roiSelectedIdx >= 0 && roiSelectedIdx < (roiList?.length ?? 0) ? roiList[roiSelectedIdx] : null; + + const effectiveShowFft = showFft; + + const updateSelectedRoi = (updates: Partial) => { + if (roiSelectedIdx < 0 || !roiList) return; + const newList = [...roiList]; + newList[roiSelectedIdx] = { ...newList[roiSelectedIdx], ...updates }; + setRoiList(newList); + }; + + // Canvas refs + const canvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); + const overlayRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); + const imageContainerRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const fftContainerRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const singleFftContainerRef = React.useRef(null); + const fftCanvasRef = React.useRef(null); + const [canvasReady, setCanvasReady] = React.useState(0); // Trigger re-render when refs attached + + // Zoom/Pan state - per-image when not linked, shared when linked + const [initialZoom] = useModelState("initial_zoom"); + const [linkPan, setLinkPan] = useModelState("link_pan"); + const [imgHeight] = useModelState("height"); + const [imgWidth] = useModelState("width"); + // Note: pan derived from zoom_row/zoom_col is applied via a useEffect AFTER canvasW/canvasH + // are computed (see "Initial pan from zoom_row/zoom_col" effect below). + const initialZoomState: ZoomState = React.useMemo( + () => ({ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, initialZoom || 1)), panX: 0, panY: 0 }), + [initialZoom] + ); + void linkPan; void setLinkPan; void imgWidth; void imgHeight; + const [zoomStates, setZoomStates] = React.useState>(new Map()); + const [linkedZoomState, setLinkedZoomState] = React.useState(initialZoomState); + const [linkedZoom, setLinkedZoom] = useModelState("link_zoom"); + const [isDraggingPan, setIsDraggingPan] = React.useState(false); + const [panStart, setPanStart] = React.useState<{ x: number, y: number, pX: number, pY: number } | null>(null); + + // Helper to get zoom state for an image. zoom and pan link independently: + // zoom from linkedZoomState if linkedZoom else per-image + // pan from linkedZoomState if linkPan else per-image + const getZoomState = React.useCallback((idx: number): ZoomState => { + const per = zoomStates.get(idx) || initialZoomState; + return { + zoom: linkedZoom ? linkedZoomState.zoom : per.zoom, + panX: linkPan ? linkedZoomState.panX : per.panX, + panY: linkPan ? linkedZoomState.panY : per.panY, + }; + }, [linkedZoom, linkPan, linkedZoomState, zoomStates, initialZoomState]); + + // Helper to set zoom state for an image. zoom and pan honored independently: + // zoom: writes to linkedZoomState if linkedZoom, else per-image + // pan: writes to linkedZoomState if linkPan, else per-image + const setZoomState = React.useCallback((idx: number, state: ZoomState) => { + if (linkedZoom || linkPan) { + setLinkedZoomState(prev => ({ + zoom: linkedZoom ? state.zoom : prev.zoom, + panX: linkPan ? state.panX : prev.panX, + panY: linkPan ? state.panY : prev.panY, + })); + } + if (!linkedZoom || !linkPan) { + setZoomStates(prev => { + const m = new Map(prev); + const cur = m.get(idx) || initialZoomState; + m.set(idx, { + zoom: linkedZoom ? cur.zoom : state.zoom, + panX: linkPan ? cur.panX : state.panX, + panY: linkPan ? cur.panY : state.panY, + }); + return m; + }); + } + }, [linkedZoom, linkPan, initialZoomState]); + + // FFT zoom/pan state (single mode) + const [fftZoom, setFftZoom] = React.useState(DEFAULT_FFT_ZOOM); + const [fftPanX, setFftPanX] = React.useState(0); + const [fftPanY, setFftPanY] = React.useState(0); + const [isDraggingFftPan, setIsDraggingFftPan] = React.useState(false); + const [fftPanStart, setFftPanStart] = React.useState<{ x: number, y: number, pX: number, pY: number } | null>(null); + + // Histogram state — per-image contrast ranges (gallery) or single (one image) + const [linkedContrast, setLinkedContrast] = useModelState("link_contrast"); + const [linkedContrastState, setLinkedContrastState] = React.useState<{ vminPct: number; vmaxPct: number }>({ vminPct: 0, vmaxPct: 100 }); + const [contrastStates, setContrastStates] = React.useState>(new Map()); + // Ref mirror for fast slider path (bypass React effect batching) + const contrastRef = React.useRef<{ linked: { vminPct: number; vmaxPct: number }; perImage: Map }>({ linked: { vminPct: 0, vmaxPct: 100 }, perImage: new Map() }); + const sliderRafRef = React.useRef(0); + const getContrastState = React.useCallback((idx: number) => { + if (linkedContrast) return linkedContrastState; + return contrastStates.get(idx) || { vminPct: 0, vmaxPct: 100 }; + }, [linkedContrast, linkedContrastState, contrastStates]); + const setContrastState = React.useCallback((idx: number, state: { vminPct: number; vmaxPct: number }) => { + // Update ref immediately (for fast rAF render) + if (linkedContrast) { + contrastRef.current.linked = state; + setLinkedContrastState(state); + } else { + contrastRef.current.perImage.set(idx, state); + setContrastStates(prev => new Map(prev).set(idx, state)); + } + // Fast path: direct GPU render via rAF, bypassing React effect batching + const engine = gpuCmapRef.current; + if (engine && gpuCmapReadyRef.current && engine.slotCount >= nImages) { + cancelAnimationFrame(sliderRafRef.current); + sliderRafRef.current = requestAnimationFrame(() => { + const cachedRanges = dataRangesRef.current; + if (cachedRanges.length === 0) return; + const lut = COLORMAPS[cmapRef.current] || COLORMAPS.inferno; + engine.uploadLUT(cmapRef.current, lut); + const indices = Array.from({ length: nImages }, (_, i) => i); + const ranges: { vmin: number; vmax: number }[] = []; + for (let i = 0; i < nImages; i++) { + const cs = linkedContrast ? contrastRef.current.linked : (contrastRef.current.perImage.get(i) || { vminPct: 0, vmaxPct: 100 }); + let cr = cachedRanges[i]; + if (!cr || cr.min === cr.max) { + if (rawDataRef.current && rawDataRef.current[i]) cr = findDataRange(rawDataRef.current[i]); + } + cr = cr || { min: 0, max: 1 }; + if (cs.vminPct > 0 || cs.vmaxPct < 100) { + ranges.push(sliderRange(cr.min, cr.max, cs.vminPct, cs.vmaxPct)); + } else { + ranges.push({ vmin: cr.min, vmax: cr.max }); + } + } + const ls = logScaleRef.current ?? false; + const bitmaps = engine.renderSlotsToImageBitmap(indices, ranges, ls); + if (bitmaps && bitmaps[0]) { + for (let i = 0; i < bitmaps.length; i++) { + const offscreen = mainOffscreensRef.current[i]; + if (offscreen && bitmaps[i]) offscreen.getContext("2d")?.drawImage(bitmaps[i], 0, 0); + } + setOffscreenVersion(v => v + 1); + } + }); + } + }, [linkedContrast, nImages]); + // Convenience accessors for active image + const activeContrastIdx = nImages > 1 ? selectedIdx : 0; + const imageVminPct = getContrastState(activeContrastIdx).vminPct; + const imageVmaxPct = getContrastState(activeContrastIdx).vmaxPct; + + const [imageHistogramData, setImageHistogramData] = React.useState(null); + const [imageHistogramBins, setImageHistogramBins] = React.useState(null); + const [imageDataRange, setImageDataRange] = React.useState<{ min: number; max: number }>({ min: 0, max: 1 }); + + // FFT display state (single mode) + const [fftVminPct, setFftVminPct] = React.useState(0); + const [fftVmaxPct, setFftVmaxPct] = React.useState(100); + const [fftHistogramData, setFftHistogramData] = React.useState(null); + const [fftDataRange, setFftDataRange] = React.useState<{ min: number; max: number }>({ min: 0, max: 1 }); + const [fftColormap, setFftColormap] = React.useState("inferno"); + const [fftScaleMode, setFftScaleMode] = React.useState<"linear" | "log" | "power">("linear"); + const [fftAuto, setFftAuto] = React.useState(true); + const [fftSmooth, setFftSmooth] = React.useState(true); + const [fftLinkedZoom, setFftLinkedZoom] = React.useState(false); + const [fftLinkPan, setFftLinkPan] = React.useState(false); + const [fftLinkedContrast, setFftLinkedContrast] = React.useState(true); + // Per-image FFT contrast (used when fftLinkedContrast=false) + const [fftContrastStates, setFftContrastStates] = React.useState>(new Map()); + const fftContrastFor = React.useCallback((idx: number) => { + if (fftLinkedContrast) return { vminPct: fftVminPct, vmaxPct: fftVmaxPct }; + return fftContrastStates.get(idx) || { vminPct: 0, vmaxPct: 100 }; + }, [fftLinkedContrast, fftVminPct, fftVmaxPct, fftContrastStates]); + const setFftContrastFor = React.useCallback((idx: number, val: { vminPct: number; vmaxPct: number }) => { + if (fftLinkedContrast) { + setFftVminPct(val.vminPct); + setFftVmaxPct(val.vmaxPct); + } else { + setFftContrastStates(prev => new Map(prev).set(idx, val)); + } + }, [fftLinkedContrast]); + const [fftStats, setFftStats] = React.useState(null); + const [fftShowColorbar, setFftShowColorbar] = React.useState(false); + + // FFT loading state — shown as a pulsing overlay while FFT computes + const [fftComputing, setFftComputing] = React.useState(false); + const [fftProgress, setFftProgress] = React.useState(""); + + // Cursor readout state + const [cursorInfo, setCursorInfo] = React.useState<{ row: number; col: number; value: number } | null>(null); + + // Colorbar state (single image mode only) + const [showColorbar, setShowColorbar] = React.useState(false); + + // Inset magnifier state + const [showLens, setShowLens] = React.useState(false); + const [lensPos, setLensPos] = React.useState<{ row: number; col: number } | null>(null); + const [lensMag, setLensMag] = React.useState(4); // magnification 2×–8× + const [lensDisplaySize, setLensDisplaySize] = React.useState(128); // CSS px 64–256 + const [lensAnchor, setLensAnchor] = React.useState<{ x: number; y: number } | null>(null); // custom position (CSS px from top-left of canvas) + const [isDraggingLens, setIsDraggingLens] = React.useState(false); + const [isResizingLens, setIsResizingLens] = React.useState(false); + const [isHoveringLensEdge, setIsHoveringLensEdge] = React.useState(false); + const lensDragStartRef = React.useRef<{ mx: number; my: number; ax: number; ay: number } | null>(null); + const lensResizeStartRef = React.useRef<{ my: number; startSize: number } | null>(null); + const lensCanvasRef = React.useRef(null); + + // FFT d-spacing measurement + const [fftClickInfo, setFftClickInfo] = React.useState<{ + row: number; col: number; distPx: number; + spatialFreq: number | null; dSpacing: number | null; + } | null>(null); + const fftClickStartRef = React.useRef<{ x: number; y: number } | null>(null); + const fftOverlayRef = React.useRef(null); + + // Line profile state + const [profileActive, setProfileActive] = React.useState(false); + const [profileLine, setProfileLine] = useModelState<{ row: number; col: number }[]>("profile_line"); + const [profileDataAll, setProfileDataAll] = React.useState<(Float32Array | null)[]>([]); + const profileCanvasRef = React.useRef(null); + const profileBaseImageRef = React.useRef(null); + const profileLayoutRef = React.useRef<{ padLeft: number; plotW: number; padTop: number; plotH: number; gMin: number; gMax: number; totalDist: number; xUnit: string } | null>(null); + + // Sync profile points from model state + const profilePoints = profileLine || []; + const setProfilePoints = (pts: { row: number; col: number }[]) => setProfileLine(pts); + + // Distance measurement state (JS-only, not persisted) + const [measureActive, setMeasureActive] = React.useState(false); + const [measurePoints, setMeasurePoints] = React.useState<{row: number; col: number}[]>([]); + + // FFT zoom/pan state (gallery mode — per-image or linked) + const [galleryFftStates, setGalleryFftStates] = React.useState>(new Map()); + const [linkedFftZoomState, setLinkedFftZoomState] = React.useState({ zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }); + const [fftPanningIdx, setFftPanningIdx] = React.useState(null); + const getGalleryFftState = React.useCallback((idx: number) => { + const per = galleryFftStates.get(idx) || { zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }; + return { + zoom: fftLinkedZoom ? linkedFftZoomState.zoom : per.zoom, + panX: fftLinkPan ? linkedFftZoomState.panX : per.panX, + panY: fftLinkPan ? linkedFftZoomState.panY : per.panY, + }; + }, [fftLinkedZoom, fftLinkPan, linkedFftZoomState, galleryFftStates]); + const setGalleryFftState = React.useCallback((idx: number, state: ZoomState) => { + if (fftLinkedZoom || fftLinkPan) { + setLinkedFftZoomState(prev => ({ + zoom: fftLinkedZoom ? state.zoom : prev.zoom, + panX: fftLinkPan ? state.panX : prev.panX, + panY: fftLinkPan ? state.panY : prev.panY, + })); + } + if (!fftLinkedZoom || !fftLinkPan) { + setGalleryFftStates(prev => { + const cur = prev.get(idx) || { zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }; + const next = new Map(prev); + next.set(idx, { + zoom: fftLinkedZoom ? cur.zoom : state.zoom, + panX: fftLinkPan ? cur.panX : state.panX, + panY: fftLinkPan ? cur.panY : state.panY, + }); + return next; + }); + } + }, [fftLinkedZoom, fftLinkPan]); + + // Resizable state (gallery starts smaller) + const [canvasSize, setCanvasSize] = React.useState(nImages > 1 ? GALLERY_IMAGE_TARGET : SINGLE_IMAGE_TARGET); + + // Sync initial sizes from traits + React.useEffect(() => { + if (canvasSizeTrait > 0) setCanvasSize(canvasSizeTrait); + }, [canvasSizeTrait]); + + const [isResizingCanvas, setIsResizingCanvas] = React.useState(false); + const [resizeStart, setResizeStart] = React.useState<{ x: number, y: number, size: number } | null>(null); + + // Profile height resize + const [profileHeight, setProfileHeight] = React.useState(76); + const [isResizingProfile, setIsResizingProfile] = React.useState(false); + const [profileResizeStart, setProfileResizeStart] = React.useState<{ y: number; height: number } | null>(null); + + // WebGPU FFT + const gpuFFTRef = React.useRef(null); + const gpuReadyRef = React.useRef(false); + const rawDataRef = React.useRef(null); + const diffCanvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); + const diffFftCanvasRef = React.useRef(null); + const diffFftMagRef = React.useRef(null); + + // WebGPU colormap engine — uses refs (not state) to avoid re-triggering + // effects when GPU initializes. Effects check refs opportunistically: + // on first render they use CPU, on subsequent renders (data/slider change) + // they use GPU if available. No double computation. + const gpuCmapRef = React.useRef(null); + const gpuCmapReadyRef = React.useRef(false); + + // Cached offscreen canvases for main image rendering (avoids per-zoom/pan recompute) + const mainOffscreensRef = React.useRef([]); + const mainImgDatasRef = React.useRef([]); + const logBufferRef = React.useRef(null); + const colorbarVminRef = React.useRef(0); + const colorbarVmaxRef = React.useRef(1); + const [offscreenVersion, setOffscreenVersion] = React.useState(0); + + // Truthful first-render signal: flipped ONCE after the first colormap pass has + // actually painted. Python side observes `_js_rendered` and prints the real + // end-to-end wall clock. Two rAFs ensure the browser has composited before we + // fire, so the printed time reflects "user can see the widget," not "data arrived." + const [, setJsRendered] = useModelState("_js_rendered"); + const firstRenderFiredRef = React.useRef(false); + React.useEffect(() => { + if (firstRenderFiredRef.current) return; + if (offscreenVersion === 0) return; + firstRenderFiredRef.current = true; + requestAnimationFrame(() => requestAnimationFrame(() => setJsRendered(true))); + }, [offscreenVersion, setJsRendered]); + + // Inline FFT refs for gallery mode + const fftCanvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]); + const fftOffscreensRef = React.useRef<(HTMLCanvasElement | null)[]>([]); + const fftMagCacheGalleryRef = React.useRef<(Float32Array | null)[]>([]); + const galleryFftDimsRef = React.useRef<{ w: number; h: number } | null>(null); + const [galleryFftMagVersion, setGalleryFftMagVersion] = React.useState(0); + + // Cached FFT magnitude for single image mode (avoids recomputing on zoom/pan) + const fftMagCacheRef = React.useRef(null); + const [fftMagVersion, setFftMagVersion] = React.useState(0); + // Generation counter for FFT — coalesces rapid ROI drag events to ≤1 FFT/frame + const fftGenRef = React.useRef(0); + + // Cached FFT offscreen canvas for single mode (avoids reprocessing on zoom/pan) + const fftOffscreenRef = React.useRef(null); + // Caches transformed magnitude + range + stats so contrast slider drag + // doesn't re-run log/power/findDataRange/autoEnhance on every tick. + const fftPipelineRef = React.useRef<{ + magnitude: Float32Array; + displayMin: number; + displayMax: number; + magVersion: number; + scaleMode: string; + fftAuto: boolean; + } | null>(null); + const [fftOffscreenVersion, setFftOffscreenVersion] = React.useState(0); + + // ROI FFT state: when ROI + FFT are both active, compute FFT of cropped ROI region + const [fftCropDims, setFftCropDims] = React.useState<{ cropWidth: number; cropHeight: number; fftWidth: number; fftHeight: number } | null>(null); + + // Layout calculations + const isGallery = nImages > 1; + const showDiffPanel = diffMode && nImages >= 2; + const diffPanelCount = showDiffPanel ? Math.max(0, nImages - 1) : 0; + const effectiveNcols = Math.min(ncols, nImages) + diffPanelCount; + const diffOtherIndices = React.useMemo( + () => Array.from({ length: nImages }, (_, i) => i).filter(i => i !== diffReference), + [nImages, diffReference] + ); + const displayScale = canvasSize / Math.max(width, height); + const canvasW = Math.round(width * displayScale); + const canvasH = Math.round(height * displayScale); + + // Initial pan from zoom_row/zoom_col — runs once after first render with valid canvas dims. + // panX/panY computed so target image (zoomRow, zoomCol) lands at canvas center after transform: + // ctx.translate(cx+panX, cy+panY) ⋅ scale(zoom) ⋅ translate(-cx,-cy) + // target screen = cx + panX + zoom * (target_canvas - cx) = cx + // ⟹ panX = zoom * (cx - target_canvas) = zoom * canvasW * (0.5 - col/width) + const initialPanAppliedRef = React.useRef(false); + React.useEffect(() => { + if (initialPanAppliedRef.current) return; + if (zoomRowTrait == null && zoomColTrait == null) return; + if (canvasW <= 0 || canvasH <= 0 || width <= 0 || height <= 0) return; + const z = initialZoomState.zoom; + const panX = zoomColTrait != null ? z * canvasW * (0.5 - zoomColTrait / width) : 0; + const panY = zoomRowTrait != null ? z * canvasH * (0.5 - zoomRowTrait / height) : 0; + setLinkedZoomState({ zoom: z, panX, panY }); + setZoomStates(prev => { + const m = new Map(prev); + for (let i = 0; i < nImages; i++) m.set(i, { zoom: z, panX, panY }); + return m; + }); + initialPanAppliedRef.current = true; + }, [zoomRowTrait, zoomColTrait, canvasW, canvasH, width, height, nImages, initialZoomState.zoom]); + const floatsPerImage = width * height; + const galleryGridWidth = isGallery ? effectiveNcols * canvasW + (effectiveNcols - 1) * 8 : canvasW; + const profileCanvasWidth = galleryGridWidth; + + // ROI FFT active: both ROI and FFT on, with a selected ROI + const roiFftActive = effectiveShowFft && roiActive && roiSelectedIdx >= 0 && roiSelectedIdx < (roiList?.length ?? 0); + + // Stable key for ROI geometry — only changes when the selected ROI's geometry changes, + // not when other ROIs move or roiList gets a new reference from unrelated edits. + // Shared by both ROI FFT and preview panel to avoid redundant recomputes. + const selectedRoiKey = React.useMemo(() => { + if (!roiList || roiSelectedIdx < 0 || roiSelectedIdx >= roiList.length) return ""; + const r = roiList[roiSelectedIdx]; + return `${r.row},${r.col},${r.radius},${r.radius_inner},${r.width},${r.height},${r.shape}`; + }, [roiList, roiSelectedIdx]); + const roiFftKey = roiFftActive ? selectedRoiKey : ""; + + // Extract raw float32 bytes and parse into Float32Arrays + const allFloats = React.useMemo(() => extractFloat32(frameBytes), [frameBytes]); + + // Initialize WebGPU FFT + colormap engine on mount. + // Sets refs (not state) — no effect re-triggers on GPU init. + // Effects pick up GPU on their next natural re-run (data/slider change). + React.useEffect(() => { + getWebGPUFFT().then(fft => { + if (fft) { + gpuFFTRef.current = fft; + gpuReadyRef.current = true; + const info = getGPUInfo(); + console.log(`[Show2D] WebGPU FFT initialized — ${info || "GPU"}`); + } else { + console.log("[Show2D] WebGPU unavailable — using CPU Worker fallback"); + } + }); + getGPUColormapEngine().then(engine => { + if (engine) { + gpuCmapRef.current = engine; + gpuCmapReadyRef.current = true; + console.log("[Show2D] WebGPU colormap engine initialized"); + // Report GPU memory to Python for auto-bin budget + getGPUMaxBufferSize().then(bytes => { + if (bytes > 0) setGpuMaxBufferMB(Math.floor(bytes / (1024 * 1024))); + }); + // Upload data if already parsed (GPU init may be slower than data arrival). + // Do NOT call setState — that would re-trigger effects and cause double + // computation. Instead, upload data and do a warm-up render via rAF. + // This compiles the GPU pipeline in the background so the first user + // interaction is fast (~100ms instead of ~750ms cold start). + if (rawDataRef.current && rawDataRef.current.length > 0) { + const nImg = rawDataRef.current.length; + for (let i = 0; i < nImg; i++) { + const d = rawDataRef.current[i]; + if (d) engine.uploadData(i, d, width, height); + } + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + engine.uploadLUT(cmap, lut); + gpuDataVersionRef.current++; + // Warm-up: render once to compile GPU pipeline + fill canvases. + // Uses full data range (no slider adjustment) for the initial frame. + requestAnimationFrame(async () => { + const offscreens = mainOffscreensRef.current; + const imgDatas = mainImgDatasRef.current; + if (offscreens.length === 0 || imgDatas.length === 0) return; + const cachedRanges = dataRangesRef.current; + if (cachedRanges.length === 0) return; + const indices = Array.from({ length: nImg }, (_, i) => i); + const ranges = cachedRanges.map(r => ({ vmin: r.min, vmax: r.max })); + const ofs = indices.map(i => offscreens[i] || null); + const ids = indices.map(i => imgDatas[i] || null); + const logSc = logScaleRef.current ?? false; + await engine.renderSlots(indices, ranges, ofs, ids, logSc); + setOffscreenVersion(v => v + 1); + }); + } + } + }); + }, []); + + const [dataVersion, setDataVersion] = React.useState(0); + + // Keep inline FFT ref arrays in sync with nImages + React.useEffect(() => { + fftCanvasRefs.current = fftCanvasRefs.current.slice(0, nImages); + fftOffscreensRef.current = fftOffscreensRef.current.slice(0, nImages); + }, [nImages]); + + // FFT of diff (n=2 only). Computes A − B in JS at full image resolution from rawDataRef, + // feeds to FFT pipeline. Recomputes when raw data changes. + React.useEffect(() => { + if (!effectiveShowFft || !showDiffPanel || nImages !== 2) return; + const raw = rawDataRef.current; + if (!raw || raw.length < 2 || !raw[0] || !raw[1]) return; + const a = raw[0], b = raw[1]; + const bytes = new Float32Array(width * height); + for (let i = 0; i < bytes.length; i++) bytes[i] = a[i] - b[i]; + const canvas = diffFftCanvasRef.current; + if (!canvas) return; + const fftW = nextPow2(width), fftH = nextPow2(height); + const real = new Float32Array(fftW * fftH); + const imag = new Float32Array(fftW * fftH); + const src = new Float32Array(bytes); + if (fftWindow) applyHannWindow2D(src, width, height); + const padR = Math.floor((fftH - height) / 2), padC = Math.floor((fftW - width) / 2); + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) real[(r + padR) * fftW + c + padC] = src[r * width + c]; + } + let cancelled = false; + (async () => { + // WebGPU primary (matches main + gallery FFT paths). CPU worker fallback + // for browsers without WebGPU (Safari <17, FF behind flag). + const result = (gpuFFTRef.current && gpuReadyRef.current) + ? await gpuFFTRef.current.fft2D(real, imag, fftW, fftH, false) + : await fft2dAsync(real, imag, fftW, fftH, false); + if (cancelled) return; + const mag = computeMagnitude(result.real, result.imag); + fftshift(mag, fftW, fftH); + diffFftMagRef.current = mag; + const { min, max } = autoEnhanceFFT(mag, fftW, fftH); + const off = renderToOffscreen(mag, fftW, fftH, COLORMAPS[fftColormap] || COLORMAPS.inferno, min, max); + if (!off) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = fftSmooth; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(off, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); + })(); + return () => { cancelled = true; }; + }, [effectiveShowFft, showDiffPanel, nImages, dataVersion, width, height, fftWindow, fftColormap, canvasW, canvasH, fftSmooth]); + + // Diff panels render — DYNAMIC. One per non-reference image: image[ref] − image[i]. + // Computed at canvas resolution from raw float data, re-running on zoom/pan/align change. + // For n=2: alignDy/dx applied to non-ref image. For n>2: no align (per-pair align not yet supported). + React.useEffect(() => { + if (!showDiffPanel) return; + const raw = rawDataRef.current; + if (!raw || raw.length < 2) return; + const ref = diffReference; + const a = raw[ref]; + if (!a) return; + diffOtherIndices.forEach((otherIdx, slot) => { + renderDiffPanel(slot, a, raw[otherIdx], otherIdx); + }); + // forEach inlines below — extracted as effect helper. + function renderDiffPanel(slot: number, refData: Float32Array, otherData: Float32Array | undefined, otherIdx: number) { + if (!otherData) return; + const canvas = diffCanvasRefs.current[slot]; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const zs0 = getZoomState(ref); + const zs1 = getZoomState(otherIdx); + const useAlign = nImages === 2; + const adY = useAlign ? alignDy : 0; + const adX = useAlign ? alignDx : 0; + const a = refData, b = otherData; + const cw = canvasW, ch = canvasH; + const cx = cw / 2, cy = ch / 2; + const sx = width / cw, sy = height / ch; + const diff = new Float32Array(cw * ch); + let mn = Infinity, mx = -Infinity; + // Smooth: bilinear (slower, sub-pixel correct). !Smooth: nearest neighbor (faster, pixelated). + const Hm1 = height - 1, Wm1 = width - 1; + const a_panX = zs0.panX, a_panY = zs0.panY, a_zoom = zs0.zoom; + const b_panX = zs1.panX, b_panY = zs1.panY, b_zoom = zs1.zoom; + if (smooth) { + for (let y = 0; y < ch; y++) { + const ayu = (y - cy - a_panY) / a_zoom + cy; + const byu = (y - cy - b_panY) / b_zoom + cy; + const aRowF = ayu * sy; + const bRowF = byu * sy - adY; + const aR0 = aRowF | 0, bR0 = bRowF | 0; + const aFr = aRowF - aR0, bFr = bRowF - bR0; + const aRowOOB = aR0 < 0 || aR0 >= Hm1; + const bRowOOB = bR0 < 0 || bR0 >= Hm1; + const aRowOff = aR0 * width; + const bRowOff = bR0 * width; + const rowOff = y * cw; + for (let x = 0; x < cw; x++) { + const axu = (x - cx - a_panX) / a_zoom + cx; + const bxu = (x - cx - b_panX) / b_zoom + cx; + const aColF = axu * sx; + const bColF = bxu * sx - adX; + const aC0 = aColF | 0, bC0 = bColF | 0; + let v = 0; + if (!aRowOOB && !bRowOOB && aC0 >= 0 && aC0 < Wm1 && bC0 >= 0 && bC0 < Wm1) { + const aFc = aColF - aC0, bFc = bColF - bC0; + const ai = aRowOff + aC0; + const bi = bRowOff + bC0; + const aV = (a[ai] * (1 - aFc) + a[ai + 1] * aFc) * (1 - aFr) + + (a[ai + width] * (1 - aFc) + a[ai + width + 1] * aFc) * aFr; + const bV = (b[bi] * (1 - bFc) + b[bi + 1] * bFc) * (1 - bFr) + + (b[bi + width] * (1 - bFc) + b[bi + width + 1] * bFc) * bFr; + v = aV - bV; + } + diff[rowOff + x] = v; + if (v < mn) mn = v; + if (v > mx) mx = v; + } + } + } else { + for (let y = 0; y < ch; y++) { + const ayu = (y - cy - a_panY) / a_zoom + cy; + const byu = (y - cy - b_panY) / b_zoom + cy; + const aRow = (ayu * sy + 0.5) | 0; + const bRow = (byu * sy - adY + 0.5) | 0; + const aRowOK = aRow >= 0 && aRow < height; + const bRowOK = bRow >= 0 && bRow < height; + const aRowOff = aRow * width; + const bRowOff = bRow * width; + const rowOff = y * cw; + for (let x = 0; x < cw; x++) { + const axu = (x - cx - a_panX) / a_zoom + cx; + const bxu = (x - cx - b_panX) / b_zoom + cx; + const aCol = (axu * sx + 0.5) | 0; + const bCol = (bxu * sx - adX + 0.5) | 0; + let v = 0; + if (aRowOK && bRowOK && aCol >= 0 && aCol < width && bCol >= 0 && bCol < width) { + v = a[aRowOff + aCol] - b[bRowOff + bCol]; + } + diff[rowOff + x] = v; + if (v < mn) mn = v; + if (v > mx) mx = v; + } + } + } + const sym = Math.max(Math.abs(mn), Math.abs(mx)); + // Diff is signed-around-zero — use diverging cmap (RdBu) if user picked a sequential one. + const sequentialCmaps = new Set(["inferno", "viridis", "plasma", "magma", "hot", "gray", "turbo"]); + const diffCmap = sequentialCmaps.has(cmap) ? "RdBu" : cmap; + const off = renderToOffscreen(diff, cw, ch, COLORMAPS[diffCmap] || COLORMAPS.RdBu, -sym, sym); + if (!off) return; + ctx.imageSmoothingEnabled = smooth; + if (smooth) ctx.imageSmoothingQuality = "high"; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(off, 0, 0); + } + }, [showDiffPanel, diffOtherIndices, diffReference, nImages, dataVersion, width, height, cmap, smooth, canvasW, canvasH, + alignDy, alignDx, getZoomState, linkedZoom, linkPan, linkedZoomState, zoomStates]); + + React.useEffect(() => { + if (!allFloats || allFloats.length === 0) return; + const dataArrays: Float32Array[] = []; + for (let i = 0; i < nImages; i++) { + const start = i * floatsPerImage; + const imageData = allFloats.subarray(start, start + floatsPerImage); + dataArrays.push(new Float32Array(imageData)); + } + rawDataRef.current = dataArrays; + // Upload to GPU colormap engine if available (ref check, no state trigger) + const engine = gpuCmapRef.current; + if (engine && gpuCmapReadyRef.current) { + for (let i = 0; i < dataArrays.length; i++) engine.uploadData(i, dataArrays[i], width, height); + gpuDataVersionRef.current++; + } + setDataVersion(v => v + 1); + }, [allFloats, nImages, floatsPerImage]); + + // Initialize reusable offscreen canvases (one per image, resized when dimensions change) + React.useEffect(() => { + if (width <= 0 || height <= 0 || nImages <= 0) return; + const canvases: HTMLCanvasElement[] = []; + const imgDatas: ImageData[] = []; + for (let i = 0; i < nImages; i++) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + canvases.push(canvas); + imgDatas.push(canvas.getContext("2d")!.createImageData(width, height)); + } + mainOffscreensRef.current = canvases; + mainImgDatasRef.current = imgDatas; + logBufferRef.current = new Float32Array(width * height); + }, [width, height, nImages]); + + // Compute histogram data for the displayed image (reflects log scale) + // GPU path: uses persistent per-slot histogram buffers — no CPU data scan + // CPU fallback: computeHistogramFromBytes (before GPU ready) + React.useEffect(() => { + if (!rawDataRef.current) return; + const idx = nImages > 1 ? selectedIdx : 0; + const raw = rawDataRef.current[idx]; + if (!raw) return; + + // Use cached ranges (no CPU findDataRange scan) + const cachedRaw = rawRangesRef.current[idx]; + const rawRange = cachedRaw || findDataRange(raw); // fallback if cache miss + const range = logScale + ? { min: Math.log1p(Math.max(rawRange.min, 0)), max: Math.log1p(Math.max(rawRange.max, 0)) } + : rawRange; + setImageDataRange(range); + + const engine = gpuCmapRef.current; + if (engine && gpuCmapReadyRef.current && engine.slotCount > idx) { + // GPU histogram — single image, persistent buffers + engine.computeHistogramWithRange(idx, range.min, range.max, logScale).then(bins => { + setImageHistogramBins(bins); + setImageHistogramData(null); + }); + } else { + // CPU fallback (before GPU ready) + const d = logScale ? applyLogScale(raw) : raw; + setImageHistogramBins(null); + setImageHistogramData(d); + } + }, [allFloats, nImages, floatsPerImage, logScale, selectedIdx]); + + // Prevent page scroll when scrolling on canvases (must use native listener with passive: false) + // In gallery mode, only block scroll on the selected image (or all if linkedZoom) + React.useEffect(() => { + const preventDefault = (e: WheelEvent) => e.preventDefault(); + const elements: (HTMLElement | null)[] = isGallery + ? (linkedZoom + ? [ + ...imageContainerRefs.current, + ...(effectiveShowFft ? fftContainerRefs.current : []), + ] + : [ + imageContainerRefs.current[selectedIdx], + ...(effectiveShowFft ? [fftContainerRefs.current[selectedIdx]] : []), + ]) + : [ + imageContainerRefs.current[0], + ...(effectiveShowFft ? [singleFftContainerRef.current] : []), + ]; + elements.forEach(el => el?.addEventListener("wheel", preventDefault, { passive: false })); + return () => elements.forEach(el => el?.removeEventListener("wheel", preventDefault)); + }, [canvasReady, effectiveShowFft, isGallery, selectedIdx, linkedZoom]); + + const gpuDataVersionRef = React.useRef(0); + // Generation counter for colormap — coalesces rapid slider events to ≤1 render per frame + // Cached per-image data ranges — only recomputed when data or logScale changes, NOT on slider drag + const dataRangesRef = React.useRef<{ min: number; max: number }[]>([]); + // Cached log-transformed data — avoids 12×16M log1p calls per slider tick + const logDataCacheRef = React.useRef([]); + // Ref mirrors for async GPU callbacks (avoid stale closures) + const logScaleRef = React.useRef(logScale); + logScaleRef.current = logScale; + const cmapRef = React.useRef(cmap); + cmapRef.current = cmap; + // Auto-contrast cache: GPU-computed percentile ranges per image + const autoContrastCacheRef = React.useRef<{ vmin: number; vmax: number }[]>([]); + + // Cache per-image data ranges (raw AND log) on data change only. + // Log ranges are derived mathematically: log1p(rawMin), log1p(rawMax). + // NO applyLogScale here — GPU shader handles log1p per pixel. + // Log toggle is now free: just pick the right cached ranges. + const rawRangesRef = React.useRef<{ min: number; max: number }[]>([]); + React.useEffect(() => { + if (!rawDataRef.current || rawDataRef.current.length === 0) return; + const engine = gpuCmapRef.current; + const nImg = rawDataRef.current.length; + + if (engine && gpuCmapReadyRef.current && engine.slotCount >= nImg) { + // GPU path: batch compute min/max on GPU (async, updates refs when done) + const indices = Array.from({ length: nImg }, (_, i) => i); + engine.computeRangeBatch(indices).then(rawRanges => { + rawRangesRef.current = rawRanges; + const logRanges = rawRanges.map(r => ({ + min: Math.log1p(Math.max(r.min, 0)), + max: Math.log1p(Math.max(r.max, 0)), + })); + dataRangesRef.current = logScaleRef.current ? logRanges : rawRanges; + }); + } else { + // CPU fallback: scan each image for min/max + const rawRanges: { min: number; max: number }[] = []; + for (let i = 0; i < nImg; i++) { + const rawData = rawDataRef.current[i]; + if (!rawData) { rawRanges.push({ min: 0, max: 1 }); continue; } + rawRanges.push(findDataRange(rawData)); + } + rawRangesRef.current = rawRanges; + const logRanges = rawRanges.map(r => ({ + min: Math.log1p(Math.max(r.min, 0)), + max: Math.log1p(Math.max(r.max, 0)), + })); + dataRangesRef.current = logScale ? logRanges : rawRanges; + } + logDataCacheRef.current = rawDataRef.current.slice(); + }, [dataVersion]); + + // When logScale toggles, just swap cached ranges (no data scan) + React.useEffect(() => { + if (rawRangesRef.current.length === 0) return; + const logRanges = rawRangesRef.current.map(r => ({ + min: Math.log1p(Math.max(r.min, 0)), + max: Math.log1p(Math.max(r.max, 0)), + })); + dataRangesRef.current = logScale ? logRanges : rawRangesRef.current; + }, [logScale]); + + // GPU auto-contrast: batch-compute percentile ranges from GPU histograms. + // One GPU submission for all images. Caches results for synchronous use in render. + React.useEffect(() => { + if (!autoContrast) { autoContrastCacheRef.current = []; return; } + const engine = gpuCmapRef.current; + if (!engine || !gpuCmapReadyRef.current || !rawDataRef.current) return; + const cachedRanges = dataRangesRef.current; + if (cachedRanges.length === 0) return; + const ls = logScale; + const nImg = Math.min(rawDataRef.current.length, engine.slotCount); + if (nImg === 0) return; + + (async () => { + const indices = Array.from({ length: nImg }, (_, i) => i); + const histRanges = indices.map(i => cachedRanges[i] || { min: 0, max: 1 }); + const allBins = await engine.computeHistogramBatch(indices, histRanges, ls); + + const pLow = 2, pHigh = 98; + const acRanges: { vmin: number; vmax: number }[] = []; + for (let k = 0; k < allBins.length; k++) { + const bins = allBins[k]; + const cr = histRanges[k]; + // Percentile from normalized histogram CDF + let sum = 0; + for (let b = 0; b < 256; b++) sum += bins[b]; + let binLow = 0, binHigh = 255; + const targetLow = sum * pLow / 100; + const targetHigh = sum * pHigh / 100; + let running = 0; + for (let b = 0; b < 256; b++) { + running += bins[b]; + if (running >= targetLow && binLow === 0) binLow = b; + if (running >= targetHigh) { binHigh = b; break; } + } + const range = cr.max - cr.min; + acRanges.push({ vmin: cr.min + (binLow / 255) * range, vmax: cr.min + (binHigh / 255) * range }); + } + autoContrastCacheRef.current = acRanges; + console.log(`[Show2D] GPU auto-contrast: ${nImg} images, ${allBins.length} histograms`); + setOffscreenVersion(v => v + 1); + })(); + }, [autoContrast, dataVersion, logScale]); + + // ------------------------------------------------------------------------- + // Data effect: normalize + colormap → reusable offscreen canvases + // GPU path: runs compute shader for all images in one submission + // CPU fallback: per-image applyColormap loop + // (does NOT depend on zoom/pan — avoids recomputing 16M pixels on every pan/zoom) + // ------------------------------------------------------------------------- + React.useEffect(() => { + if (!dataVersion || !rawDataRef.current || rawDataRef.current.length === 0) return; + if (mainOffscreensRef.current.length === 0 || mainImgDatasRef.current.length === 0) return; + + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + + // Compute per-image vmin/vmax from CACHED data ranges (no findDataRange per tick). + // dataRangesRef is precomputed when data or logScale changes. + const cachedRanges = dataRangesRef.current; + const hasAbsoluteRange = traitVmin != null && traitVmax != null; + const ranges: { vmin: number; vmax: number }[] = []; + for (let i = 0; i < nImages; i++) { + let vmin: number, vmax: number; + const cs = linkedContrast ? linkedContrastState : (contrastStates.get(i) || { vminPct: 0, vmaxPct: 100 }); + + // Per-image absolute range (vmins/vmaxs) takes precedence over scalar (vmin/vmax) + const perI_min = traitVmins && traitVmins[i] != null ? traitVmins[i] : null; + const perI_max = traitVmaxs && traitVmaxs[i] != null ? traitVmaxs[i] : null; + const hasPerImage = perI_min != null && perI_max != null; + const isDiffSlot = false; + const diffSym = 0; + + let rangeMin: number, rangeMax: number; + if (isDiffSlot) { + rangeMin = -diffSym; + rangeMax = diffSym; + } else if (hasPerImage) { + rangeMin = logScale ? Math.log1p(Math.max(perI_min!, 0)) : perI_min!; + rangeMax = logScale ? Math.log1p(Math.max(perI_max!, 0)) : perI_max!; + } else if (hasAbsoluteRange) { + rangeMin = logScale ? Math.log1p(Math.max(traitVmin!, 0)) : traitVmin!; + rangeMax = logScale ? Math.log1p(Math.max(traitVmax!, 0)) : traitVmax!; + } else { + // GPU range compute is async — when cache missing OR collapsed (min==max from race), + // sync findDataRange on raw data to ensure non-degenerate range. + let cached = cachedRanges[i]; + if (!cached || cached.min === cached.max) { + if (rawDataRef.current && rawDataRef.current[i]) { + cached = findDataRange(rawDataRef.current[i]); + } + } + cached = cached || { min: 0, max: 1 }; + rangeMin = cached.min; + rangeMax = cached.max; + } + + if (!hasAbsoluteRange && !hasPerImage && autoContrast) { + // Auto-contrast: use GPU-precomputed percentile ranges. + // If GPU cache not ready yet, use full data range as placeholder + // (GPU auto-contrast effect will fire async and trigger re-render). + const acCache = autoContrastCacheRef.current[i]; + if (acCache) { + vmin = acCache.vmin; vmax = acCache.vmax; + } else { + vmin = rangeMin; vmax = rangeMax; + } + } else if (rangeMin !== rangeMax && (cs.vminPct > 0 || cs.vmaxPct < 100)) { + ({ vmin, vmax } = sliderRange(rangeMin, rangeMax, cs.vminPct, cs.vmaxPct)); + } else { + vmin = rangeMin; vmax = rangeMax; + } + ranges.push({ vmin, vmax }); + } + + // Cache first image's vmin/vmax for colorbar/lens + if (ranges.length > 0) { + colorbarVminRef.current = ranges[0].vmin; + colorbarVmaxRef.current = ranges[0].vmax; + } + + // GPU colormap — first-class citizen. + // Try zero-copy path (OffscreenCanvas → ImageBitmap, no mapAsync). + // Falls back to renderSlots (mapAsync + putImageData) if zero-copy fails. + const engine = gpuCmapRef.current; + const gpuReady = engine && gpuCmapReadyRef.current && engine.slotCount >= nImages; + if (gpuReady) { + engine!.uploadLUT(cmap, lut); + const capturedRanges = ranges.slice(); + const capturedLogScale = logScale; + const capturedNImages = nImages; + requestAnimationFrame(async () => { + const indices = Array.from({ length: capturedNImages }, (_, i) => i); + + // Zero-copy path: GPU → OffscreenCanvas → ImageBitmap → drawImage + const bitmaps = engine!.renderSlotsToImageBitmap(indices, capturedRanges, capturedLogScale); + if (bitmaps && bitmaps.length > 0 && bitmaps[0]) { + for (let i = 0; i < bitmaps.length; i++) { + const offscreen = mainOffscreensRef.current[i]; + if (!offscreen || !bitmaps[i]) continue; + const ctx = offscreen.getContext("2d"); + if (ctx) ctx.drawImage(bitmaps[i], 0, 0); + } + setOffscreenVersion(v => v + 1); + return; + } + + // Fallback: renderSlots (mapAsync + copy to ImageData) + const offscreens = indices.map(i => mainOffscreensRef.current[i] || null); + const imgDatas = indices.map(i => mainImgDatasRef.current[i] || null); + const rendered = await engine!.renderSlots(indices, capturedRanges, offscreens, imgDatas, capturedLogScale); + if (rendered === 0) { + for (let i = 0; i < capturedNImages; i++) { + const offscreen = mainOffscreensRef.current[i]; + const imgData = mainImgDatasRef.current[i]; + if (!offscreen || !imgData) continue; + const raw = rawDataRef.current?.[i]; + if (!raw) continue; + const processed = capturedLogScale ? applyLogScale(raw) : raw; + renderToOffscreenReuse(processed, lut, capturedRanges[i].vmin, capturedRanges[i].vmax, offscreen, imgData); + } + } + setOffscreenVersion(v => v + 1); + }); + } else { + // CPU fallback: initial render or no WebGPU + // CPU must do log transform itself (GPU shader would handle it) + for (let i = 0; i < nImages; i++) { + const offscreen = mainOffscreensRef.current[i]; + const imgData = mainImgDatasRef.current[i]; + if (!offscreen || !imgData) continue; + const raw = rawDataRef.current?.[i]; + if (!raw) continue; + const processed = logScale ? applyLogScale(raw) : raw; + renderToOffscreenReuse(processed, lut, ranges[i].vmin, ranges[i].vmax, offscreen, imgData); + } + setOffscreenVersion(v => v + 1); + } + }, [dataVersion, nImages, width, height, cmap, logScale, autoContrast, linkedContrast, linkedContrastState, contrastStates, traitVmin, traitVmax, traitVmins, traitVmaxs, diffMode]); + + // ------------------------------------------------------------------------- + // Draw effect: zoom/pan changes — cheap, just drawImage from cached offscreens + // useLayoutEffect prevents black flash when canvas dimensions change (resize) + // ------------------------------------------------------------------------- + React.useLayoutEffect(() => { + if (mainOffscreensRef.current.length === 0) return; + + for (let i = 0; i < nImages; i++) { + const canvas = canvasRefs.current[i]; + const offscreen = mainOffscreensRef.current[i]; + if (!canvas || !offscreen) continue; + const ctx = canvas.getContext("2d"); + if (!ctx) continue; + + ctx.imageSmoothingEnabled = smooth; + if (smooth) ctx.imageSmoothingQuality = "high"; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const zs = getZoomState(i); + const { zoom, panX, panY } = zs; + + if (zoom !== 1 || panX !== 0 || panY !== 0) { + ctx.save(); + const cx = canvasW / 2; + const cy = canvasH / 2; + ctx.translate(cx + panX, cy + panY); + ctx.scale(zoom, zoom); + ctx.translate(-cx, -cy); + ctx.drawImage(offscreen, 0, 0, width, height, 0, 0, canvasW, canvasH); + ctx.restore(); + } else { + ctx.drawImage(offscreen, 0, 0, width, height, 0, 0, canvasW, canvasH); + } + } + }, [offscreenVersion, nImages, width, height, displayScale, canvasW, canvasH, canvasReady, linkedZoom, linkedZoomState, zoomStates, smooth]); + + // ------------------------------------------------------------------------- + // Render Overlays (scale bar, colorbar, zoom indicator) + // ------------------------------------------------------------------------- + React.useEffect(() => { + for (let i = 0; i < nImages; i++) { + const overlay = overlayRefs.current[i]; + if (!overlay) continue; + const ctx = overlay.getContext("2d"); + if (!ctx) continue; + + if (scaleBarVisible) { + const zs = getZoomState(i); + const unit = pixelSize > 0 ? pixelUnit : "px"; + const pxSize = pixelSize > 0 ? pixelSize : 1; + drawScaleBarHiDPI(overlay, DPR, zs.zoom, pxSize, unit, width); + } else { + ctx.clearRect(0, 0, overlay.width, overlay.height); + } + + // Colorbar (single image mode only) — uses cached vmin/vmax from data effect + if (showColorbar && !isGallery) { + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + const cssW = overlay.width / DPR; + const cssH = overlay.height / DPR; + const vmin = colorbarVminRef.current; + const vmax = colorbarVmaxRef.current; + + ctx.save(); + ctx.scale(DPR, DPR); + drawColorbar(ctx, cssW, cssH, lut, vmin, vmax, logScale); + ctx.restore(); + } + + // ROI overlay — draw all ROIs + if (roiActive && roiList && roiList.length > 0) { + const zs = getZoomState(i); + const { zoom, panX, panY } = zs; + const cx = canvasW / 2; + const cy = canvasH / 2; + + // Highlight mask: dim everything outside highlighted ROIs + const highlightedRois = roiList.filter(r => r.highlight); + if (highlightedRois.length > 0) { + ctx.save(); + ctx.scale(DPR, DPR); + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillRect(0, 0, canvasW, canvasH); + ctx.globalCompositeOperation = "destination-out"; + for (const roi of highlightedRois) { + const sx = (roi.col * displayScale - cx) * zoom + cx + panX; + const sy = (roi.row * displayScale - cy) * zoom + cy + panY; + const sr = roi.radius * displayScale * zoom; + const shape = roi.shape || "circle"; + ctx.fillStyle = "rgba(0,0,0,1)"; + if (shape === "circle") { + ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill(); + } else if (shape === "square") { + ctx.fillRect(sx - sr, sy - sr, sr * 2, sr * 2); + } else if (shape === "rectangle") { + const sw = roi.width * displayScale * zoom; + const sh = roi.height * displayScale * zoom; + ctx.fillRect(sx - sw / 2, sy - sh / 2, sw, sh); + } else if (shape === "annular") { + ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI * 2); ctx.fill(); + // Re-darken inner ring + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = "rgba(0,0,0,0.6)"; + const sir = roi.radius_inner * displayScale * zoom; + ctx.beginPath(); ctx.arc(sx, sy, sir, 0, Math.PI * 2); ctx.fill(); + ctx.globalCompositeOperation = "destination-out"; + } + } + ctx.restore(); + } + + ctx.save(); + ctx.scale(DPR, DPR); + for (let ri = 0; ri < roiList.length; ri++) { + const roi = roiList[ri]; + const isSelected = ri === roiSelectedIdx; + const screenX = (roi.col * displayScale - cx) * zoom + cx + panX; + const screenY = (roi.row * displayScale - cy) * zoom + cy + panY; + const screenRadius = roi.radius * displayScale * zoom; + const screenW = roi.width * displayScale * zoom; + const screenH = roi.height * displayScale * zoom; + const screenRadiusInner = roi.radius_inner * displayScale * zoom; + const shape = (roi.shape || "circle") as "circle" | "square" | "rectangle" | "annular"; + ctx.lineWidth = roi.line_width || 2; + drawROI(ctx, screenX, screenY, shape, screenRadius, screenW, screenH, roi.color || ROI_COLORS[ri % ROI_COLORS.length], roi.color || ROI_COLORS[ri % ROI_COLORS.length], isSelected && isDraggingROI, screenRadiusInner); + if (isSelected) { + ctx.setLineDash([4, 3]); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1; + if (shape === "circle" || shape === "annular") { + ctx.beginPath(); ctx.arc(screenX, screenY, screenRadius + 3, 0, Math.PI * 2); ctx.stroke(); + } else if (shape === "square") { + ctx.strokeRect(screenX - screenRadius - 3, screenY - screenRadius - 3, (screenRadius + 3) * 2, (screenRadius + 3) * 2); + } else if (shape === "rectangle") { + ctx.strokeRect(screenX - screenW / 2 - 3, screenY - screenH / 2 - 3, screenW + 6, screenH + 6); + } + ctx.setLineDash([]); + } + } + ctx.restore(); + } + + // Line profile overlay + if (profileActive && profilePoints.length > 0) { + const zs = getZoomState(i); + const { zoom, panX, panY } = zs; + ctx.save(); + ctx.scale(DPR, DPR); + + // Transform image coords to screen coords + const cx = canvasW / 2; + const cy = canvasH / 2; + const toScreenX = (ix: number) => (ix * displayScale - cx) * zoom + cx + panX; + const toScreenY = (iy: number) => (iy * displayScale - cy) * zoom + cy + panY; + + // Draw point A + const ax = toScreenX(profilePoints[0].col); + const ay = toScreenY(profilePoints[0].row); + ctx.fillStyle = themeColors.accent; + ctx.beginPath(); + ctx.arc(ax, ay, 4, 0, Math.PI * 2); + ctx.fill(); + + // Draw line and point B if complete + if (profilePoints.length === 2) { + const bx = toScreenX(profilePoints[1].col); + const by = toScreenY(profilePoints[1].row); + + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = themeColors.accent; + ctx.beginPath(); + ctx.arc(bx, by, 4, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + } + + // Distance measurement overlay + if (measureActive && measurePoints.length >= 1) { + const zs = getZoomState(i); + const { zoom, panX, panY } = zs; + ctx.save(); + ctx.scale(DPR, DPR); + const cx = canvasW / 2; + const cy = canvasH / 2; + const toSX = (ix: number) => (ix * displayScale - cx) * zoom + cx + panX; + const toSY = (iy: number) => (iy * displayScale - cy) * zoom + cy + panY; + + ctx.shadowColor = "rgba(0,0,0,0.6)"; + ctx.shadowBlur = 3; + + // Endpoint A + const ax = toSX(measurePoints[0].col); + const ay = toSY(measurePoints[0].row); + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.arc(ax, ay, 4, 0, Math.PI * 2); + ctx.fill(); + + if (measurePoints.length === 2) { + const bx = toSX(measurePoints[1].col); + const by = toSY(measurePoints[1].row); + + // Solid white line (distinct from profile's dashed accent line) + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + + // Endpoint B + ctx.beginPath(); + ctx.arc(bx, by, 4, 0, Math.PI * 2); + ctx.fill(); + + // Distance label + const dc = measurePoints[1].col - measurePoints[0].col; + const dr = measurePoints[1].row - measurePoints[0].row; + const distPx = Math.sqrt(dc * dc + dr * dr); + let label: string; + if (pixelSize > 0) { + const distA = distPx * pixelSize; + label = distA >= 10 ? `${(distA / 10).toFixed(2)} nm` : `${distA.toFixed(2)} Å`; + } else { + label = `${distPx.toFixed(1)} px`; + } + + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + ctx.font = "bold 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillStyle = "#fff"; + ctx.fillText(label, mx, my - 8); + } + + ctx.shadowBlur = 0; + ctx.restore(); + } + } + }, [nImages, pixelSize, scaleBarVisible, selectedIdx, isGallery, canvasW, canvasH, width, displayScale, linkedZoom, linkedZoomState, zoomStates, dataVersion, showColorbar, cmap, offscreenVersion, logScale, profileActive, profilePoints, roiActive, roiList, roiSelectedIdx, isDraggingROI, themeColors, measureActive, measurePoints]); + + // ------------------------------------------------------------------------- + // Inset magnifier (lens) — renders magnified region at cursor in bottom-left + // ------------------------------------------------------------------------- + React.useEffect(() => { + const lensCanvas = lensCanvasRef.current; + if (lensCanvas) { + const lctx = lensCanvas.getContext("2d"); + if (lctx) lctx.clearRect(0, 0, lensCanvas.width, lensCanvas.height); + } + if (!showLens || isGallery || !lensPos || !rawDataRef.current?.[0]) return; + if (!lensCanvas) return; + const ctx = lensCanvas.getContext("2d"); + if (!ctx) return; + + const raw = rawDataRef.current[0]; + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + // Use cached vmin/vmax from data effect (avoids full-image applyLogScale + findDataRange) + const vmin = colorbarVminRef.current; + const vmax = colorbarVmaxRef.current; + + // Extract region around cursor — regionSize = displaySize / magnification + const regionSize = Math.max(4, Math.round(lensDisplaySize / lensMag)); + const lensSize = lensDisplaySize; + const margin = 12; + const half = Math.floor(regionSize / 2); + const r0 = lensPos.row - half; + const c0 = lensPos.col - half; + + // Create small offscreen canvas for the region + const regionCanvas = document.createElement("canvas"); + regionCanvas.width = regionSize; + regionCanvas.height = regionSize; + const rctx = regionCanvas.getContext("2d"); + if (!rctx) return; + const imgData = rctx.createImageData(regionSize, regionSize); + const range = vmax - vmin || 1; + for (let dr = 0; dr < regionSize; dr++) { + for (let dc = 0; dc < regionSize; dc++) { + const sr = r0 + dr; + const sc = c0 + dc; + const idx = (dr * regionSize + dc) * 4; + if (sr < 0 || sr >= height || sc < 0 || sc >= width) { + imgData.data[idx] = 0; imgData.data[idx + 1] = 0; imgData.data[idx + 2] = 0; imgData.data[idx + 3] = 255; + } else { + // Apply log scale inline per-pixel (only for the small region, not full image) + const rawVal = raw[sr * width + sc]; + const val = logScale ? Math.log1p(rawVal) : rawVal; + const t = Math.max(0, Math.min(1, (val - vmin) / range)); + const li = Math.round(t * 255); + imgData.data[idx] = lut[li * 3]; imgData.data[idx + 1] = lut[li * 3 + 1]; imgData.data[idx + 2] = lut[li * 3 + 2]; imgData.data[idx + 3] = 255; + } + } + } + rctx.putImageData(imgData, 0, 0); + + // Draw lens inset on overlay — use custom anchor or default bottom-left + ctx.save(); + ctx.scale(DPR, DPR); + const lx = lensAnchor ? lensAnchor.x : margin; + const ly = lensAnchor ? lensAnchor.y : canvasH - lensSize - margin - 20; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(regionCanvas, lx, ly, lensSize, lensSize); + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 2; + ctx.strokeRect(lx, ly, lensSize, lensSize); + // Crosshair at center + const cx = lx + lensSize / 2; + const cy = ly + lensSize / 2; + ctx.strokeStyle = "rgba(255,255,255,0.5)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx - 8, cy); ctx.lineTo(cx + 8, cy); + ctx.moveTo(cx, cy - 8); ctx.lineTo(cx, cy + 8); + ctx.stroke(); + // Magnification label + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.font = "10px monospace"; + ctx.fillText(`${lensMag}×`, lx + 4, ly + lensSize - 4); + ctx.restore(); + }, [showLens, lensPos, isGallery, cmap, logScale, offscreenVersion, width, height, canvasH, themeColors, lensMag, lensDisplaySize, lensAnchor]); + + // ------------------------------------------------------------------------- + // Auto-compute profile when profile_line is set (e.g. from Python) + // ------------------------------------------------------------------------- + React.useEffect(() => { + if (profilePoints.length === 2 && rawDataRef.current) { + const p0 = profilePoints[0], p1 = profilePoints[1]; + const allProfiles: (Float32Array | null)[] = []; + for (let i = 0; i < rawDataRef.current.length; i++) { + const raw = rawDataRef.current[i]; + allProfiles.push(raw ? sampleLineProfile(raw, width, height, p0.row, p0.col, p1.row, p1.col) : null); + } + setProfileDataAll(allProfiles); + if (!profileActive) setProfileActive(true); + } + }, [profilePoints, dataVersion, profileActive]); + + // ------------------------------------------------------------------------- + // Render sparkline for line profile + // ------------------------------------------------------------------------- + React.useEffect(() => { + const canvas = profileCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const cssW = profileCanvasWidth; + const cssH = profileHeight; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + ctx.scale(dpr, dpr); + + const isDark = themeInfo.theme === "dark"; + ctx.fillStyle = isDark ? "#1a1a1a" : "#f0f0f0"; + ctx.fillRect(0, 0, cssW, cssH); + + const hasData = profileDataAll.some(d => d && d.length >= 2); + if (!hasData) { + ctx.font = "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#555" : "#999"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Click two points on the image to draw a profile", cssW / 2, cssH / 2); + return; + } + + const padLeft = 40; + const padRight = 8; + const padTop = 6; + const padBottom = 18; + const plotW = cssW - padLeft - padRight; + const plotH = cssH - padTop - padBottom; + + // Find global min/max across all profiles + let gMin = Infinity, gMax = -Infinity; + for (const d of profileDataAll) { + if (!d) continue; + for (let i = 0; i < d.length; i++) { + if (d[i] < gMin) gMin = d[i]; + if (d[i] > gMax) gMax = d[i]; + } + } + const range = gMax - gMin || 1; + + // Draw each profile + const colors = profileDataAll.length === 1 ? [themeColors.accent] : PROFILE_COLORS; + for (let pIdx = 0; pIdx < profileDataAll.length; pIdx++) { + const d = profileDataAll[pIdx]; + if (!d || d.length < 2) continue; + ctx.strokeStyle = colors[pIdx % colors.length]; + ctx.lineWidth = pIdx === selectedIdx || profileDataAll.length === 1 ? 1.5 : 1; + ctx.globalAlpha = pIdx === selectedIdx || profileDataAll.length === 1 ? 1 : 0.5; + ctx.beginPath(); + for (let i = 0; i < d.length; i++) { + const x = padLeft + (i / (d.length - 1)) * plotW; + const y = padTop + plotH - ((d[i] - gMin) / range) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + ctx.globalAlpha = 1; + + // Compute total distance for x-axis + const firstProfile = profileDataAll.find(d => d); + let totalDist = (firstProfile?.length ?? 2) - 1; + let xUnit = "px"; + if (profilePoints.length === 2) { + const dx = profilePoints[1].col - profilePoints[0].col; + const dy = profilePoints[1].row - profilePoints[0].row; + const distPx = Math.sqrt(dx * dx + dy * dy); + if (pixelSize > 0) { + totalDist = distPx * pixelSize; + xUnit = pixelUnit; + } else { + totalDist = distPx; + } + } + + // Draw x-axis ticks + const tickY = padTop + plotH; + ctx.strokeStyle = isDark ? "#555" : "#bbb"; + ctx.lineWidth = 0.5; + const idealTicks = Math.max(2, Math.floor(plotW / 70)); + const tickStep = roundToNiceValue(totalDist / idealTicks); + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#888" : "#666"; + ctx.textBaseline = "top"; + const ticks: number[] = []; + for (let v = 0; v <= totalDist + tickStep * 0.01; v += tickStep) { + if (v > totalDist * 1.001) break; + ticks.push(v); + } + for (let i = 0; i < ticks.length; i++) { + const v = ticks[i]; + const frac = totalDist > 0 ? v / totalDist : 0; + const x = padLeft + frac * plotW; + ctx.beginPath(); ctx.moveTo(x, tickY); ctx.lineTo(x, tickY + 3); ctx.stroke(); + ctx.textAlign = frac < 0.05 ? "left" : frac > 0.95 ? "right" : "center"; + const valStr = v % 1 === 0 ? v.toFixed(0) : v.toFixed(1); + ctx.fillText(i === ticks.length - 1 ? `${valStr} ${xUnit}` : valStr, x, tickY + 4); + } + + // Draw y-axis min/max labels + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#888" : "#666"; + ctx.textAlign = "right"; + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(gMax), padLeft - 3, padTop); + ctx.textBaseline = "bottom"; + ctx.fillText(formatNumber(gMin), padLeft - 3, padTop + plotH); + + // Draw axis lines + ctx.strokeStyle = isDark ? "#555" : "#bbb"; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(padLeft, padTop); + ctx.lineTo(padLeft, padTop + plotH); + ctx.lineTo(padLeft + plotW, padTop + plotH); + ctx.stroke(); + + // Legend (gallery mode with multiple images) + if (profileDataAll.length > 1) { + ctx.textAlign = "right"; + ctx.textBaseline = "top"; + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + let legendX = cssW - 4; + for (let pIdx = profileDataAll.length - 1; pIdx >= 0; pIdx--) { + if (!profileDataAll[pIdx]) continue; + const label = labels?.[pIdx] || `#${pIdx + 1}`; + const color = colors[pIdx % colors.length]; + const textW = ctx.measureText(label).width; + ctx.globalAlpha = pIdx === selectedIdx ? 1 : 0.5; + ctx.fillStyle = color; + ctx.fillRect(legendX - textW - 10, 2, 6, 6); + ctx.fillStyle = isDark ? "#aaa" : "#555"; + ctx.fillText(label, legendX, 1); + legendX -= textW + 16; + } + ctx.globalAlpha = 1; + } + + // Save base rendering + layout for hover overlay + profileBaseImageRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + profileLayoutRef.current = { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit }; + }, [profileDataAll, themeInfo.theme, themeColors.accent, profilePoints, pixelSize, selectedIdx, labels, profileCanvasWidth, profileHeight]); + + // Profile hover handler — draws crosshair + value readout + const handleProfileMouseMove = React.useCallback((e: React.MouseEvent) => { + const canvas = profileCanvasRef.current; + const base = profileBaseImageRef.current; + const layout = profileLayoutRef.current; + if (!canvas || !base || !layout) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit } = layout; + const range = gMax - gMin || 1; + + // Restore base image + ctx.putImageData(base, 0, 0); + + if (cssX < padLeft || cssX > padLeft + plotW) return; + const frac = (cssX - padLeft) / plotW; + + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Vertical crosshair + ctx.strokeStyle = themeInfo.theme === "dark" ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(cssX, padTop); + ctx.lineTo(cssX, padTop + plotH); + ctx.stroke(); + ctx.setLineDash([]); + + // Dot on each profile line + collect values + const colors = profileDataAll.length === 1 ? [themeColors.accent] : PROFILE_COLORS; + const activeIdx = isGallery ? selectedIdx : 0; + let displayVal: number | null = null; + for (let pIdx = 0; pIdx < profileDataAll.length; pIdx++) { + const d = profileDataAll[pIdx]; + if (!d || d.length < 2) continue; + const dataIdx = Math.min(d.length - 1, Math.max(0, Math.round(frac * (d.length - 1)))); + const val = d[dataIdx]; + const y = padTop + plotH - ((val - gMin) / range) * plotH; + ctx.fillStyle = colors[pIdx % colors.length]; + ctx.globalAlpha = pIdx === activeIdx || profileDataAll.length === 1 ? 1 : 0.5; + ctx.beginPath(); + ctx.arc(cssX, y, 3, 0, Math.PI * 2); + ctx.fill(); + if (pIdx === activeIdx || profileDataAll.length === 1) displayVal = val; + } + ctx.globalAlpha = 1; + + // Value readout label + if (displayVal !== null) { + const dist = frac * totalDist; + const label = `${formatNumber(displayVal)} @ ${dist.toFixed(1)} ${xUnit}`; + const isDark = themeInfo.theme === "dark"; + ctx.font = "bold 9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + const textW = ctx.measureText(label).width; + const labelX = Math.min(cssX + 6, padLeft + plotW - textW - 2); + const labelY = padTop + 2; + ctx.fillStyle = isDark ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)"; + ctx.fillRect(labelX - 2, labelY - 1, textW + 4, 11); + ctx.fillStyle = isDark ? "#fff" : "#000"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(label, labelX, labelY); + } + + ctx.restore(); + }, [profileDataAll, themeInfo.theme, themeColors.accent, isGallery, selectedIdx]); + + const handleProfileMouseLeave = React.useCallback(() => { + const canvas = profileCanvasRef.current; + const base = profileBaseImageRef.current; + if (!canvas || !base) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.putImageData(base, 0, 0); + }, []); + + // ------------------------------------------------------------------------- + // Compute FFT magnitude (cached — only recomputes when data changes) + // Supports ROI-scoped FFT: when ROI is active with a selected ROI, compute + // FFT of the cropped region instead of the full image. + // ------------------------------------------------------------------------- + React.useEffect(() => { + if (!effectiveShowFft || isGallery || !rawDataRef.current) return; + if (!rawDataRef.current[selectedIdx]) return; + // Generation counter: coalesces rapid ROI drag events so at most one + // FFT runs per animation frame. The rAF yield lets the browser paint + // the ROI position update before the (potentially blocking) FFT runs. + const gen = ++fftGenRef.current; + + const doCompute = async () => { + // Yield to next animation frame — browser paints updated ROI first, + // and stale requests (from earlier drag events) are discarded below. + await new Promise(r => requestAnimationFrame(() => r())); + if (gen !== fftGenRef.current) return; + + // Wait for WebGPU init if it's still in flight — avoids first-call CPU race. + if (!gpuReadyRef.current) { + try { + const fft = await getWebGPUFFT(); + if (fft) { gpuFFTRef.current = fft; gpuReadyRef.current = true; } + } catch (_e) { /* fall to CPU */ } + if (gen !== fftGenRef.current) return; + } + const backend = gpuFFTRef.current && gpuReadyRef.current ? "WebGPU" : "CPU Worker"; + setFftComputing(true); + setFftProgress(`Computing FFT… (${backend})`); + const t0 = performance.now(); + const data = rawDataRef.current![selectedIdx]; + let fftW = width; + let fftH = height; + let inputData = data; + + // ROI crop: extract bounding box and optionally zero-mask outside radius + let origCropW = 0, origCropH = 0; + if (roiFftActive && roiList && roiSelectedIdx >= 0 && roiSelectedIdx < roiList.length) { + const roi = roiList[roiSelectedIdx]; + const crop = cropROIRegion(data, width, height, roi); + if (crop) { + origCropW = crop.cropW; + origCropH = crop.cropH; + // Apply Hann window to crop at native dimensions BEFORE zero-padding + if (fftWindow) applyHannWindow2D(crop.cropped, crop.cropW, crop.cropH); + // Pad to next power-of-2 so fft2d doesn't truncate frequency data + const padW = nextPow2(crop.cropW); + const padH = nextPow2(crop.cropH); + const padded = new Float32Array(padW * padH); + for (let y = 0; y < crop.cropH; y++) { + for (let x = 0; x < crop.cropW; x++) { + padded[y * padW + x] = crop.cropped[y * crop.cropW + x]; + } + } + inputData = padded; + fftW = padW; + fftH = padH; + } + } + + // Pre-pad non-power-of-2 full images so fft2d doesn't truncate frequency data + if (origCropW === 0) { + const padW = nextPow2(fftW); + const padH = nextPow2(fftH); + if (padW !== fftW || padH !== fftH) { + const padded = new Float32Array(padW * padH); + for (let y = 0; y < fftH; y++) { + for (let x = 0; x < fftW; x++) { + padded[y * padW + x] = inputData[y * fftW + x]; + } + } + inputData = padded; + fftW = padW; + fftH = padH; + } + } + + const tCrop = performance.now(); + const real = inputData.slice(); + const imag = new Float32Array(inputData.length); + + if (gpuFFTRef.current && gpuReadyRef.current) { + const result = await gpuFFTRef.current.fft2D(real, imag, fftW, fftH, false); + if (gen !== fftGenRef.current) return; + const tGpu = performance.now(); + fftshift(result.real, fftW, fftH); + fftshift(result.imag, fftW, fftH); + fftMagCacheRef.current = computeMagnitude(result.real, result.imag); + console.log(`[Show2D FFT] GPU ${fftW}×${fftH}: crop=${(tCrop-t0).toFixed(1)}ms gpu=${(tGpu-tCrop).toFixed(1)}ms post=${(performance.now()-tGpu).toFixed(1)}ms`); + } else { + // CPU fallback: run in Web Worker to avoid blocking the main thread + const result = await fft2dAsync(real, imag, fftW, fftH, false); + if (gen !== fftGenRef.current) return; + fftMagCacheRef.current = result.magnitude; + console.log(`[Show2D FFT] Worker ${fftW}×${fftH}: crop=${(tCrop-t0).toFixed(1)}ms worker=${(performance.now()-tCrop).toFixed(1)}ms`); + } + // Track FFT dimensions when they differ from image dimensions (ROI crop or non-pow2 padding) + if (origCropW > 0) { + setFftCropDims({ cropWidth: origCropW, cropHeight: origCropH, fftWidth: fftW, fftHeight: fftH }); + } else if (fftW !== width || fftH !== height) { + setFftCropDims({ cropWidth: width, cropHeight: height, fftWidth: fftW, fftHeight: fftH }); + } else { + setFftCropDims(null); + } + setFftMagVersion(v => v + 1); + setFftComputing(false); + setFftProgress(""); + }; + + doCompute(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveShowFft, isGallery, selectedIdx, width, height, dataVersion, roiFftKey, fftWindow]); + + // Clear FFT measurement when image, FFT state, or ROI changes + React.useEffect(() => { setFftClickInfo(null); }, [selectedIdx, effectiveShowFft, roiFftActive, roiSelectedIdx]); + + // ------------------------------------------------------------------------- + // FFT data effect: normalize + colormap → cached offscreen canvas + // (does NOT depend on fftZoom/fftPanX/fftPanY — avoids reprocessing on zoom/pan) + // ------------------------------------------------------------------------- + React.useEffect(() => { + if (!effectiveShowFft || isGallery || !fftMagCacheRef.current) return; + + const fftMag = fftMagCacheRef.current; + const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; + + // Use crop dimensions when ROI FFT is active + const fftW = fftCropDims?.fftWidth ?? width; + const fftH = fftCropDims?.fftHeight ?? height; + + // Heavy steps (log/power transform, range, stats, histogram-data copy) only + // when source magnitude OR scale-mode changed — NOT on every contrast slider tick. + // Cached values live in fftPipelineRef for cheap re-renders. + const sourceChanged = ( + fftPipelineRef.current?.magVersion !== fftMagVersion || + fftPipelineRef.current?.scaleMode !== fftScaleMode || + fftPipelineRef.current?.fftAuto !== fftAuto + ); + if (sourceChanged) { + const magnitude = new Float32Array(fftMag.length); + for (let i = 0; i < fftMag.length; i++) { + if (fftScaleMode === "log") magnitude[i] = Math.log1p(fftMag[i]); + else if (fftScaleMode === "power") magnitude[i] = Math.pow(fftMag[i], 0.5); + else magnitude[i] = fftMag[i]; + } + let displayMin: number, displayMax: number; + if (fftAuto) ({ min: displayMin, max: displayMax } = autoEnhanceFFT(magnitude, fftW, fftH)); + else ({ min: displayMin, max: displayMax } = findDataRange(magnitude)); + const { mean, std } = computeStats(magnitude); + setFftStats([mean, displayMin, displayMax, std]); + setFftHistogramData(magnitude); // no .slice() — magnitude is fresh + setFftDataRange({ min: displayMin, max: displayMax }); + fftPipelineRef.current = { magnitude, displayMin, displayMax, magVersion: fftMagVersion, scaleMode: fftScaleMode, fftAuto }; + } + + const cache = fftPipelineRef.current!; + const { vmin, vmax } = sliderRange(cache.displayMin, cache.displayMax, fftVminPct, fftVmaxPct); + + // GPU colormap path for FFT — uses dedicated slot at index nImages. + // Uploads magnitude only when source changed; contrast/cmap drag triggers cheap re-render. + const engine = gpuCmapRef.current; + const fftSlot = nImages; // dedicate slot just past main image slots + if (engine && gpuCmapReadyRef.current) { + try { + if (sourceChanged) engine.uploadData(fftSlot, cache.magnitude, fftW, fftH); + engine.uploadLUT(fftColormap, lut); + const bitmaps = engine.renderSlotsToImageBitmap([fftSlot], [{ vmin, vmax }], false); + if (bitmaps && bitmaps[0]) { + const oc = fftOffscreenRef.current && fftOffscreenRef.current.width === fftW && fftOffscreenRef.current.height === fftH + ? fftOffscreenRef.current + : Object.assign(document.createElement("canvas"), { width: fftW, height: fftH }); + const ctx = oc.getContext("2d"); + if (ctx) { + ctx.drawImage(bitmaps[0], 0, 0); + fftOffscreenRef.current = oc; + setFftOffscreenVersion(v => v + 1); + return; + } + } + } catch (_e) { /* fall through to CPU */ } + } + // CPU fallback + const offscreen = renderToOffscreen(cache.magnitude, fftW, fftH, lut, vmin, vmax); + if (!offscreen) return; + fftOffscreenRef.current = offscreen; + setFftOffscreenVersion(v => v + 1); + }, [effectiveShowFft, isGallery, fftMagVersion, fftVminPct, fftVmaxPct, fftColormap, fftScaleMode, fftAuto, width, height, fftCropDims, nImages]); + + // ------------------------------------------------------------------------- + // FFT draw effect: cheap drawImage from cached offscreen (zoom/pan changes) + // ------------------------------------------------------------------------- + React.useLayoutEffect(() => { + if (!effectiveShowFft || isGallery || !fftCanvasRef.current || !fftOffscreenRef.current) return; + + const canvas = fftCanvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const offscreen = fftOffscreenRef.current; + const fftW = offscreen.width; + const fftH = offscreen.height; + + // Use bilinear smoothing when FFT is smaller than canvas (avoids blocky upscaling) + ctx.imageSmoothingEnabled = fftSmooth || (fftW < canvasW || fftH < canvasH); + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.save(); + + const centerOffsetX = (canvasW - canvasW * fftZoom) / 2 + fftPanX; + const centerOffsetY = (canvasH - canvasH * fftZoom) / 2 + fftPanY; + + ctx.translate(centerOffsetX, centerOffsetY); + ctx.scale(fftZoom, fftZoom); + // Stretch cropped FFT to fill the full canvas (no layout change during drag) + ctx.drawImage(offscreen, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); + ctx.restore(); + }, [effectiveShowFft, isGallery, fftOffscreenVersion, canvasW, canvasH, fftZoom, fftPanX, fftPanY, fftSmooth]); + + // ------------------------------------------------------------------------- + // Render FFT overlay (scale bar + colorbar + d-spacing marker) + // ------------------------------------------------------------------------- + React.useEffect(() => { + const overlay = fftOverlayRef.current; + if (!overlay || !effectiveShowFft || isGallery) return; + const ctx = overlay.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, overlay.width, overlay.height); + + // Use crop dimensions for reciprocal-space calculations + const fftW = fftCropDims?.fftWidth ?? width; + + // FFT colorbar + if (fftShowColorbar && fftDataRange.min !== fftDataRange.max) { + const { vmin, vmax } = sliderRange(fftDataRange.min, fftDataRange.max, fftVminPct, fftVmaxPct); + const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; + ctx.save(); + ctx.scale(DPR, DPR); + const cssW = overlay.width / DPR; + const cssH = overlay.height / DPR; + drawColorbar(ctx, cssW, cssH, lut, vmin, vmax, fftScaleMode === "log"); + ctx.restore(); + } + + // D-spacing crosshair marker — use crop dims for coordinate mapping + const fftH = fftCropDims?.fftHeight ?? height; + if (fftClickInfo) { + ctx.save(); + ctx.scale(DPR, DPR); + const centerOffsetX = (canvasW - canvasW * fftZoom) / 2 + fftPanX; + const centerOffsetY = (canvasH - canvasH * fftZoom) / 2 + fftPanY; + const screenX = centerOffsetX + fftZoom * (fftClickInfo.col / fftW * canvasW); + const screenY = centerOffsetY + fftZoom * (fftClickInfo.row / fftH * canvasH); + ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; + ctx.shadowColor = "rgba(0, 0, 0, 0.6)"; + ctx.shadowBlur = 2; + ctx.lineWidth = 1.5; + const r = 8; + ctx.beginPath(); + ctx.moveTo(screenX - r, screenY); ctx.lineTo(screenX - 3, screenY); + ctx.moveTo(screenX + 3, screenY); ctx.lineTo(screenX + r, screenY); + ctx.moveTo(screenX, screenY - r); ctx.lineTo(screenX, screenY - 3); + ctx.moveTo(screenX, screenY + 3); ctx.lineTo(screenX, screenY + r); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(screenX, screenY, 4, 0, Math.PI * 2); + ctx.stroke(); + if (fftClickInfo.dSpacing != null) { + const d = fftClickInfo.dSpacing; + const label = d >= 10 ? `d = ${(d / 10).toFixed(2)} nm` : `d = ${d.toFixed(2)} Å`; + ctx.font = "bold 11px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = "white"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, screenX + 10, screenY - 4); + } + ctx.restore(); + } + }, [effectiveShowFft, isGallery, fftClickInfo, canvasW, canvasH, fftZoom, fftPanX, fftPanY, width, height, pixelSize, fftDataRange, fftVminPct, fftVmaxPct, fftColormap, fftScaleMode, fftShowColorbar, fftCropDims]); + + // ------------------------------------------------------------------------- + // Compute FFT magnitudes for gallery mode (cache raw magnitudes) + // ------------------------------------------------------------------------- + React.useEffect(() => { + if (!effectiveShowFft || !isGallery || !rawDataRef.current) return; + if (rawDataRef.current.length === 0) return; + let cancelled = false; + + const computeAllFFTs = async () => { + // Wait for WebGPU init if it's still in flight — avoids first-call CPU race. + if (!gpuReadyRef.current) { + try { + const fft = await getWebGPUFFT(); + if (fft) { gpuFFTRef.current = fft; gpuReadyRef.current = true; } + } catch (_e) { /* fall to CPU */ } + if (cancelled) return; + } + // Initialize cache; preserve existing entries (only recompute missing) + if (fftMagCacheGalleryRef.current.length !== nImages) { + fftMagCacheGalleryRef.current = new Array(nImages).fill(null); + } + setFftComputing(true); + const useGPU = !!(gpuFFTRef.current && gpuReadyRef.current); + const backend = useGPU ? "WebGPU" : "CPU Worker"; + setFftProgress(`FFT (${backend})`); + await new Promise(r => requestAnimationFrame(() => r())); + if (cancelled) { setFftComputing(false); return; } + + const useRoiCrop = roiFftActive && roiList && roiSelectedIdx >= 0 && roiSelectedIdx < roiList.length; + const roi = useRoiCrop ? roiList[roiSelectedIdx] : null; + const t0 = performance.now(); + + // Helper: prep one image for FFT (crop, pad, window) + const prepOne = (idx: number): { real: Float32Array; imag: Float32Array; w: number; h: number } | null => { + const data = rawDataRef.current![idx]; + if (!data) return null; + let inputData = data; + let curW = width, curH = height; + if (roi) { + const crop = cropROIRegion(data, width, height, roi); + if (crop) { + if (fftWindow) applyHannWindow2D(crop.cropped, crop.cropW, crop.cropH); + const padW = nextPow2(crop.cropW), padH = nextPow2(crop.cropH); + const padded = new Float32Array(padW * padH); + for (let y = 0; y < crop.cropH; y++) + for (let x = 0; x < crop.cropW; x++) + padded[y * padW + x] = crop.cropped[y * crop.cropW + x]; + inputData = padded; curW = padW; curH = padH; + } + } else { + const padW = nextPow2(curW), padH = nextPow2(curH); + if (padW !== curW || padH !== curH) { + const padded = new Float32Array(padW * padH); + for (let y = 0; y < curH; y++) + for (let x = 0; x < curW; x++) + padded[y * padW + x] = inputData[y * curW + x]; + inputData = padded; curW = padW; curH = padH; + } + } + return { real: inputData.slice(), imag: new Float32Array(inputData.length), w: curW, h: curH }; + }; + + // ── Prep all images ── + const inputs: { real: Float32Array; imag: Float32Array }[] = []; + let fftW = width, fftH = height; + for (let idx = 0; idx < nImages; idx++) { + const input = prepOne(idx); + if (input) { + fftW = input.w; fftH = input.h; + inputs.push({ real: input.real, imag: input.imag }); + } else { + inputs.push({ real: new Float32Array(0), imag: new Float32Array(0) }); + } + } + galleryFftDimsRef.current = { w: fftW, h: fftH }; + const tPrep = performance.now() - t0; + if (cancelled) { setFftComputing(false); return; } + + // ── Batched progressive FFT: batch BATCH_SIZE at a time, display after each batch ── + const BATCH_SIZE = 4; + const tFFT0 = performance.now(); + for (let batchStart = 0; batchStart < nImages; batchStart += BATCH_SIZE) { + if (cancelled) { setFftComputing(false); return; } + const batchEnd = Math.min(batchStart + BATCH_SIZE, nImages); + const batchInputs = inputs.slice(batchStart, batchEnd).filter(inp => inp.real.length > 0); + setFftProgress(`FFT ${batchStart + 1}–${batchEnd}/${nImages} (${backend})`); + + if (useGPU && batchInputs.length > 1) { + // GPU batch: one submission for BATCH_SIZE images + const batchResults = await gpuFFTRef.current!.fft2DBatch(batchInputs, fftW, fftH); + if (cancelled) { setFftComputing(false); return; } + let ri = 0; + for (let idx = batchStart; idx < batchEnd; idx++) { + if (inputs[idx].real.length === 0) continue; + fftshift(batchResults[ri].real, fftW, fftH); + fftshift(batchResults[ri].imag, fftW, fftH); + fftMagCacheGalleryRef.current[idx] = computeMagnitude(batchResults[ri].real, batchResults[ri].imag); + ri++; + } + } else { + // CPU or single image + for (let idx = batchStart; idx < batchEnd; idx++) { + if (inputs[idx].real.length === 0) continue; + if (cancelled) { setFftComputing(false); return; } + const { real, imag } = inputs[idx]; + if (useGPU) { + const result = await gpuFFTRef.current!.fft2D(real, imag, fftW, fftH, false); + fftshift(result.real, fftW, fftH); + fftshift(result.imag, fftW, fftH); + fftMagCacheGalleryRef.current[idx] = computeMagnitude(result.real, result.imag); + } else { + fft2d(real, imag, fftW, fftH, false); + fftshift(real, fftW, fftH); + fftshift(imag, fftW, fftH); + fftMagCacheGalleryRef.current[idx] = computeMagnitude(real, imag); + } + } + } + // Show this batch immediately (progressive top-to-bottom) + setGalleryFftMagVersion(v => v + 1); + // Yield to let the browser paint the batch + await new Promise(r => requestAnimationFrame(() => r())); + } + const tFFT = performance.now() - tFFT0; + const tTotal = performance.now() - t0; + if (!cancelled) { + console.log(`[Show2D FFT] Gallery ${nImages}×${fftW}×${fftH}: prep=${tPrep.toFixed(0)}ms fft=${tFFT.toFixed(0)}ms total=${tTotal.toFixed(0)}ms (${backend} batch=${BATCH_SIZE})`); + } + setFftComputing(false); + setFftProgress(""); + }; + + computeAllFFTs(); + + return () => { cancelled = true; setFftComputing(false); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveShowFft, isGallery, nImages, width, height, dataVersion, roiFftKey, fftWindow]); + + // Gallery FFT data effect: normalize + colormap → cached offscreen canvases + // (does NOT depend on gallery zoom/pan states) + const [galleryFftOffscreenVersion, setGalleryFftOffscreenVersion] = React.useState(0); + React.useEffect(() => { + if (!effectiveShowFft || !isGallery) return; + const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; + const fftW = galleryFftDimsRef.current?.w ?? width; + const fftH = galleryFftDimsRef.current?.h ?? height; + + for (let idx = 0; idx < nImages; idx++) { + const magnitude = fftMagCacheGalleryRef.current[idx]; + if (!magnitude) continue; + + // Apply scale transform (same logic as single mode) + let displayData: Float32Array; + let displayMin: number, displayMax: number; + if (fftScaleMode === "log") { + displayData = applyLogScale(magnitude); + } else if (fftScaleMode === "power") { + displayData = new Float32Array(magnitude.length); + for (let j = 0; j < magnitude.length; j++) displayData[j] = Math.sqrt(magnitude[j]); + } else { + displayData = magnitude; + } + if (fftAuto) { + ({ min: displayMin, max: displayMax } = autoEnhanceFFT(magnitude, fftW, fftH)); + if (fftScaleMode === "log") { displayMin = Math.log1p(displayMin); displayMax = Math.log1p(displayMax); } + else if (fftScaleMode === "power") { displayMin = Math.sqrt(displayMin); displayMax = Math.sqrt(displayMax); } + } else { + ({ min: displayMin, max: displayMax } = findDataRange(displayData)); + } + const fc = fftContrastFor(idx); + const { vmin, vmax } = sliderRange(displayMin, displayMax, fc.vminPct, fc.vmaxPct); + + const offscreen = renderToOffscreen(displayData, fftW, fftH, lut, vmin, vmax); + if (!offscreen) continue; + fftOffscreensRef.current[idx] = offscreen; + } + + // Update FFT histogram from selected image + const selMag = fftMagCacheGalleryRef.current[selectedIdx]; + if (selMag) { + let histData: Float32Array; + if (fftScaleMode === "log") histData = applyLogScale(selMag); + else if (fftScaleMode === "power") { histData = new Float32Array(selMag.length); for (let j = 0; j < selMag.length; j++) histData[j] = Math.sqrt(selMag[j]); } + else histData = selMag; + setFftHistogramData(histData); + setFftDataRange(findDataRange(histData)); + } + setGalleryFftOffscreenVersion(v => v + 1); + }, [effectiveShowFft, isGallery, nImages, width, height, galleryFftMagVersion, fftColormap, fftScaleMode, fftAuto, fftVminPct, fftVmaxPct, selectedIdx, fftLinkedContrast, fftContrastStates]); + + // Gallery FFT draw effect: cheap drawImage from cached offscreens (zoom/pan changes) + React.useLayoutEffect(() => { + if (!effectiveShowFft || !isGallery) return; + const fftW = galleryFftDimsRef.current?.w ?? width; + const fftH = galleryFftDimsRef.current?.h ?? height; + + for (let idx = 0; idx < nImages; idx++) { + const offscreen = fftOffscreensRef.current[idx]; + const canvas = fftCanvasRefs.current[idx]; + if (!offscreen || !canvas) continue; + const ctx = canvas.getContext("2d"); + if (!ctx) continue; + + const { zoom, panX, panY } = getGalleryFftState(idx); + ctx.imageSmoothingEnabled = fftSmooth; + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.save(); + const cx = canvasW / 2; + const cy = canvasH / 2; + ctx.translate(cx + panX, cy + panY); + ctx.scale(zoom, zoom); + ctx.translate(-cx, -cy); + ctx.drawImage(offscreen, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); + ctx.restore(); + } + }, [effectiveShowFft, isGallery, nImages, canvasW, canvasH, width, height, galleryFftOffscreenVersion, galleryFftStates, fftLinkedZoom, linkedFftZoomState, fftSmooth]); + + // ------------------------------------------------------------------------- + // Mouse Handlers for Zoom/Pan + // ------------------------------------------------------------------------- + const handleWheel = (e: React.WheelEvent, idx: number) => { + // In gallery mode, only allow zoom on the selected image (unless linked) + if (isGallery && idx !== selectedIdx && !linkedZoom) return; + e.preventDefault(); // Prevent page scroll when zooming + + const canvas = canvasRefs.current[idx]; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + + // Get current zoom state + const zs = getZoomState(idx); + + // Mouse position relative to canvas (in canvas pixel coordinates) + const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); + + // Canvas center + const cx = canvas.width / 2; + const cy = canvas.height / 2; + + // Mouse position relative to the current view (accounting for pan and zoom) + // The transformation is: translate(cx + panX, cy + panY) -> scale(zoom) -> translate(-cx, -cy) + // So a point on screen at (screenX, screenY) maps to image space as: + // imageX = (screenX - cx - panX) / zoom + cx + const mouseImageX = (mouseCanvasX - cx - zs.panX) / zs.zoom + cx; + const mouseImageY = (mouseCanvasY - cy - zs.panY) / zs.zoom + cy; + + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zs.zoom * zoomFactor)); + + // Calculate new pan to keep the mouse position fixed on the same image point + // After zoom: screenX = (imageX - cx) * newZoom + cx + newPanX + // We want screenX to stay at mouseCanvasX, so: + // newPanX = mouseCanvasX - (imageX - cx) * newZoom - cx + const newPanX = mouseCanvasX - (mouseImageX - cx) * newZoom - cx; + const newPanY = mouseCanvasY - (mouseImageY - cy) * newZoom - cy; + + setZoomState(idx, { zoom: newZoom, panX: newPanX, panY: newPanY }); + }; + + const handleDoubleClick = (idx: number) => { + setZoomState(idx, initialZoomState); + }; + + // Reset view (zoom/pan only — preserves profile, FFT state, etc.) + const handleResetAll = () => { + setZoomStates(new Map()); + setLinkedZoomState(initialZoomState); + setGalleryFftStates(new Map()); + setLinkedFftZoomState({ zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 }); + setFftZoom(DEFAULT_FFT_ZOOM); + setFftPanX(0); + setFftPanY(0); + }; + + // FFT zoom/pan — cursor-anchored zoom matching FFT's own canvas transform. + // FFT render: translate(centerOffsetX, centerOffsetY) → scale(zoom) where + // centerOffsetX = (canvasW - canvasW*zoom)/2 + panX + // Solving for image-space u in [0,1]: + // u = (screenX - centerOffsetX) / (zoom * canvasW) + // After zoom change, keep screenX of mouse at u: + // newPanX = mouseX - (canvasW - canvasW*newZoom)/2 - newZoom*u*canvasW + const handleFftWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const canvas = fftCanvasRef.current; + if (!canvas) { + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + setFftZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, fftZoom * zoomFactor))); + return; + } + const rect = canvas.getBoundingClientRect(); + const mouseX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseY = (e.clientY - rect.top) * (canvas.height / rect.height); + const cw = canvas.width, ch = canvas.height; + const cOffX = (cw - cw * fftZoom) / 2 + fftPanX; + const cOffY = (ch - ch * fftZoom) / 2 + fftPanY; + const u = (mouseX - cOffX) / (fftZoom * cw); + const v = (mouseY - cOffY) / (fftZoom * ch); + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, fftZoom * zoomFactor)); + const newPanX = mouseX - (cw - cw * newZoom) / 2 - newZoom * u * cw; + const newPanY = mouseY - (ch - ch * newZoom) / 2 - newZoom * v * ch; + setFftZoom(newZoom); + setFftPanX(newPanX); + setFftPanY(newPanY); + }; + + const handleFftDoubleClick = () => { + setFftZoom(DEFAULT_FFT_ZOOM); + setFftPanX(0); + setFftPanY(0); + setFftClickInfo(null); + }; + + // Convert FFT canvas mouse position to FFT image pixel coordinates + const fftScreenToImg = (e: React.MouseEvent): { col: number; row: number } | null => { + const canvas = fftCanvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const cOffX = (canvasW - canvasW * fftZoom) / 2 + fftPanX; + const cOffY = (canvasH - canvasH * fftZoom) / 2 + fftPanY; + const fftW = fftCropDims?.fftWidth ?? width; + const fftH = fftCropDims?.fftHeight ?? height; + const imgCol = ((mouseX - cOffX) / fftZoom) / canvasW * fftW; + const imgRow = ((mouseY - cOffY) / fftZoom) / canvasH * fftH; + if (imgCol >= 0 && imgCol < fftW && imgRow >= 0 && imgRow < fftH) { + return { col: imgCol, row: imgRow }; + } + return null; + }; + + const handleFftMouseDown = (e: React.MouseEvent) => { + fftClickStartRef.current = { x: e.clientX, y: e.clientY }; + setIsDraggingFftPan(true); + setFftPanStart({ x: e.clientX, y: e.clientY, pX: fftPanX, pY: fftPanY }); + }; + + const handleFftMouseMove = (e: React.MouseEvent) => { + if (!isDraggingFftPan || !fftPanStart) return; + const dx = e.clientX - fftPanStart.x; + const dy = e.clientY - fftPanStart.y; + setFftPanX(fftPanStart.pX + dx); + setFftPanY(fftPanStart.pY + dy); + }; + + const handleFftMouseUp = (e: React.MouseEvent) => { + // Click detection for d-spacing measurement + if (fftClickStartRef.current) { + const dx = e.clientX - fftClickStartRef.current.x; + const dy = e.clientY - fftClickStartRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) < 3) { + const pos = fftScreenToImg(e); + if (pos) { + // Use crop dimensions when ROI FFT is active + const fftW = fftCropDims?.fftWidth ?? width; + const fftH = fftCropDims?.fftHeight ?? height; + let imgCol = pos.col; + let imgRow = pos.row; + // Snap to nearest Bragg spot (local max in FFT magnitude) + if (fftMagCacheRef.current) { + const snapped = findFFTPeak(fftMagCacheRef.current, fftW, fftH, imgCol, imgRow, FFT_SNAP_RADIUS); + imgCol = snapped.col; + imgRow = snapped.row; + } + const halfW = Math.floor(fftW / 2); + const halfH = Math.floor(fftH / 2); + const dcol = imgCol - halfW; + const drow = imgRow - halfH; + const distPx = Math.sqrt(dcol * dcol + drow * drow); + if (distPx < 1) { + setFftClickInfo(null); + } else { + let spatialFreq: number | null = null; + let dSpacing: number | null = null; + if (pixelSize > 0) { + const paddedW = nextPow2(fftW); + const paddedH = nextPow2(fftH); + const binC = ((Math.round(imgCol) - halfW) % fftW + fftW) % fftW; + const binR = ((Math.round(imgRow) - halfH) % fftH + fftH) % fftH; + const freqC = binC <= paddedW / 2 ? binC / (paddedW * pixelSize) : (binC - paddedW) / (paddedW * pixelSize); + const freqR = binR <= paddedH / 2 ? binR / (paddedH * pixelSize) : (binR - paddedH) / (paddedH * pixelSize); + spatialFreq = Math.sqrt(freqC * freqC + freqR * freqR); + dSpacing = spatialFreq > 0 ? 1 / spatialFreq : null; + } + setFftClickInfo({ row: imgRow, col: imgCol, distPx, spatialFreq, dSpacing }); + } + } + } + fftClickStartRef.current = null; + } + setIsDraggingFftPan(false); + setFftPanStart(null); + }; + + const handleFftMouseLeave = () => { + fftClickStartRef.current = null; + setIsDraggingFftPan(false); + setFftPanStart(null); + }; + + // Gallery FFT zoom/pan handlers (only selected image's FFT responds) + const handleGalleryFftWheel = (e: React.WheelEvent, idx: number) => { + if (isGallery && idx !== selectedIdx && !fftLinkedZoom) return; + e.preventDefault(); // Prevent page scroll when zooming FFT + const zs = getGalleryFftState(idx); + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + setGalleryFftState(idx, { ...zs, zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zs.zoom * zoomFactor)) }); + }; + + const handleGalleryFftMouseDown = (e: React.MouseEvent, idx: number) => { + if (isGallery && idx !== selectedIdx) { + setSelectedIdx(idx); + return; // Select first, don't start panning + } + const zs = getGalleryFftState(idx); + setFftPanningIdx(idx); + setIsDraggingFftPan(true); + setFftPanStart({ x: e.clientX, y: e.clientY, pX: zs.panX, pY: zs.panY }); + }; + + const handleGalleryFftMouseMove = (e: React.MouseEvent, idx: number) => { + if (!isDraggingFftPan || !fftPanStart || fftPanningIdx !== idx) return; + const dx = e.clientX - fftPanStart.x; + const dy = e.clientY - fftPanStart.y; + const zs = getGalleryFftState(idx); + setGalleryFftState(idx, { ...zs, panX: fftPanStart.pX + dx, panY: fftPanStart.pY + dy }); + }; + + const handleGalleryFftMouseUp = () => { + setIsDraggingFftPan(false); + setFftPanStart(null); + setFftPanningIdx(null); + }; + + // Track which image is being panned + const [panningIdx, setPanningIdx] = React.useState(null); + const clickStartRef = React.useRef<{ x: number; y: number } | null>(null); + const [draggingProfileEndpoint, setDraggingProfileEndpoint] = React.useState<0 | 1 | null>(null); + const [isDraggingProfileLine, setIsDraggingProfileLine] = React.useState(false); + const [hoveredProfileEndpoint, setHoveredProfileEndpoint] = React.useState<0 | 1 | null>(null); + const [isHoveringProfileLine, setIsHoveringProfileLine] = React.useState(false); + const profileDragStartRef = React.useRef<{ row: number; col: number; p0: { row: number; col: number }; p1: { row: number; col: number } } | null>(null); + + const screenToImg = (e: React.MouseEvent, idx: number): { imgCol: number; imgRow: number } => { + const canvas = canvasRefs.current[idx]; + if (!canvas) return { imgCol: 0, imgRow: 0 }; + const rect = canvas.getBoundingClientRect(); + const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); + const zs = getZoomState(idx); + const cx = canvasW / 2; + const cy = canvasH / 2; + return { + imgCol: ((mouseCanvasX - cx - zs.panX) / zs.zoom + cx) / displayScale, + imgRow: ((mouseCanvasY - cy - zs.panY) / zs.zoom + cy) / displayScale, + }; + }; + + const updateAllProfileData = (p0: { row: number; col: number }, p1: { row: number; col: number }) => { + if (!rawDataRef.current) return; + const allProfiles: (Float32Array | null)[] = []; + for (let j = 0; j < rawDataRef.current.length; j++) { + const raw = rawDataRef.current[j]; + allProfiles.push(raw ? sampleLineProfile(raw, width, height, p0.row, p0.col, p1.row, p1.col) : null); + } + setProfileDataAll(allProfiles); + }; + + const updateROI = (e: React.MouseEvent, idx: number) => { + const { imgCol, imgRow } = screenToImg(e, idx); + updateSelectedRoi({ col: Math.max(0, Math.min(width - 1, Math.floor(imgCol))), row: Math.max(0, Math.min(height - 1, Math.floor(imgRow))) }); + }; + + const hitTestROI = (imgCol: number, imgRow: number): number => { + if (!roiActive || !roiList) return -1; + for (let ri = roiList.length - 1; ri >= 0; ri--) { + const roi = roiList[ri]; + const shape = roi.shape || "circle"; + if (shape === "circle" || shape === "annular") { + if (Math.sqrt((imgCol - roi.col) ** 2 + (imgRow - roi.row) ** 2) <= roi.radius) return ri; + } else if (shape === "square") { + if (Math.abs(imgCol - roi.col) <= roi.radius && Math.abs(imgRow - roi.row) <= roi.radius) return ri; + } else if (shape === "rectangle") { + if (Math.abs(imgCol - roi.col) <= roi.width / 2 && Math.abs(imgRow - roi.row) <= roi.height / 2) return ri; + } + } + return -1; + }; + + const getHitArea = () => { + const zoom = (getZoomState(selectedIdx)).zoom; + return RESIZE_HIT_AREA_PX / (displayScale * zoom); + }; + + const isNearEdge = (imgCol: number, imgRow: number, roi: ROIItem): boolean => { + const hitArea = getHitArea(); + const shape = roi.shape || "circle"; + if (shape === "circle" || shape === "annular") { + const dist = Math.sqrt((imgCol - roi.col) ** 2 + (imgRow - roi.row) ** 2); + return Math.abs(dist - roi.radius) < hitArea; + } + if (shape === "square") { + const dx = Math.abs(imgCol - roi.col); + const dy = Math.abs(imgRow - roi.row); + const r = roi.radius; + return (dx <= r + hitArea && dy <= r + hitArea) && (Math.abs(dx - r) < hitArea || Math.abs(dy - r) < hitArea); + } + if (shape === "rectangle") { + const dx = Math.abs(imgCol - roi.col); + const dy = Math.abs(imgRow - roi.row); + const hw = roi.width / 2; + const hh = roi.height / 2; + return (dx <= hw + hitArea && dy <= hh + hitArea) && (Math.abs(dx - hw) < hitArea || Math.abs(dy - hh) < hitArea); + } + return false; + }; + + const isNearResizeHandle = (imgCol: number, imgRow: number): boolean => { + if (!roiActive || !selectedRoi) return false; + return isNearEdge(imgCol, imgRow, selectedRoi); + }; + + const isNearAnyEdge = (imgCol: number, imgRow: number): boolean => { + if (!roiActive || !roiList) return false; + return roiList.some(roi => isNearEdge(imgCol, imgRow, roi)); + }; + + const isNearResizeHandleInner = (imgCol: number, imgRow: number): boolean => { + if (!roiActive || !selectedRoi || selectedRoi.shape !== "annular") return false; + const hitArea = getHitArea(); + const dist = Math.sqrt((imgCol - selectedRoi.col) ** 2 + (imgRow - selectedRoi.row) ** 2); + return Math.abs(dist - selectedRoi.radius_inner) < hitArea; + }; + + const handleMouseDown = (e: React.MouseEvent, idx: number) => { + const zs = getZoomState(idx); + if (isGallery && idx !== selectedIdx) { + setSelectedIdx(idx); + // Continue to pan setup so click-drag on unselected panel pans immediately + // (no double-click required to select first then drag). + } + // Check if click is on the lens inset — edge = resize, interior = drag + if (showLens && !isGallery && idx === 0) { + const canvas = canvasRefs.current[0]; + if (canvas) { + const rect = canvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const cssY = e.clientY - rect.top; + const margin = 12; + const lx = lensAnchor ? lensAnchor.x : margin; + const ly = lensAnchor ? lensAnchor.y : canvasH - lensDisplaySize - margin - 20; + if (cssX >= lx && cssX <= lx + lensDisplaySize && cssY >= ly && cssY <= ly + lensDisplaySize) { + const edgeHit = 8; + const nearEdge = cssX - lx < edgeHit || lx + lensDisplaySize - cssX < edgeHit || cssY - ly < edgeHit || ly + lensDisplaySize - cssY < edgeHit; + if (nearEdge) { + setIsResizingLens(true); + lensResizeStartRef.current = { my: e.clientY, startSize: lensDisplaySize }; + } else { + setIsDraggingLens(true); + lensDragStartRef.current = { mx: e.clientX, my: e.clientY, ax: lx, ay: ly }; + } + e.preventDefault(); + return; + } + } + } + clickStartRef.current = { x: e.clientX, y: e.clientY }; + if (profileActive) { + const { imgCol, imgRow } = screenToImg(e, idx); + if (profilePoints.length === 2) { + const p0 = profilePoints[0]; + const p1 = profilePoints[1]; + const hitRadius = 10 / (displayScale * zs.zoom); + const d0 = Math.sqrt((imgCol - p0.col) ** 2 + (imgRow - p0.row) ** 2); + const d1 = Math.sqrt((imgCol - p1.col) ** 2 + (imgRow - p1.row) ** 2); + if (d0 <= hitRadius || d1 <= hitRadius) { + setDraggingProfileEndpoint(d0 <= d1 ? 0 : 1); + setIsDraggingPan(false); + setPanStart(null); + setPanningIdx(null); + return; + } + if (pointToSegmentDistance(imgCol, imgRow, p0.col, p0.row, p1.col, p1.row) <= hitRadius) { + setIsDraggingProfileLine(true); + profileDragStartRef.current = { + row: imgRow, + col: imgCol, + p0: { row: p0.row, col: p0.col }, + p1: { row: p1.row, col: p1.col }, + }; + setIsDraggingPan(false); + setPanStart(null); + setPanningIdx(null); + return; + } + } + setIsDraggingPan(true); + setPanningIdx(idx); + setPanStart({ x: e.clientX, y: e.clientY, pX: zs.panX, pY: zs.panY }); + return; + } + if (roiActive) { + const { imgCol, imgRow } = screenToImg(e, idx); + // Check resize handles on selected ROI first + if (isNearResizeHandleInner(imgCol, imgRow)) { + setIsDraggingResizeInner(true); + return; + } + if (isNearResizeHandle(imgCol, imgRow)) { + e.preventDefault(); + resizeAspectRef.current = selectedRoi && (selectedRoi.shape === "rectangle") && selectedRoi.width > 0 && selectedRoi.height > 0 ? selectedRoi.width / selectedRoi.height : null; + setIsDraggingResize(true); + return; + } + // Check edge of any ROI — auto-select and start resize + if (roiList) { + for (let ri = 0; ri < roiList.length; ri++) { + if (isNearEdge(imgCol, imgRow, roiList[ri])) { + e.preventDefault(); + const roi = roiList[ri]; + resizeAspectRef.current = roi && (roi.shape === "rectangle") && roi.width > 0 && roi.height > 0 ? roi.width / roi.height : null; + setRoiSelectedIdx(ri); + setIsDraggingResize(true); + return; + } + } + } + // Hit-test existing ROIs (click inside to select + drag) + const hitIdx = hitTestROI(imgCol, imgRow); + if (hitIdx >= 0) { + setRoiSelectedIdx(hitIdx); + setIsDraggingROI(true); + return; + } + // Click on empty space — deselect and allow panning + setRoiSelectedIdx(-1); + } + // Start panning (works in both ROI-active and normal modes) + { + setIsDraggingPan(true); + setPanningIdx(idx); + setPanStart({ x: e.clientX, y: e.clientY, pX: zs.panX, pY: zs.panY }); + } + }; + + const handleMouseMove = (e: React.MouseEvent, idx: number) => { + // Fast path: during pan drag, skip all cursor/hover/lens work — just update pan + if (isDraggingPan && panStart && panningIdx !== null) { + const canvas = canvasRefs.current[idx]; + if (!canvas || idx !== panningIdx) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const dx = (e.clientX - panStart.x) * scaleX; + const dy = (e.clientY - panStart.y) * scaleY; + const zs = getZoomState(idx); + setZoomState(idx, { ...zs, panX: panStart.pX + dx, panY: panStart.pY + dy }); + return; + } + + // Cursor readout: convert screen position to image pixel coordinates + const canvas = canvasRefs.current[idx]; + if (canvas && rawDataRef.current) { + const rect = canvas.getBoundingClientRect(); + const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); + const zs = getZoomState(idx); + const cx = canvasW / 2; + const cy = canvasH / 2; + const imageCanvasX = (mouseCanvasX - cx - zs.panX) / zs.zoom + cx; + const imageCanvasY = (mouseCanvasY - cy - zs.panY) / zs.zoom + cy; + const imgX = Math.floor(imageCanvasX / displayScale); + const imgY = Math.floor(imageCanvasY / displayScale); + if (imgX >= 0 && imgX < width && imgY >= 0 && imgY < height) { + const rawData = rawDataRef.current[idx]; + if (rawData) setCursorInfo({ row: imgY, col: imgX, value: rawData[imgY * width + imgX] }); + if (showLens && !isGallery) setLensPos({ row: imgY, col: imgX }); + } else { + setCursorInfo(null); + // Don't clear lensPos — lens stays at last position when toggle is on + } + } + + // Lens drag + if (isDraggingLens && lensDragStartRef.current) { + const dx = e.clientX - lensDragStartRef.current.mx; + const dy = e.clientY - lensDragStartRef.current.my; + setLensAnchor({ x: lensDragStartRef.current.ax + dx, y: lensDragStartRef.current.ay + dy }); + return; + } + // Lens resize drag + if (isResizingLens && lensResizeStartRef.current) { + const dy = e.clientY - lensResizeStartRef.current.my; + setLensDisplaySize(Math.max(64, Math.min(256, lensResizeStartRef.current.startSize + dy))); + return; + } + + if (profileActive && profilePoints.length === 2) { + const { imgCol, imgRow } = screenToImg(e, idx); + const p0 = profilePoints[0]; + const p1 = profilePoints[1]; + const activeZoom = linkedZoom ? linkedZoomState.zoom : (zoomStates.get(idx) || initialZoomState).zoom; + const hitRadius = 10 / (displayScale * activeZoom); + const d0 = Math.sqrt((imgCol - p0.col) ** 2 + (imgRow - p0.row) ** 2); + const d1 = Math.sqrt((imgCol - p1.col) ** 2 + (imgRow - p1.row) ** 2); + if (draggingProfileEndpoint !== null) { + const clampedRow = Math.max(0, Math.min(height - 1, imgRow)); + const clampedCol = Math.max(0, Math.min(width - 1, imgCol)); + const next = [ + draggingProfileEndpoint === 0 ? { row: clampedRow, col: clampedCol } : profilePoints[0], + draggingProfileEndpoint === 1 ? { row: clampedRow, col: clampedCol } : profilePoints[1], + ]; + setProfilePoints(next); + updateAllProfileData(next[0], next[1]); + return; + } + if (isDraggingProfileLine && profileDragStartRef.current) { + const drag = profileDragStartRef.current; + let deltaRow = imgRow - drag.row; + let deltaCol = imgCol - drag.col; + const minRow = Math.min(drag.p0.row, drag.p1.row); + const maxRow = Math.max(drag.p0.row, drag.p1.row); + const minCol = Math.min(drag.p0.col, drag.p1.col); + const maxCol = Math.max(drag.p0.col, drag.p1.col); + deltaRow = Math.max(deltaRow, -minRow); + deltaRow = Math.min(deltaRow, (height - 1) - maxRow); + deltaCol = Math.max(deltaCol, -minCol); + deltaCol = Math.min(deltaCol, (width - 1) - maxCol); + const next = [ + { row: drag.p0.row + deltaRow, col: drag.p0.col + deltaCol }, + { row: drag.p1.row + deltaRow, col: drag.p1.col + deltaCol }, + ]; + setProfilePoints(next); + updateAllProfileData(next[0], next[1]); + return; + } + const nextHoveredEndpoint: 0 | 1 | null = d0 <= hitRadius ? 0 : d1 <= hitRadius ? 1 : null; + const nextHoverLine = nextHoveredEndpoint === null && pointToSegmentDistance(imgCol, imgRow, p0.col, p0.row, p1.col, p1.row) <= hitRadius; + setHoveredProfileEndpoint(nextHoveredEndpoint); + setIsHoveringProfileLine(nextHoverLine); + } else { + if (hoveredProfileEndpoint !== null) setHoveredProfileEndpoint(null); + if (isHoveringProfileLine) setIsHoveringProfileLine(false); + } + + // ROI resize drag (inner annular ring) + if (isDraggingResizeInner && selectedRoi) { + const { imgCol: ic, imgRow: ir } = screenToImg(e, idx); + const newR = Math.sqrt((ic - selectedRoi.col) ** 2 + (ir - selectedRoi.row) ** 2); + updateSelectedRoi({ radius_inner: Math.max(1, Math.min(selectedRoi.radius - 1, Math.round(newR))) }); + return; + } + // ROI resize drag (outer) + if (isDraggingResize && selectedRoi) { + const { imgCol: ic, imgRow: ir } = screenToImg(e, idx); + const shape = selectedRoi.shape || "circle"; + if (shape === "rectangle") { + let newW = Math.max(2, Math.round(Math.abs(ic - selectedRoi.col) * 2)); + let newH = Math.max(2, Math.round(Math.abs(ir - selectedRoi.row) * 2)); + if (e.shiftKey && resizeAspectRef.current != null) { + const aspect = resizeAspectRef.current; + if (newW / newH > aspect) newH = Math.max(2, Math.round(newW / aspect)); + else newW = Math.max(2, Math.round(newH * aspect)); + } + updateSelectedRoi({ width: newW, height: newH }); + } else { + const newR = shape === "square" ? Math.max(Math.abs(ic - selectedRoi.col), Math.abs(ir - selectedRoi.row)) : Math.sqrt((ic - selectedRoi.col) ** 2 + (ir - selectedRoi.row) ** 2); + const minR = shape === "annular" ? selectedRoi.radius_inner + 1 : 1; + updateSelectedRoi({ radius: Math.max(minR, Math.round(newR)) }); + } + return; + } + // ROI drag (move center) + if (isDraggingROI) { + updateROI(e, idx); + return; + } + // Lens edge hover detection + if (showLens && !isGallery && canvas) { + const rect = canvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const cssY = e.clientY - rect.top; + const margin = 12; + const lx = lensAnchor ? lensAnchor.x : margin; + const ly = lensAnchor ? lensAnchor.y : canvasH - lensDisplaySize - margin - 20; + const inside = cssX >= lx && cssX <= lx + lensDisplaySize && cssY >= ly && cssY <= ly + lensDisplaySize; + const edgeHit = 8; + const nearEdge = inside && (cssX - lx < edgeHit || lx + lensDisplaySize - cssX < edgeHit || cssY - ly < edgeHit || ly + lensDisplaySize - cssY < edgeHit); + setIsHoveringLensEdge(nearEdge); + } else { + setIsHoveringLensEdge(false); + } + // Hover detection for resize handles (show cursor on any ROI edge) + if (roiActive && !isDraggingPan) { + const { imgCol: ic, imgRow: ir } = screenToImg(e, idx); + setIsHoveringResizeInner(isNearResizeHandleInner(ic, ir)); + setIsHoveringResize(isNearAnyEdge(ic, ir)); + } + + // Panning + if (!isDraggingPan || !panStart || panningIdx === null) return; + if (idx !== panningIdx) return; + if (!canvas) return; + const rect2 = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect2.width; + const scaleY = canvas.height / rect2.height; + const dx = (e.clientX - panStart.x) * scaleX; + const dy = (e.clientY - panStart.y) * scaleY; + + const zs = getZoomState(idx); + setZoomState(idx, { ...zs, panX: panStart.pX + dx, panY: panStart.pY + dy }); + }; + + const handleMouseUp = (e: React.MouseEvent, idx: number) => { + if (isDraggingLens) { + setIsDraggingLens(false); + lensDragStartRef.current = null; + return; + } + if (isResizingLens) { + setIsResizingLens(false); + lensResizeStartRef.current = null; + return; + } + if (draggingProfileEndpoint !== null || isDraggingProfileLine) { + setDraggingProfileEndpoint(null); + setIsDraggingProfileLine(false); + profileDragStartRef.current = null; + clickStartRef.current = null; + setIsDraggingROI(false); + setIsDraggingResize(false); + setIsDraggingResizeInner(false); + setIsDraggingPan(false); + setPanStart(null); + setPanningIdx(null); + setHoveredProfileEndpoint(null); + setIsHoveringProfileLine(false); + return; + } + // Detect click (vs drag) for profile mode + if (profileActive && clickStartRef.current) { + const dx = e.clientX - clickStartRef.current.x; + const dy = e.clientY - clickStartRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) < 3) { + // It's a click — compute image coordinates + const canvas = canvasRefs.current[idx]; + if (canvas && rawDataRef.current) { + const rect = canvas.getBoundingClientRect(); + const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); + const zs = getZoomState(idx); + const cx = canvasW / 2; + const cy = canvasH / 2; + const imgX = ((mouseCanvasX - cx - zs.panX) / zs.zoom + cx) / displayScale; + const imgY = ((mouseCanvasY - cy - zs.panY) / zs.zoom + cy) / displayScale; + if (imgX >= 0 && imgX < width && imgY >= 0 && imgY < height) { + const pt = { row: imgY, col: imgX }; + if (profilePoints.length === 0 || profilePoints.length === 2) { + // Start new line + setProfilePoints([pt]); + setProfileDataAll([]); + } else { + // Complete the line + const p0 = profilePoints[0]; + setProfilePoints([p0, pt]); + updateAllProfileData(p0, pt); + } + } + } + } + } + // Detect click for measurement mode (only when profile is not active) + if (measureActive && !profileActive && clickStartRef.current) { + const dx = e.clientX - clickStartRef.current.x; + const dy = e.clientY - clickStartRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) < 3) { + const canvas = canvasRefs.current[idx]; + if (canvas) { + const rect = canvas.getBoundingClientRect(); + const mouseCanvasX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseCanvasY = (e.clientY - rect.top) * (canvas.height / rect.height); + const zs = getZoomState(idx); + const cx = canvasW / 2; + const cy = canvasH / 2; + const imgX = ((mouseCanvasX - cx - zs.panX) / zs.zoom + cx) / displayScale; + const imgY = ((mouseCanvasY - cy - zs.panY) / zs.zoom + cy) / displayScale; + if (imgX >= 0 && imgX < width && imgY >= 0 && imgY < height) { + const pt = { row: imgY, col: imgX }; + if (measurePoints.length < 2) { + setMeasurePoints([...measurePoints, pt]); + } else { + setMeasurePoints([pt]); + } + } + } + } + } + clickStartRef.current = null; + setDraggingProfileEndpoint(null); + setIsDraggingProfileLine(false); + profileDragStartRef.current = null; + setIsDraggingROI(false); + setIsDraggingResize(false); + setIsDraggingResizeInner(false); + setIsDraggingPan(false); + setPanStart(null); + setPanningIdx(null); + setHoveredProfileEndpoint(null); + setIsHoveringProfileLine(false); + }; + + const handleMouseLeave = (idx: number) => { + setCursorInfo(null); + // Don't clear lensPos — lens stays at last position when toggle is on + setIsDraggingLens(false); + setIsResizingLens(false); + lensDragStartRef.current = null; + lensResizeStartRef.current = null; + setIsHoveringLensEdge(false); + setIsDraggingROI(false); + setIsDraggingResize(false); + setIsDraggingResizeInner(false); + setDraggingProfileEndpoint(null); + setIsDraggingProfileLine(false); + setHoveredProfileEndpoint(null); + setIsHoveringProfileLine(false); + profileDragStartRef.current = null; + setIsHoveringResize(false); + setIsHoveringResizeInner(false); + if (panningIdx === idx) { + setIsDraggingPan(false); + setPanStart(null); + setPanningIdx(null); + } + }; + + // ------------------------------------------------------------------------- + // Copy to clipboard handler + const handleCopy = React.useCallback(async () => { + const canvas = canvasRefs.current[isGallery ? selectedIdx : 0]; + if (!canvas) return; + try { + const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); + if (!blob) return; + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + } catch { + // Fallback: download if clipboard API unavailable + canvas.toBlob((b) => { if (b) downloadBlob(b, `show2d_${labels?.[selectedIdx] || "image"}.png`); }, "image/png"); + } + }, [isGallery, selectedIdx, labels]); + + // Export publication-quality figure with scale bar, colorbar, annotations + const handleExportFigure = React.useCallback((withScaleBar: boolean, withColorbar: boolean) => { + setExportAnchor(null); + const idx = isGallery ? selectedIdx : 0; + const rawData = rawDataRef.current?.[idx]; + if (!rawData) return; + + const processed = logScale ? applyLogScale(rawData) : rawData; + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + + let vmin: number, vmax: number; + const hasAbsRange = traitVmin != null && traitVmax != null; + const rMin = hasAbsRange ? (logScale ? Math.log1p(Math.max(traitVmin!, 0)) : traitVmin!) : imageDataRange.min; + const rMax = hasAbsRange ? (logScale ? Math.log1p(Math.max(traitVmax!, 0)) : traitVmax!) : imageDataRange.max; + if (rMin !== rMax && (imageVminPct > 0 || imageVmaxPct < 100)) { + ({ vmin, vmax } = sliderRange(rMin, rMax, imageVminPct, imageVmaxPct)); + } else if (!hasAbsRange && autoContrast) { + ({ vmin, vmax } = percentileClip(processed, 2, 98)); + } else { + vmin = rMin; + vmax = rMax; + } + + const offscreen = renderToOffscreen(processed, width, height, lut, vmin, vmax); + if (!offscreen) return; + + const figCanvas = exportFigure({ + imageCanvas: offscreen, + title: title || undefined, + lut, + vmin, + vmax, + logScale, + pixelSize: pixelSize > 0 ? pixelSize : undefined, + showColorbar: withColorbar, + showScaleBar: withScaleBar && pixelSize > 0, + drawAnnotations: (ctx) => { + // ROI highlight mask + if (roiActive && roiList) { + const hlRois = roiList.filter(r => r.highlight); + if (hlRois.length > 0) { + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillRect(0, 0, width, height); + ctx.globalCompositeOperation = "destination-out"; + for (const roi of hlRois) { + ctx.fillStyle = "rgba(0,0,0,1)"; + const shape = roi.shape || "circle"; + if (shape === "circle") { ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); } + else if (shape === "square") { ctx.fillRect(roi.col - roi.radius, roi.row - roi.radius, roi.radius * 2, roi.radius * 2); } + else if (shape === "rectangle") { ctx.fillRect(roi.col - roi.width / 2, roi.row - roi.height / 2, roi.width, roi.height); } + else if (shape === "annular") { + ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius_inner, 0, Math.PI * 2); ctx.fill(); + ctx.globalCompositeOperation = "destination-out"; + } + } + ctx.restore(); + } + // ROI outlines + for (const roi of roiList) { + const shape = (roi.shape || "circle") as "circle" | "square" | "rectangle" | "annular"; + ctx.lineWidth = roi.line_width || 2; + drawROI(ctx, roi.col, roi.row, shape, roi.radius, roi.width, roi.height, roi.color, roi.color, false, roi.radius_inner); + } + } + // Profile line + if (profileActive && profilePoints.length === 2) { + ctx.strokeStyle = "#4fc3f7"; + ctx.lineWidth = 2; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(profilePoints[0].col, profilePoints[0].row); + ctx.lineTo(profilePoints[1].col, profilePoints[1].row); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = "#4fc3f7"; + ctx.beginPath(); + ctx.arc(profilePoints[0].col, profilePoints[0].row, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(profilePoints[1].col, profilePoints[1].row, 3, 0, Math.PI * 2); + ctx.fill(); + } + }, + }); + + canvasToPDF(figCanvas).then((blob) => downloadBlob(blob, `show2d_figure_${labels?.[selectedIdx] || "image"}.pdf`)); + }, [isGallery, selectedIdx, labels, width, height, cmap, logScale, autoContrast, imageDataRange, imageVminPct, imageVmaxPct, pixelSize, title, roiActive, roiList, profileActive, profilePoints]); + + // Export all variants (PNG + PDF) as zip + const handleExportAll = React.useCallback(async () => { + setExportAnchor(null); + const idx = isGallery ? selectedIdx : 0; + const rawData = rawDataRef.current?.[idx]; + if (!rawData) return; + + const processed = logScale ? applyLogScale(rawData) : rawData; + const lut = COLORMAPS[cmap] || COLORMAPS.inferno; + + let vmin: number, vmax: number; + const hasAbsRange2 = traitVmin != null && traitVmax != null; + const rMin2 = hasAbsRange2 ? (logScale ? Math.log1p(Math.max(traitVmin!, 0)) : traitVmin!) : imageDataRange.min; + const rMax2 = hasAbsRange2 ? (logScale ? Math.log1p(Math.max(traitVmax!, 0)) : traitVmax!) : imageDataRange.max; + if (rMin2 !== rMax2 && (imageVminPct > 0 || imageVmaxPct < 100)) { + ({ vmin, vmax } = sliderRange(rMin2, rMax2, imageVminPct, imageVmaxPct)); + } else if (!hasAbsRange2 && autoContrast) { + ({ vmin, vmax } = percentileClip(processed, 2, 98)); + } else { + vmin = rMin2; + vmax = rMax2; + } + + const offscreen = renderToOffscreen(processed, width, height, lut, vmin, vmax); + if (!offscreen) return; + + const drawAnnotations = (ctx: CanvasRenderingContext2D) => { + if (roiActive && roiList) { + const hlRois = roiList.filter(r => r.highlight); + if (hlRois.length > 0) { + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fillRect(0, 0, width, height); + ctx.globalCompositeOperation = "destination-out"; + for (const roi of hlRois) { + ctx.fillStyle = "rgba(0,0,0,1)"; + const shape = roi.shape || "circle"; + if (shape === "circle") { ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); } + else if (shape === "square") { ctx.fillRect(roi.col - roi.radius, roi.row - roi.radius, roi.radius * 2, roi.radius * 2); } + else if (shape === "rectangle") { ctx.fillRect(roi.col - roi.width / 2, roi.row - roi.height / 2, roi.width, roi.height); } + else if (shape === "annular") { + ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius, 0, Math.PI * 2); ctx.fill(); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.beginPath(); ctx.arc(roi.col, roi.row, roi.radius_inner, 0, Math.PI * 2); ctx.fill(); + ctx.globalCompositeOperation = "destination-out"; + } + } + ctx.restore(); + for (const roi of roiList) { + const shape = (roi.shape || "circle") as "circle" | "square" | "rectangle" | "annular"; + ctx.lineWidth = roi.line_width || 2; + drawROI(ctx, roi.col, roi.row, shape, roi.radius, roi.width, roi.height, roi.color, roi.color, false, roi.radius_inner); + } + } + } + if (profileActive && profilePoints.length === 2) { + ctx.strokeStyle = "#4fc3f7"; + ctx.lineWidth = 2; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(profilePoints[0].col, profilePoints[0].row); + ctx.lineTo(profilePoints[1].col, profilePoints[1].row); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = "#4fc3f7"; + ctx.beginPath(); ctx.arc(profilePoints[0].col, profilePoints[0].row, 3, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(profilePoints[1].col, profilePoints[1].row, 3, 0, Math.PI * 2); ctx.fill(); + } + }; + + const hasScale = pixelSize > 0; + const baseOpts = { + imageCanvas: offscreen, + title: title || undefined, + lut, + vmin, + vmax, + logScale, + pixelSize: hasScale ? pixelSize : undefined, + drawAnnotations, + }; + + const variants: { name: string; showScaleBar: boolean; showColorbar: boolean }[] = [ + { name: "figure", showScaleBar: false, showColorbar: false }, + { name: "figure_scalebar", showScaleBar: true, showColorbar: false }, + { name: "figure_scalebar_colorbar", showScaleBar: true, showColorbar: true }, + ]; + + const zip = new JSZip(); + const prefix = `show2d_${labels?.[selectedIdx] || "image"}`; + const metadata = { + metadata_version: "1.0", + widget_name: "Show2D", + widget_version: widgetVersion || "unknown", + exported_at: new Date().toISOString(), + format: "zip", + export_kind: "figure_variants", + selected_idx: idx, + image_shape: { rows: height, cols: width }, + display: { + cmap, + log_scale: logScale, + auto_contrast: autoContrast, + vmin_pct: imageVminPct, + vmax_pct: imageVmaxPct, + }, + variants, + }; + zip.file("metadata.json", JSON.stringify(metadata, null, 2)); + + for (const v of variants) { + const figCanvas = exportFigure({ ...baseOpts, showScaleBar: v.showScaleBar && hasScale, showColorbar: v.showColorbar }); + const pngBlob = await new Promise((resolve) => figCanvas.toBlob((b) => resolve(b!), "image/png")); + zip.file(`${prefix}_${v.name}.png`, pngBlob); + const pdfBlob = await canvasToPDF(figCanvas); + zip.file(`${prefix}_${v.name}.pdf`, pdfBlob); + } + + const blob = await zip.generateAsync({ type: "blob" }); + downloadBlob(blob, `${prefix}_all.zip`); + }, [isGallery, selectedIdx, labels, width, height, cmap, logScale, autoContrast, imageDataRange, imageVminPct, imageVmaxPct, pixelSize, title, roiActive, roiList, profileActive, profilePoints, widgetVersion]); + + // Resize Handlers + // ------------------------------------------------------------------------- + const handleCanvasResizeStart = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsResizingCanvas(true); + setResizeStart({ x: e.clientX, y: e.clientY, size: canvasSize }); + }; + + React.useEffect(() => { + if (!isResizingCanvas) return; + let rafId = 0; + let latestSize = resizeStart ? resizeStart.size : canvasSize; + + const handleMouseMove = (e: MouseEvent) => { + if (!resizeStart) return; + const delta = Math.max(e.clientX - resizeStart.x, e.clientY - resizeStart.y); + latestSize = Math.max(200, resizeStart.size + delta); + if (!rafId) { + rafId = requestAnimationFrame(() => { + rafId = 0; + setCanvasSize(latestSize); + }); + } + }; + + const handleMouseUp = () => { + cancelAnimationFrame(rafId); + setCanvasSize(latestSize); + setIsResizingCanvas(false); + setResizeStart(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + cancelAnimationFrame(rafId); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizingCanvas, resizeStart]); + + // Profile height resize + React.useEffect(() => { + if (!isResizingProfile) return; + const handleMouseMove = (e: MouseEvent) => { + if (!profileResizeStart) return; + const delta = e.clientY - profileResizeStart.y; + setProfileHeight(Math.max(40, Math.min(300, profileResizeStart.height + delta))); + }; + const handleMouseUp = () => { + setIsResizingProfile(false); + setProfileResizeStart(null); + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizingProfile, profileResizeStart]); + + // ------------------------------------------------------------------------- + // Keyboard shortcuts + // ------------------------------------------------------------------------- + const handleKeyDown = (e: React.KeyboardEvent) => { + // Number keys 1-9 select gallery images (avoids arrow key conflicts with Jupyter) + if (isGallery && e.key >= "1" && e.key <= "9") { + const idx = parseInt(e.key) - 1; + if (idx < nImages) { e.preventDefault(); setSelectedIdx(idx); } + return; + } + switch (e.key) { + case "ArrowLeft": + if (isGallery) { e.preventDefault(); setSelectedIdx(Math.max(0, selectedIdx - 1)); } + break; + case "ArrowRight": + if (isGallery) { e.preventDefault(); setSelectedIdx(Math.min(nImages - 1, selectedIdx + 1)); } + break; + case "r": + case "R": + handleResetAll(); + break; + case "m": + case "M": + if (measureActive) { + setMeasureActive(false); + setMeasurePoints([]); + } else { + setMeasureActive(true); + setMeasurePoints([]); + } + break; + case "Escape": + if (measureActive) { + setMeasureActive(false); + setMeasurePoints([]); + } + break; + case "]": + { + e.preventDefault(); + const rIdx = isGallery ? selectedIdx : 0; + const rots = [...(imageRotations || [])]; + while (rots.length <= rIdx) rots.push(0); + rots[rIdx] = (rots[rIdx] + 3) % 4; + setImageRotations(rots); + } + break; + case "[": + { + e.preventDefault(); + const rIdx2 = isGallery ? selectedIdx : 0; + const rots2 = [...(imageRotations || [])]; + while (rots2.length <= rIdx2) rots2.push(0); + rots2[rIdx2] = (rots2[rIdx2] + 1) % 4; + setImageRotations(rots2); + } + break; + case "Delete": + case "Backspace": + if (roiActive && roiSelectedIdx >= 0 && roiList && roiSelectedIdx < roiList.length) { + e.preventDefault(); + const newList = roiList.filter((_, i) => i !== roiSelectedIdx); + setRoiList(newList); + setRoiSelectedIdx(newList.length > 0 ? Math.min(roiSelectedIdx, newList.length - 1) : -1); + } + break; + } + }; + + // ------------------------------------------------------------------------- + // Render (Show3D-style layout) + // ------------------------------------------------------------------------- + const needsReset = getZoomState(isGallery ? selectedIdx : 0).zoom !== 1 || getZoomState(isGallery ? selectedIdx : 0).panX !== 0 || getZoomState(isGallery ? selectedIdx : 0).panY !== 0; + const statsIdx = isGallery ? selectedIdx : 0; + + // Calibrated cursor position - unit is whatever the user passed via sampling/units. + const calibratedUnit = pixelSize > 0 ? pixelUnit : ""; + const calibratedFactor = pixelSize; + + return ( + + + {/* Main panel */} + + {/* Title row */} + + {title || (isGallery ? "Gallery" : "Image")} + {displayBinFactor > 1 && ( + + {displayBinFactor}× binned + + )} + {(() => { const rk = (imageRotations?.[isGallery ? selectedIdx : 0] ?? 0) % 4; return rk !== 0 ? ( + { + const ri = isGallery ? selectedIdx : 0; + const rots = [...(imageRotations || [])]; + while (rots.length <= ri) rots.push(0); + rots[ri] = (rots[ri] + 3) % 4; + setImageRotations(rots); + }} + sx={{ ml: 0.5, color: themeColors.accent, cursor: "pointer", fontSize: "inherit", "&:hover": { opacity: 0.7 } }} + > + ({rk * 90}°) + + ) : null; })()} + + Controls + FFT: Show power spectrum (Fourier transform) alongside image. + Profile: Click two points on image to draw a line intensity profile. + ROI: Region of Interest — click to place, drag to move. + {!isGallery && Lens: Magnifier inset that follows the cursor.} + Auto: Percentile-based contrast (2nd–98th percentile). FFT Auto masks DC + clips to 99.9th. + {isGallery && Link Zoom / Contrast: Sync zoom or histogram range across all gallery images.} + Keyboard + + } theme={themeInfo.theme} /> + + {/* Controls row: Profile, ROI, Lens, FFT, Export, Reset, Copy */} + + {( + <> + Profile: + { + const on = e.target.checked; + setProfileActive(on); + if (on) { + setRoiActive(false); + } else { + setProfilePoints([]); + setProfileDataAll([]); + setHoveredProfileEndpoint(null); + setIsHoveringProfileLine(false); + } + }} + size="small" + sx={switchStyles.small} + /> + + )} + {!isGallery && ( + <> + ROI: + { + const on = e.target.checked; + setRoiActive(on); + if (on) { + setProfileActive(false); + setProfilePoints([]); + setProfileDataAll([]); + setHoveredProfileEndpoint(null); + setIsHoveringProfileLine(false); + } else { + setRoiSelectedIdx(-1); + } + }} + size="small" + sx={switchStyles.small} + /> + + )} + {( + <> + {!isGallery && ( + <> + Lens: + { + if (!showLens) { + setShowLens(true); + setLensPos({ row: Math.floor(height / 2), col: Math.floor(width / 2) }); + } else { + setShowLens(false); + setLensPos(null); + } + }} + + size="small" + sx={switchStyles.small} + /> + + )} + FFT: + { + const on = e.target.checked; + if (on && width * height > 2048 * 2048) { + console.warn(`Show2D: FFT on ${width}×${height} image (${(width * height / 1e6).toFixed(1)}M pixels) may be slow`); + } + setShowFft(on); + }} + + size="small" + sx={switchStyles.small} + /> + {nImages === 2 && ( + <> + Diff: + { setDiffMode(!diffMode); }} size="small" sx={switchStyles.small} /> + + )} + + )} + + {( + + )} + {( + <> + + setExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> + handleExportFigure(true, true)} sx={{ fontSize: 12 }}>PDF + scalebar + colorbar + handleExportFigure(true, false)} sx={{ fontSize: 12 }}>PDF + scalebar + handleExportFigure(false, false)} sx={{ fontSize: 12 }}>PDF + All (PNG + PDF) + + + + )} + + + {isGallery ? ( + /* Gallery mode */ + + {Array.from({ length: nImages }).map((_, i) => ( + + { imageContainerRefs.current[i] = el; }} + sx={{ position: "relative", bgcolor: "#000", border: `2px solid ${i === selectedIdx ? themeColors.accent : themeColors.border}`, borderRadius: 0, width: canvasW, height: canvasH }} + onMouseDown={(e) => handleMouseDown(e, i)} + onMouseMove={(e) => handleMouseMove(e, i)} + onMouseUp={(e) => handleMouseUp(e, i)} + onMouseLeave={() => handleMouseLeave(i)} + onWheel={(i === selectedIdx || linkedZoom) ? (e) => handleWheel(e, i) : undefined} + onDoubleClick={() => handleDoubleClick(i)} + > + { if (el && canvasRefs.current[i] !== el) { canvasRefs.current[i] = el; setCanvasReady(c => c + 1); } }} + width={canvasW} height={canvasH} + style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle }} + /> + { overlayRefs.current[i] = el; }} + width={Math.round(canvasW * DPR)} height={Math.round(canvasH * DPR)} + style={{ position: "absolute", top: 0, left: 0, width: canvasW, height: canvasH, pointerEvents: "none" }} + /> + {( + + )} + + + {labels?.[i] || `Image ${i + 1}`} + {(imageRotations?.[i] ?? 0) % 4 !== 0 && ( + { + e.stopPropagation(); + const rots = [...(imageRotations || [])]; + while (rots.length <= i) rots.push(0); + rots[i] = (rots[i] + 3) % 4; + setImageRotations(rots); + }} + sx={{ ml: 0.5, color: themeColors.accent, cursor: "pointer", "&:hover": { opacity: 0.7 } }} + > + ({(imageRotations[i] % 4) * 90}°) + + )} + + {effectiveShowFft && ( + { fftContainerRefs.current[i] = el; }} + sx={{ mt: 0.5, position: "relative", border: `2px solid ${i === selectedIdx ? themeColors.accent : themeColors.border}`, borderRadius: 0, bgcolor: "#000", cursor: "grab" }} + onWheel={(i === selectedIdx || fftLinkedZoom) ? (e) => handleGalleryFftWheel(e, i) : undefined} + onDoubleClick={() => setGalleryFftState(i, { zoom: DEFAULT_FFT_ZOOM, panX: 0, panY: 0 })} + onMouseDown={(e) => handleGalleryFftMouseDown(e, i)} + onMouseMove={(e) => handleGalleryFftMouseMove(e, i)} + onMouseUp={handleGalleryFftMouseUp} + onMouseLeave={handleGalleryFftMouseUp} + > + { fftCanvasRefs.current[i] = el; }} + width={canvasW} height={canvasH} + style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle, display: "block" }} + /> + {fftComputing && !fftMagCacheGalleryRef.current[i] && ( + + FFT… + + )} + + )} + + ))} + {showDiffPanel && diffOtherIndices.map((otherIdx, slot) => ( + + + { diffCanvasRefs.current[slot] = el; }} + width={canvasW} height={canvasH} + style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle }} + /> + + + {nImages === 2 ? "Diff (A − B)" : `Diff (#${diffReference + 1} − #${otherIdx + 1})`} + + {/* FFT of diff (n=2 only) */} + {effectiveShowFft && nImages === 2 && slot === 0 && ( + + { diffFftCanvasRef.current = el; }} + width={canvasW} height={canvasH} + style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle, display: "block" }} + /> + + )} + + ))} + + ) : ( + /* Single image mode */ + { imageContainerRefs.current[0] = el; }} + sx={{ position: "relative", bgcolor: "#000", border: `1px solid ${themeColors.border}`, width: canvasW, height: canvasH, cursor: isHoveringLensEdge ? "nwse-resize" : isDraggingROI ? "move" : (isDraggingResize || isDraggingResizeInner || isHoveringResize || isHoveringResizeInner) ? "nwse-resize" : (draggingProfileEndpoint !== null || isDraggingProfileLine) ? "grabbing" : (profileActive && (hoveredProfileEndpoint !== null || isHoveringProfileLine)) ? "grab" : (profileActive || roiActive || measureActive) ? "crosshair" : "grab" }} + onMouseDown={(e) => handleMouseDown(e, 0)} + onMouseMove={(e) => handleMouseMove(e, 0)} + onMouseUp={(e) => handleMouseUp(e, 0)} + onMouseLeave={() => handleMouseLeave(0)} + onWheel={(e) => handleWheel(e, 0)} + onDoubleClick={() => handleDoubleClick(0)} + > + { if (el && canvasRefs.current[0] !== el) { canvasRefs.current[0] = el; setCanvasReady(c => c + 1); } }} + width={canvasW} height={canvasH} + style={{ width: canvasW, height: canvasH, imageRendering: imageRenderingStyle }} + /> + { overlayRefs.current[0] = el; }} + width={Math.round(canvasW * DPR)} height={Math.round(canvasH * DPR)} + style={{ position: "absolute", top: 0, left: 0, width: canvasW, height: canvasH, pointerEvents: "none" }} + /> + + {cursorInfo && ( + + + ({cursorInfo.row}, {cursorInfo.col}){pixelSize > 0 ? ` = (${(cursorInfo.row * calibratedFactor).toFixed(1)}, ${(cursorInfo.col * calibratedFactor).toFixed(1)} ${calibratedUnit})` : ""} {formatNumber(cursorInfo.value)} + + + )} + {( + + )} + + )} + + {/* Stats bar - right below canvas (Show3D style) */} + {showStats && ( + + {isGallery && ( + {labels?.[statsIdx] || `#${statsIdx + 1}`} + )} + Mean {formatNumber(statsMean?.[statsIdx] ?? 0)} + Min {formatNumber(statsMin?.[statsIdx] ?? 0)} + Max {formatNumber(statsMax?.[statsIdx] ?? 0)} + Std {formatNumber(statsStd?.[statsIdx] ?? 0)} + {measureActive && ( + <> + + Measuring + + )} + + )} + + {/* Gallery FFT Controls - below gallery grid */} + {effectiveShowFft && isGallery && ( + + + + FFT Scale: + + {roiFftActive && fftCropDims && ( + <> + Win: + { setFftWindow(e.target.checked); }} size="small" sx={switchStyles.small} /> + + )} + Color: + + + {/* FFT Row 2: Auto + Smooth + Link Zoom/Pan/Contrast (mirrors main image Row 2) */} + + Auto: + { setFftAuto(e.target.checked); }} size="small" sx={switchStyles.small} /> + Smooth: + { setFftSmooth(e.target.checked); }} size="small" sx={switchStyles.small} /> + {isGallery && ( + <> + Link: + Zoom + { setFftLinkedZoom(!fftLinkedZoom); }} size="small" sx={switchStyles.small} /> + Pan + { setFftLinkPan(!fftLinkPan); }} size="small" sx={switchStyles.small} /> + Contrast + { setFftLinkedContrast(!fftLinkedContrast); }} size="small" sx={switchStyles.small} /> + + )} + + + {( + + {fftHistogramData && ( + !fftLinkedContrast && isGallery ? ( + + {Array.from({ length: nImages }).map((_, i) => { + const fc = fftContrastFor(i); + const mag = fftMagCacheGalleryRef.current[i]; + let perData: Float32Array | null = null; + if (mag) { + if (fftScaleMode === "log") perData = applyLogScale(mag); + else if (fftScaleMode === "power") { + perData = new Float32Array(mag.length); + for (let j = 0; j < mag.length; j++) perData[j] = Math.sqrt(mag[j]); + } else perData = mag; + } + const dr = perData ? findDataRange(perData) : fftDataRange; + return ( + { setFftContrastFor(i, { vminPct: min, vmaxPct: max }); }} + width={110} height={58} + theme={themeInfo.theme === "dark" ? "dark" : "light"} + dataMin={dr.min} dataMax={dr.max} + /> + ); + })} + + ) : (() => { + const fc = fftContrastFor(selectedIdx); + return ( + { setFftContrastFor(selectedIdx, { vminPct: min, vmaxPct: max }); }} + width={110} height={58} + theme={themeInfo.theme === "dark" ? "dark" : "light"} + dataMin={fftDataRange.min} dataMax={fftDataRange.max} + /> + ); + })() + )} + + )} + + )} + + {/* Line profile sparkline — always reserve space when profile is active */} + {profileActive && ( + + +
{ + e.preventDefault(); + setIsResizingProfile(true); + setProfileResizeStart({ y: e.clientY, height: profileHeight }); + }} + style={{ width: profileCanvasWidth, height: 4, cursor: "ns-resize", borderLeft: `1px solid ${themeColors.border}`, borderRight: `1px solid ${themeColors.border}`, borderBottom: `1px solid ${themeColors.border}`, background: `linear-gradient(to bottom, ${themeColors.border}, transparent)`, opacity: 1, pointerEvents: "auto" }} + /> + + )} + + {/* Controls: two rows left + histogram right, ROI below */} + {showControls && ( + + {/* Top: control rows + histogram side by side */} + + + {/* Row 1: Scale + Color */} + {( + + Scale: + + Color: + + {!isGallery && ( + <> + Colorbar: + { setShowColorbar(!showColorbar); }} size="small" sx={switchStyles.small} /> + + )} + + )} + {/* Row 2: Auto + Lens settings + Link Zoom (gallery) + zoom indicator */} + {( + + Auto: + { setAutoContrast(!autoContrast); }} size="small" sx={switchStyles.small} /> + Smooth: + { setSmooth(!smooth); }} size="small" sx={switchStyles.small} /> + {!isGallery && showLens && ( + <> + Lens {lensMag}× + setLensMag(v as number)} size="small" sx={{ ...sliderStyles.small, width: 35 }} /> + {lensDisplaySize}px + setLensDisplaySize(v as number)} size="small" sx={{ ...sliderStyles.small, width: 35 }} /> + + )} + {isGallery && ( + <> + Link: + Zoom + { setLinkedZoom(!linkedZoom); }} size="small" sx={switchStyles.small} /> + Pan + { setLinkPan(!linkPan); }} size="small" sx={switchStyles.small} /> + Contrast + { setLinkedContrast(!linkedContrast); }} size="small" sx={switchStyles.small} /> + + )} + {getZoomState(isGallery ? selectedIdx : 0).zoom !== 1 && ( + {getZoomState(isGallery ? selectedIdx : 0).zoom.toFixed(1)}x + )} + + )} + + {/* Right: histograms. Unlinked + gallery → grid matching gallery layout + (same effectiveNcols × rows). Linked or single image → one histogram. */} + {(imageHistogramData || imageHistogramBins || (isGallery && !linkedContrast && rawDataRef.current)) && ( + + {(!linkedContrast && isGallery && rawDataRef.current) ? ( + + {Array.from({ length: nImages }).map((_, i) => { + const cs = contrastStates.get(i) || { vminPct: 0, vmaxPct: 100 }; + const raw = rawDataRef.current?.[i] || null; + return ( + { setContrastState(i, { vminPct: min, vmaxPct: max }); }} + width={110} height={58} theme={themeInfo.theme === "dark" ? "dark" : "light"} + dataMin={dataRangesRef.current[i]?.min ?? imageDataRange.min} + dataMax={dataRangesRef.current[i]?.max ?? imageDataRange.max} /> + ); + })} + + ) : ( + { setContrastState(activeContrastIdx, { vminPct: min, vmaxPct: max }); }} width={110} height={58} theme={themeInfo.theme === "dark" ? "dark" : "light"} dataMin={traitVmin != null && traitVmax != null ? (logScale ? Math.log1p(Math.max(traitVmin, 0)) : traitVmin) : imageDataRange.min} dataMax={traitVmin != null && traitVmax != null ? (logScale ? Math.log1p(Math.max(traitVmax, 0)) : traitVmax) : imageDataRange.max} /> + )} + + )} + + {/* ROI Section (own box, below control rows) */} + {roiActive && ( + + {/* ROI: shape + ADD + CLEAR */} + + ROI: + + + + + + {/* Selected ROI details */} + {selectedRoi && ( + + #{roiSelectedIdx + 1}/{roiList?.length ?? 0} + + {selectedRoi.shape === "rectangle" && ( + <> + W + updateSelectedRoi({ width: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> + H + updateSelectedRoi({ height: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> + + )} + {selectedRoi.shape === "annular" && ( + <> + Inner + updateSelectedRoi({ radius_inner: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> + Outer + updateSelectedRoi({ radius: v as number })} size="small" sx={{ ...sliderStyles.small, width: 40 }} /> + + )} + {selectedRoi.shape !== "rectangle" && selectedRoi.shape !== "annular" && ( + <> + Size + updateSelectedRoi({ radius: v as number })} size="small" sx={{ ...sliderStyles.small, width: 50 }} /> + + )} + + {ROI_COLORS.map(c => ( + updateSelectedRoi({ color: c })} sx={{ width: 12, height: 12, bgcolor: c, cursor: "pointer", border: c === selectedRoi.color ? `2px solid ${themeColors.text}` : "1px solid transparent", "&:hover": { opacity: 0.8 } }} /> + ))} + + Border + updateSelectedRoi({ line_width: v as number })} size="small" sx={{ ...sliderStyles.small, width: 30 }} /> + updateSelectedRoi({ highlight: !selectedRoi.highlight })} + sx={{ cursor: "pointer", fontSize: 10, color: selectedRoi.highlight ? themeColors.accentGreen : themeColors.textMuted, "&:hover": { opacity: 0.8 } }} + title="Focus (dim outside)" + >{selectedRoi.highlight ? "\u25C9 Focus" : "\u25CB Focus"} + + + )} + {/* ROI list */} + {roiList && roiList.length > 0 && ( + + {roiList.map((roi, i) => { + const c = roi.color || ROI_COLORS[i % ROI_COLORS.length]; + const isSelected = i === roiSelectedIdx; + const shapeLabel = roi.shape === "rectangle" ? `${roi.width}×${roi.height}` : roi.shape === "annular" ? `r${roi.radius_inner}-${roi.radius}` : `r${roi.radius}`; + return ( + setRoiSelectedIdx(i)} sx={{ display: "flex", alignItems: "center", gap: "3px", lineHeight: 1.6, cursor: "pointer", "&:hover .roi-delete": { opacity: 1 } }}> + + + {i + 1}{" "} + {roi.shape} ({roi.row}, {roi.col}) {shapeLabel} + + { e.stopPropagation(); const newList = roiList.map((r, j) => ({ ...r, highlight: j === i ? !r.highlight : false })); setRoiList(newList); }} + sx={{ cursor: "pointer", fontSize: 10, color: roi.highlight ? themeColors.accentGreen : themeColors.textMuted, lineHeight: 1, opacity: roi.highlight ? 1 : 0.5, "&:hover": { opacity: 1 } }} + title="Focus (dim outside)" + >{roi.highlight ? "\u25C9" : "\u25CB"} + { e.stopPropagation(); const newList = roiList.filter((_, j) => j !== i); setRoiList(newList); setRoiSelectedIdx(newList.length > 0 ? Math.min(roiSelectedIdx, newList.length - 1) : -1); }} + sx={{ opacity: 0, cursor: "pointer", fontSize: 10, color: themeColors.textMuted, ml: 0.5, lineHeight: 1, "&:hover": { color: "#f44336" } }} + >× + + ); + })} + + )} + + )} + + )} + + + {/* FFT Panel - canvas + stats (single mode only) */} + {effectiveShowFft && !isGallery && ( + + {/* Spacer — matches main panel title row height for canvas alignment */} + + {/* Controls row — matches main panel controls row height */} + + {fftComputing ? ( + + {fftProgress || "Computing FFT…"} + ) : roiFftActive && fftCropDims ? ( + + ROI FFT ({fftCropDims.cropWidth}×{fftCropDims.cropHeight}) + + ) : } + {( + + )} + + + + + {fftComputing && ( + + + {fftProgress || "Computing FFT…"} + + + )} + {( + + )} + + {/* FFT Stats Bar */} + {fftStats && fftStats.length === 4 && ( + + Mean {formatNumber(fftStats[0])} + Min {formatNumber(fftStats[1])} + Max {formatNumber(fftStats[2])} + Std {formatNumber(fftStats[3])} + {fftClickInfo && ( + <> + + + {fftClickInfo.dSpacing != null ? ( + <>d = {fftClickInfo.dSpacing >= 10 ? `${(fftClickInfo.dSpacing / 10).toFixed(2)} nm` : `${fftClickInfo.dSpacing.toFixed(2)} Å`}{" | |g| = "}{fftClickInfo.spatialFreq!.toFixed(4)} Å⁻¹ + ) : ( + <>dist = {fftClickInfo.distPx.toFixed(1)} px + )} + + + )} + + )} + {/* FFT Controls - two rows + histogram (matching main panel layout) */} + + + + {/* Row 1: Scale + Color + Colorbar */} + + Scale: + + Color: + + Colorbar: + { setFftShowColorbar(e.target.checked); }} size="small" sx={switchStyles.small} /> + + {/* Row 2: Auto + zoom indicator */} + + Auto: + { setFftAuto(e.target.checked); }} size="small" sx={switchStyles.small} /> + {fftCropDims && ( + <> + Win: + { setFftWindow(e.target.checked); }} size="small" sx={switchStyles.small} /> + + )} + {fftZoom !== DEFAULT_FFT_ZOOM && ( + {fftZoom.toFixed(1)}x + )} + + + {/* Right: FFT Histogram */} + {( + + {fftHistogramData && ( + { setFftVminPct(min); setFftVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme === "dark" ? "dark" : "light"} dataMin={fftDataRange.min} dataMax={fftDataRange.max} /> + )} + + )} + + + + )} + + + ); +} + +export const render = createRender(Show2D); diff --git a/widget/js/show4dstem/index.tsx b/widget/js/show4dstem/index.tsx new file mode 100644 index 00000000..2a78455b --- /dev/null +++ b/widget/js/show4dstem/index.tsx @@ -0,0 +1,4066 @@ +/// +import * as React from "react"; +import { createRender, useModelState, useModel } from "@anywidget/react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Menu from "@mui/material/Menu"; +import Slider from "@mui/material/Slider"; +import Button from "@mui/material/Button"; +import Switch from "@mui/material/Switch"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import PauseIcon from "@mui/icons-material/Pause"; +import StopIcon from "@mui/icons-material/Stop"; +import FastRewindIcon from "@mui/icons-material/FastRewind"; +import FastForwardIcon from "@mui/icons-material/FastForward"; +import JSZip from "jszip"; +import { useTheme } from "../theme"; +import { COLORMAPS, applyColormap, renderToOffscreen } from "../colormaps"; +import { WebGPUFFT, getWebGPUFFT, fft2d, fftshift, autoEnhanceFFT, nextPow2, applyHannWindow2D } from "../fft"; +import { drawScaleBarHiDPI, drawColorbar, roundToNiceValue, exportFigure, canvasToPDF } from "../figure"; +import { findDataRange, sliderRange, computeStats, applyLogScale, computeHistogramFromBytes, percentileClip } from "../stats"; +import { downloadBlob, formatNumber, downloadDataView } from "../format"; + +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 10; + +// ============================================================================ +// UI Styles - component styling helpers +// ============================================================================ +const typography = { + label: { fontSize: 11 }, + labelSmall: { fontSize: 10 }, + value: { fontSize: 10, fontFamily: "monospace" }, + title: { fontWeight: "bold" as const }, +}; + +const controlPanel = { + select: { minWidth: 90, fontSize: 11, "& .MuiSelect-select": { py: 0.5 } }, +}; + +const container = { + root: { p: 2, bgcolor: "transparent", color: "inherit", fontFamily: "monospace", overflow: "visible" }, + imageBox: { bgcolor: "#000", border: "1px solid #444", overflow: "hidden", position: "relative" as const }, +}; + +const upwardMenuProps = { + anchorOrigin: { vertical: "top" as const, horizontal: "left" as const }, + transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, + sx: { zIndex: 9999 }, +}; + +const switchStyles = { + small: { '& .MuiSwitch-thumb': { width: 12, height: 12 }, '& .MuiSwitch-switchBase': { padding: '4px' } }, + medium: { '& .MuiSwitch-thumb': { width: 14, height: 14 }, '& .MuiSwitch-switchBase': { padding: '4px' } }, +}; + +const sliderStyles = { + small: { + "& .MuiSlider-thumb": { width: 12, height: 12 }, + "& .MuiSlider-rail": { height: 3 }, + "& .MuiSlider-track": { height: 3 }, + }, +}; + +// ============================================================================ +// Layout Constants - consistent spacing throughout +// ============================================================================ +const SPACING = { + XS: 4, // Extra small gap + SM: 8, // Small gap (default between elements) + MD: 12, // Medium gap (between control groups) + LG: 16, // Large gap (between major sections) +}; + +const CANVAS_SIZE = 450; // Both DP and VI canvases + +// Theme-aware ROI colors for DP detector overlay +interface RoiColors { + stroke: string; + strokeDragging: string; + fill: string; + fillDragging: string; + handleFill: string; + innerStroke: string; + innerStrokeDragging: string; + innerHandleFill: string; + textColor: string; +} +const DARK_ROI_COLORS: RoiColors = { + stroke: "rgba(0, 255, 0, 0.9)", + strokeDragging: "rgba(255, 255, 0, 0.9)", + fill: "rgba(0, 255, 0, 0.12)", + fillDragging: "rgba(255, 255, 0, 0.12)", + handleFill: "rgba(0, 255, 0, 0.8)", + innerStroke: "rgba(0, 220, 255, 0.9)", + innerStrokeDragging: "rgba(255, 200, 0, 0.9)", + innerHandleFill: "rgba(0, 220, 255, 0.8)", + textColor: "#0f0", +}; +const LIGHT_ROI_COLORS: RoiColors = { + stroke: "rgba(0, 140, 0, 0.9)", + strokeDragging: "rgba(200, 160, 0, 0.9)", + fill: "rgba(0, 140, 0, 0.15)", + fillDragging: "rgba(200, 160, 0, 0.15)", + handleFill: "rgba(0, 140, 0, 0.85)", + innerStroke: "rgba(0, 160, 200, 0.9)", + innerStrokeDragging: "rgba(200, 160, 0, 0.9)", + innerHandleFill: "rgba(0, 160, 200, 0.85)", + textColor: "#0a0", +}; + +// Interaction constants +const RESIZE_HIT_AREA_PX = 10; +const CIRCLE_HANDLE_ANGLE = 0.707; // cos(45°) +// Compact button style for Reset/Export +const compactButton = { + fontSize: 10, + py: 0.25, + px: 1, + minWidth: 0, + "&.Mui-disabled": { + color: "#666", + borderColor: "#444", + }, +}; + +// Control row style — bordered container per row. +const controlRow = { + display: "flex", + alignItems: "center", + gap: `${SPACING.SM}px`, + px: 1, + py: 0.5, + width: "fit-content", +}; + +/** Format stat value for display (compact scientific notation for small values) */ +function formatStat(value: number): string { + if (value === 0) return "0"; + const abs = Math.abs(value); + if (abs < 0.001 || abs >= 10000) { + return value.toExponential(2); + } + if (abs < 0.01) return value.toFixed(4); + if (abs < 1) return value.toFixed(3); + return value.toFixed(2); +} + + +// ============================================================================ +// FFT peak finder (snap to Bragg spot with sub-pixel centroid refinement) +// ============================================================================ +function findFFTPeak(mag: Float32Array, width: number, height: number, col: number, row: number, radius: number): { row: number; col: number } { + const c0 = Math.max(0, Math.floor(col) - radius); + const r0 = Math.max(0, Math.floor(row) - radius); + const c1 = Math.min(width - 1, Math.floor(col) + radius); + const r1 = Math.min(height - 1, Math.floor(row) + radius); + let bestCol = Math.round(col), bestRow = Math.round(row), bestVal = -Infinity; + for (let ir = r0; ir <= r1; ir++) { + for (let ic = c0; ic <= c1; ic++) { + const val = mag[ir * width + ic]; + if (val > bestVal) { bestVal = val; bestCol = ic; bestRow = ir; } + } + } + const wc0 = Math.max(0, bestCol - 1), wc1 = Math.min(width - 1, bestCol + 1); + const wr0 = Math.max(0, bestRow - 1), wr1 = Math.min(height - 1, bestRow + 1); + let sumW = 0, sumWC = 0, sumWR = 0; + for (let ir = wr0; ir <= wr1; ir++) { + for (let ic = wc0; ic <= wc1; ic++) { + const w = mag[ir * width + ic]; + sumW += w; sumWC += w * ic; sumWR += w * ir; + } + } + if (sumW > 0) return { row: sumWR / sumW, col: sumWC / sumW }; + return { row: bestRow, col: bestCol }; +} +const FFT_SNAP_RADIUS = 5; + +/** + * Draw VI crosshair on high-DPI canvas (crisp regardless of image resolution) + * Note: Does NOT clear canvas - should be called after drawScaleBarHiDPI + */ +function drawViPositionMarker( + canvas: HTMLCanvasElement, + dpr: number, + posRow: number, // Position in image coordinates + posCol: number, + zoom: number, + panX: number, + panY: number, + imageWidth: number, + imageHeight: number, + isDragging: boolean +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + const scaleX = cssWidth / imageWidth; + const scaleY = cssHeight / imageHeight; + + // Convert image coordinates to CSS pixel coordinates + const screenX = posCol * zoom * scaleX + panX * scaleX; + const screenY = posRow * zoom * scaleY + panY * scaleY; + + // Simple crosshair (no circle) + const crosshairSize = 12; + const lineWidth = 1.5; + + ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + ctx.strokeStyle = isDragging ? "rgba(255, 255, 0, 0.9)" : "rgba(255, 100, 100, 0.9)"; + ctx.lineWidth = lineWidth; + + // Draw crosshair lines only + ctx.beginPath(); + ctx.moveTo(screenX - crosshairSize, screenY); + ctx.lineTo(screenX + crosshairSize, screenY); + ctx.moveTo(screenX, screenY - crosshairSize); + ctx.lineTo(screenX, screenY + crosshairSize); + ctx.stroke(); + + ctx.restore(); +} + +/** + * Draw VI ROI overlay on high-DPI canvas for real-space region selection + * Note: Does NOT clear canvas - should be called after drawViPositionMarker + */ +function drawViRoiOverlayHiDPI( + canvas: HTMLCanvasElement, + dpr: number, + roiMode: string, + centerRow: number, + centerCol: number, + radius: number, + roiWidth: number, + roiHeight: number, + zoom: number, + panX: number, + panY: number, + imageWidth: number, + imageHeight: number, + isDragging: boolean, + isDraggingResize: boolean, + isHoveringResize: boolean +) { + if (roiMode === "off") return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + const scaleX = cssWidth / imageWidth; + const scaleY = cssHeight / imageHeight; + + // Convert image coordinates to screen coordinates (row→screenY, col→screenX) + const screenX = centerCol * zoom * scaleX + panX * scaleX; + const screenY = centerRow * zoom * scaleY + panY * scaleY; + + const lineWidth = 2.5; + const crosshairSize = 10; + const handleRadius = 6; + + ctx.shadowColor = "rgba(0, 0, 0, 0.4)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + // Helper to draw resize handle (purple color for VI ROI to differentiate from DP) + const drawResizeHandle = (handleX: number, handleY: number) => { + let handleFill: string; + let handleStroke: string; + + if (isDraggingResize) { + handleFill = "rgba(180, 100, 255, 1)"; + handleStroke = "rgba(255, 255, 255, 1)"; + } else if (isHoveringResize) { + handleFill = "rgba(220, 150, 255, 1)"; + handleStroke = "rgba(255, 255, 255, 1)"; + } else { + handleFill = "rgba(160, 80, 255, 0.8)"; + handleStroke = "rgba(255, 255, 255, 0.8)"; + } + ctx.beginPath(); + ctx.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI); + ctx.fillStyle = handleFill; + ctx.fill(); + ctx.strokeStyle = handleStroke; + ctx.lineWidth = 1.5; + ctx.stroke(); + }; + + // Helper to draw center crosshair (purple/magenta for VI ROI) + const drawCenterCrosshair = () => { + ctx.strokeStyle = isDragging ? "rgba(255, 200, 0, 0.9)" : "rgba(180, 80, 255, 0.9)"; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(screenX - crosshairSize, screenY); + ctx.lineTo(screenX + crosshairSize, screenY); + ctx.moveTo(screenX, screenY - crosshairSize); + ctx.lineTo(screenX, screenY + crosshairSize); + ctx.stroke(); + }; + + // Purple/magenta color for VI ROI to differentiate from green DP detector + const strokeColor = isDragging ? "rgba(255, 200, 0, 0.9)" : "rgba(180, 80, 255, 0.9)"; + const fillColor = isDragging ? "rgba(255, 200, 0, 0.15)" : "rgba(180, 80, 255, 0.15)"; + + if (roiMode === "circle" && radius > 0) { + const screenRadiusX = radius * zoom * scaleX; + const screenRadiusY = radius * zoom * scaleY; + + ctx.strokeStyle = strokeColor; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.ellipse(screenX, screenY, screenRadiusX, screenRadiusY, 0, 0, 2 * Math.PI); + ctx.stroke(); + + ctx.fillStyle = fillColor; + ctx.fill(); + + drawCenterCrosshair(); + + // Resize handle at 45° diagonal + const handleOffsetX = screenRadiusX * CIRCLE_HANDLE_ANGLE; + const handleOffsetY = screenRadiusY * CIRCLE_HANDLE_ANGLE; + drawResizeHandle(screenX + handleOffsetX, screenY + handleOffsetY); + + } else if (roiMode === "square" && radius > 0) { + // Square uses radius as half-size + const screenHalfW = radius * zoom * scaleX; + const screenHalfH = radius * zoom * scaleY; + const left = screenX - screenHalfW; + const top = screenY - screenHalfH; + + ctx.strokeStyle = strokeColor; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); + ctx.stroke(); + + ctx.fillStyle = fillColor; + ctx.fill(); + + drawCenterCrosshair(); + drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); + + } else if (roiMode === "rect" && roiWidth > 0 && roiHeight > 0) { + const screenHalfW = (roiWidth / 2) * zoom * scaleX; + const screenHalfH = (roiHeight / 2) * zoom * scaleY; + const left = screenX - screenHalfW; + const top = screenY - screenHalfH; + + ctx.strokeStyle = strokeColor; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); + ctx.stroke(); + + ctx.fillStyle = fillColor; + ctx.fill(); + + drawCenterCrosshair(); + drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); + } + + ctx.restore(); +} + +/** + * Draw DP crosshair on high-DPI canvas (crisp regardless of detector resolution) + * Note: Does NOT clear canvas - should be called after drawScaleBarHiDPI + */ +function drawDpCrosshairHiDPI( + canvas: HTMLCanvasElement, + dpr: number, + kCol: number, // Column position in detector coordinates + kRow: number, // Row position in detector coordinates + zoom: number, + panX: number, + panY: number, + detWidth: number, + detHeight: number, + isDragging: boolean, + roiColors: RoiColors = DARK_ROI_COLORS +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + // Use separate X/Y scale factors (canvas stretches to fill container) + const scaleX = cssWidth / detWidth; + const scaleY = cssHeight / detHeight; + + // Convert detector coordinates to CSS pixel coordinates + const screenX = kCol * zoom * scaleX + panX * scaleX; + const screenY = kRow * zoom * scaleY + panY * scaleY; + + // Fixed UI sizes in CSS pixels (consistent with VI crosshair) + const crosshairSize = 18; + const lineWidth = 3; + const dotRadius = 6; + + ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; + ctx.lineWidth = lineWidth; + + // Draw crosshair + ctx.beginPath(); + ctx.moveTo(screenX - crosshairSize, screenY); + ctx.lineTo(screenX + crosshairSize, screenY); + ctx.moveTo(screenX, screenY - crosshairSize); + ctx.lineTo(screenX, screenY + crosshairSize); + ctx.stroke(); + + // Draw center dot + ctx.beginPath(); + ctx.arc(screenX, screenY, dotRadius, 0, 2 * Math.PI); + ctx.stroke(); + + ctx.restore(); +} + +/** + * Draw ROI overlay (circle, square, rect, annular) on high-DPI canvas + * Note: Does NOT clear canvas - should be called after drawScaleBarHiDPI + */ +function drawRoiOverlayHiDPI( + canvas: HTMLCanvasElement, + dpr: number, + roiMode: string, + centerCol: number, + centerRow: number, + radius: number, + radiusInner: number, + roiWidth: number, + roiHeight: number, + zoom: number, + panX: number, + panY: number, + detWidth: number, + detHeight: number, + isDragging: boolean, + isDraggingResize: boolean, + isDraggingResizeInner: boolean, + isHoveringResize: boolean, + isHoveringResizeInner: boolean, + roiColors: RoiColors = DARK_ROI_COLORS +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.save(); + ctx.scale(dpr, dpr); + + const cssWidth = canvas.width / dpr; + const cssHeight = canvas.height / dpr; + // Use separate X/Y scale factors (canvas stretches to fill container) + const scaleX = cssWidth / detWidth; + const scaleY = cssHeight / detHeight; + + // Convert detector coordinates to CSS pixel coordinates + const screenX = centerCol * zoom * scaleX + panX * scaleX; + const screenY = centerRow * zoom * scaleY + panY * scaleY; + + // Fixed UI sizes in CSS pixels + const lineWidth = 2.5; + const crosshairSizeSmall = 10; + const handleRadius = 6; + + ctx.shadowColor = "rgba(0, 0, 0, 0.4)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + // Helper to draw resize handle + const drawResizeHandle = (handleX: number, handleY: number, isInner: boolean = false) => { + let handleFill: string; + let handleStroke: string; + const dragging = isInner ? isDraggingResizeInner : isDraggingResize; + const hovering = isInner ? isHoveringResizeInner : isHoveringResize; + + if (dragging) { + handleFill = "rgba(0, 200, 255, 1)"; + handleStroke = "rgba(255, 255, 255, 1)"; + } else if (hovering) { + handleFill = "rgba(255, 100, 100, 1)"; + handleStroke = "rgba(255, 255, 255, 1)"; + } else { + handleFill = isInner ? roiColors.innerHandleFill : roiColors.handleFill; + handleStroke = "rgba(255, 255, 255, 0.8)"; + } + ctx.beginPath(); + ctx.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI); + ctx.fillStyle = handleFill; + ctx.fill(); + ctx.strokeStyle = handleStroke; + ctx.lineWidth = 1.5; + ctx.stroke(); + }; + + // Helper to draw center crosshair + const drawCenterCrosshair = () => { + ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.moveTo(screenX - crosshairSizeSmall, screenY); + ctx.lineTo(screenX + crosshairSizeSmall, screenY); + ctx.moveTo(screenX, screenY - crosshairSizeSmall); + ctx.lineTo(screenX, screenY + crosshairSizeSmall); + ctx.stroke(); + }; + + if (roiMode === "circle" && radius > 0) { + // Use separate X/Y radii for ellipse (handles non-square detectors) + const screenRadiusX = radius * zoom * scaleX; + const screenRadiusY = radius * zoom * scaleY; + + // Draw ellipse (becomes circle if scaleX === scaleY) + ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.ellipse(screenX, screenY, screenRadiusX, screenRadiusY, 0, 0, 2 * Math.PI); + ctx.stroke(); + + // Semi-transparent fill + ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; + ctx.fill(); + + drawCenterCrosshair(); + + // Resize handle at 45° diagonal + const handleOffsetX = screenRadiusX * CIRCLE_HANDLE_ANGLE; + const handleOffsetY = screenRadiusY * CIRCLE_HANDLE_ANGLE; + drawResizeHandle(screenX + handleOffsetX, screenY + handleOffsetY); + + } else if (roiMode === "square" && radius > 0) { + // Square in detector space uses same half-size in both dimensions + const screenHalfW = radius * zoom * scaleX; + const screenHalfH = radius * zoom * scaleY; + const left = screenX - screenHalfW; + const top = screenY - screenHalfH; + + ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); + ctx.stroke(); + + ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; + ctx.fill(); + + drawCenterCrosshair(); + drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); + + } else if (roiMode === "rect" && roiWidth > 0 && roiHeight > 0) { + const screenHalfW = (roiWidth / 2) * zoom * scaleX; + const screenHalfH = (roiHeight / 2) * zoom * scaleY; + const left = screenX - screenHalfW; + const top = screenY - screenHalfH; + + ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.rect(left, top, screenHalfW * 2, screenHalfH * 2); + ctx.stroke(); + + ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; + ctx.fill(); + + drawCenterCrosshair(); + drawResizeHandle(screenX + screenHalfW, screenY + screenHalfH); + + } else if (roiMode === "annular" && radius > 0) { + // Use separate X/Y radii for ellipses + const screenRadiusOuterX = radius * zoom * scaleX; + const screenRadiusOuterY = radius * zoom * scaleY; + const screenRadiusInnerX = (radiusInner || 0) * zoom * scaleX; + const screenRadiusInnerY = (radiusInner || 0) * zoom * scaleY; + + // Outer ellipse + ctx.strokeStyle = isDragging ? roiColors.strokeDragging : roiColors.stroke; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.ellipse(screenX, screenY, screenRadiusOuterX, screenRadiusOuterY, 0, 0, 2 * Math.PI); + ctx.stroke(); + + // Inner ellipse + ctx.strokeStyle = isDragging ? roiColors.innerStrokeDragging : roiColors.innerStroke; + ctx.beginPath(); + ctx.ellipse(screenX, screenY, screenRadiusInnerX, screenRadiusInnerY, 0, 0, 2 * Math.PI); + ctx.stroke(); + + // Fill annular region + ctx.fillStyle = isDragging ? roiColors.fillDragging : roiColors.fill; + ctx.beginPath(); + ctx.ellipse(screenX, screenY, screenRadiusOuterX, screenRadiusOuterY, 0, 0, 2 * Math.PI); + ctx.ellipse(screenX, screenY, screenRadiusInnerX, screenRadiusInnerY, 0, 0, 2 * Math.PI, true); + ctx.fill(); + + drawCenterCrosshair(); + + // Outer handle at 45° diagonal + const handleOffsetOuterX = screenRadiusOuterX * CIRCLE_HANDLE_ANGLE; + const handleOffsetOuterY = screenRadiusOuterY * CIRCLE_HANDLE_ANGLE; + drawResizeHandle(screenX + handleOffsetOuterX, screenY + handleOffsetOuterY); + + // Inner handle at 45° diagonal + const handleOffsetInnerX = screenRadiusInnerX * CIRCLE_HANDLE_ANGLE; + const handleOffsetInnerY = screenRadiusInnerY * CIRCLE_HANDLE_ANGLE; + drawResizeHandle(screenX + handleOffsetInnerX, screenY + handleOffsetInnerY, true); + } + + ctx.restore(); +} + +// ============================================================================ +// Histogram Component +// ============================================================================ + +interface HistogramProps { + data: Float32Array | null; + vminPct: number; + vmaxPct: number; + onRangeChange: (min: number, max: number) => void; + width?: number; + height?: number; + theme?: "light" | "dark"; + dataMin?: number; + dataMax?: number; +} + +/** + * Info tooltip component - small ⓘ icon with hover tooltip + */ +function InfoTooltip({ text, theme = "dark" }: { text: React.ReactNode; theme?: "light" | "dark" }) { + const isDark = theme === "dark"; + const content = typeof text === "string" + ? {text} + : text; + return ( + + + ⓘ + + + ); +} + +function KeyboardShortcuts({ items }: { items: [string, string][] }) { + return ( + + + {items.map(([key, desc], i) => ( + {key}{desc} + ))} + + + ); +} + +/** + * Histogram component with integrated vmin/vmax slider and statistics. + * Shows data distribution with adjustable clipping. + */ +function Histogram({ + data, + vminPct, + vmaxPct, + onRangeChange, + width = 120, + height = 40, + theme = "dark", + dataMin = 0, + dataMax = 1, +}: HistogramProps) { + const canvasRef = React.useRef(null); + const bins = React.useMemo(() => computeHistogramFromBytes(data), [data]); + + // Theme-aware colors + const colors = theme === "dark" ? { + bg: "#1a1a1a", + barActive: "#888", + barInactive: "#444", + border: "#333", + } : { + bg: "#f0f0f0", + barActive: "#666", + barInactive: "#bbb", + border: "#ccc", + }; + + // Draw histogram (vertical gray bars) + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + + // Clear with theme background + ctx.fillStyle = colors.bg; + ctx.fillRect(0, 0, width, height); + + // Reduce to fewer bins for cleaner display + const displayBins = 64; + const binRatio = Math.floor(bins.length / displayBins); + const reducedBins: number[] = []; + for (let i = 0; i < displayBins; i++) { + let sum = 0; + for (let j = 0; j < binRatio; j++) { + sum += bins[i * binRatio + j] || 0; + } + reducedBins.push(sum / binRatio); + } + + // Normalize + const maxVal = Math.max(...reducedBins, 0.001); + const barWidth = width / displayBins; + + // Calculate which bins are in the clipped range + const vminBin = Math.floor((vminPct / 100) * displayBins); + const vmaxBin = Math.floor((vmaxPct / 100) * displayBins); + + // Draw histogram bars + for (let i = 0; i < displayBins; i++) { + const barHeight = (reducedBins[i] / maxVal) * (height - 2); + const x = i * barWidth; + + // Bars inside range are highlighted, outside are dimmed + const inRange = i >= vminBin && i <= vmaxBin; + ctx.fillStyle = inRange ? colors.barActive : colors.barInactive; + ctx.fillRect(x + 0.5, height - barHeight, Math.max(1, barWidth - 1), barHeight); + } + + }, [bins, vminPct, vmaxPct, width, height, colors]); + + return ( + + + { + const [newMin, newMax] = v as number[]; + onRangeChange(Math.min(newMin, newMax - 1), Math.max(newMax, newMin + 1)); + }} + min={0} + max={100} + size="small" + valueLabelDisplay="auto" + valueLabelFormat={(pct) => { + const val = dataMin + (pct / 100) * (dataMax - dataMin); + return val >= 1000 ? val.toExponential(1) : val.toFixed(1); + }} + sx={{ + width, + py: 0, + "& .MuiSlider-thumb": { width: 8, height: 8 }, + "& .MuiSlider-rail": { height: 2 }, + "& .MuiSlider-track": { height: 2 }, + "& .MuiSlider-valueLabel": { fontSize: 10, padding: "2px 4px" }, + }} + /> + {(() => { const v = dataMin + (vminPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()}{(() => { const v = dataMin + (vmaxPct / 100) * (dataMax - dataMin); return v >= 1000 ? v.toExponential(1) : v.toFixed(1); })()} + + ); +} + +// ============================================================================ +// Line Profile Sampling +// ============================================================================ + +function sampleSingleLine(data: Float32Array, w: number, h: number, row0: number, col0: number, row1: number, col1: number): Float32Array { + const dc = col1 - col0; + const dr = row1 - row0; + const len = Math.sqrt(dc * dc + dr * dr); + const n = Math.max(2, Math.ceil(len)); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { + const t = i / (n - 1); + const c = col0 + t * dc; + const r = row0 + t * dr; + const ci = Math.floor(c), ri = Math.floor(r); + const cf = c - ci, rf = r - ri; + const c0c = Math.max(0, Math.min(w - 1, ci)); + const c1c = Math.max(0, Math.min(w - 1, ci + 1)); + const r0c = Math.max(0, Math.min(h - 1, ri)); + const r1c = Math.max(0, Math.min(h - 1, ri + 1)); + out[i] = data[r0c * w + c0c] * (1 - cf) * (1 - rf) + + data[r0c * w + c1c] * cf * (1 - rf) + + data[r1c * w + c0c] * (1 - cf) * rf + + data[r1c * w + c1c] * cf * rf; + } + return out; +} + +function sampleLineProfile(data: Float32Array, w: number, h: number, row0: number, col0: number, row1: number, col1: number, profileWidth: number = 1): Float32Array { + if (profileWidth <= 1) return sampleSingleLine(data, w, h, row0, col0, row1, col1); + const dc = col1 - col0; + const dr = row1 - row0; + const len = Math.sqrt(dc * dc + dr * dr); + if (len < 1e-8) return sampleSingleLine(data, w, h, row0, col0, row1, col1); + const perpR = -dc / len; + const perpC = dr / len; + const half = (profileWidth - 1) / 2; + let accumulated: Float32Array | null = null; + for (let k = 0; k < profileWidth; k++) { + const off = -half + k; + const vals = sampleSingleLine(data, w, h, row0 + off * perpR, col0 + off * perpC, row1 + off * perpR, col1 + off * perpC); + if (!accumulated) { + accumulated = vals; + } else { + for (let i = 0; i < vals.length; i++) accumulated[i] += vals[i]; + } + } + if (accumulated) for (let i = 0; i < accumulated.length; i++) accumulated[i] /= profileWidth; + return accumulated || new Float32Array(0); +} + +function pointToSegmentDistance(col: number, row: number, col0: number, row0: number, col1: number, row1: number): number { + const dc = col1 - col0; + const dr = row1 - row0; + const lenSq = dc * dc + dr * dr; + if (lenSq <= 1e-12) return Math.sqrt((col - col0) ** 2 + (row - row0) ** 2); + const tRaw = ((col - col0) * dc + (row - row0) * dr) / lenSq; + const t = Math.max(0, Math.min(1, tRaw)); + const projCol = col0 + t * dc; + const projRow = row0 + t * dr; + return Math.sqrt((col - projCol) ** 2 + (row - projRow) ** 2); +} + +// ============================================================================ +// Crop single-mode ROI region from raw float32 data for ROI-scoped FFT +// ============================================================================ +function cropSingleROI( + data: Float32Array, imgW: number, imgH: number, + mode: string, centerRow: number, centerCol: number, + radius: number, roiW: number, roiH: number, +): { cropped: Float32Array; cropW: number; cropH: number } | null { + if (mode === "off") return null; + let x0: number, y0: number, x1: number, y1: number; + + if (mode === "rect") { + const hw = roiW / 2, hh = roiH / 2; + x0 = Math.max(0, Math.floor(centerCol - hw)); + y0 = Math.max(0, Math.floor(centerRow - hh)); + x1 = Math.min(imgW, Math.ceil(centerCol + hw)); + y1 = Math.min(imgH, Math.ceil(centerRow + hh)); + } else { + x0 = Math.max(0, Math.floor(centerCol - radius)); + y0 = Math.max(0, Math.floor(centerRow - radius)); + x1 = Math.min(imgW, Math.ceil(centerCol + radius)); + y1 = Math.min(imgH, Math.ceil(centerRow + radius)); + } + + const cropW = x1 - x0, cropH = y1 - y0; + if (cropW < 2 || cropH < 2) return null; + + const cropped = new Float32Array(cropW * cropH); + if (mode === "circle") { + const rSq = radius * radius; + for (let dy = 0; dy < cropH; dy++) { + for (let dx = 0; dx < cropW; dx++) { + const ix = x0 + dx, iy = y0 + dy; + const distSq = (ix - centerCol) * (ix - centerCol) + (iy - centerRow) * (iy - centerRow); + cropped[dy * cropW + dx] = distSq <= rSq ? data[iy * imgW + ix] : 0; + } + } + } else { + for (let dy = 0; dy < cropH; dy++) { + const srcOff = (y0 + dy) * imgW + x0; + cropped.set(data.subarray(srcOff, srcOff + cropW), dy * cropW); + } + } + return { cropped, cropW, cropH }; +} + +// ============================================================================ +// Main Component +// ============================================================================ +function Show4DSTEM() { + // Direct model access for batched updates + const model = useModel(); + + // ───────────────────────────────────────────────────────────────────────── + // Model State (synced with Python) + // ───────────────────────────────────────────────────────────────────────── + const [shapeRows] = useModelState("shape_rows"); + const [shapeCols] = useModelState("shape_cols"); + const [detRows] = useModelState("det_rows"); + const [detCols] = useModelState("det_cols"); + + const [posRow, setPosRow] = useModelState("pos_row"); + const [posCol, setPosCol] = useModelState("pos_col"); + const [roiCenterCol, setRoiCenterCol] = useModelState("roi_center_col"); + const [roiCenterRow, setRoiCenterRow] = useModelState("roi_center_row"); + const [pixelSize] = useModelState("pixel_size"); + const [pixelUnit] = useModelState("pixel_unit"); + const [kPixelSize] = useModelState("k_pixel_size"); + const [kPixelUnit] = useModelState("k_pixel_unit"); + const [kCalibrated] = useModelState("k_calibrated"); + const [widgetVersion] = useModelState("widget_version"); + const [title] = useModelState("title"); + + const [frameBytes] = useModelState("frame_bytes"); + const [virtualImageBytes] = useModelState("virtual_image_bytes"); + + // ROI state + const [roiRadius, setRoiRadius] = useModelState("roi_radius"); + const [roiRadiusInner, setRoiRadiusInner] = useModelState("roi_radius_inner"); + const [roiMode, setRoiMode] = useModelState("roi_mode"); + const [roiWidth, setRoiWidth] = useModelState("roi_width"); + const [roiHeight, setRoiHeight] = useModelState("roi_height"); + + // Global min/max for DP normalization (from Python) + const [dpGlobalMin] = useModelState("dp_global_min"); + const [dpGlobalMax] = useModelState("dp_global_max"); + + // VI min/max for normalization (from Python) + // viDataMin/viDataMax are derived JS-side from virtual_image_bytes (computed below). + // Keeping them out of Python traits avoids a comm-message ordering race where + // bytes from click N arrive with min/max from click N-1. + + // Detector calibration (for presets) + const [bfRadius] = useModelState("bf_radius"); + const [centerCol] = useModelState("center_col"); + const [centerRow] = useModelState("center_row"); + + // Path animation state + const [pathPlaying, setPathPlaying] = useModelState("path_playing"); + const [pathIndex, setPathIndex] = useModelState("path_index"); + const [pathLength] = useModelState("path_length"); + const [pathIntervalMs] = useModelState("path_interval_ms"); + const [pathLoop] = useModelState("path_loop"); + + // Frame animation state (5D time/tilt series) + const [frameIdx, setFrameIdx] = useModelState("frame_idx"); + const [nFrames] = useModelState("n_frames"); + const [frameDimLabel] = useModelState("frame_dim_label"); + const [frameLabels] = useModelState("frame_labels"); + const [framePlaying, setFramePlaying] = useModelState("frame_playing"); + const [frameLoop, setFrameLoop] = useModelState("frame_loop"); + const [frameFps, setFrameFps] = useModelState("frame_fps"); + const [frameReverse, setFrameReverse] = useModelState("frame_reverse"); + const [frameBoomerang, setFrameBoomerang] = useModelState("frame_boomerang"); + + // Profile line state (synced with Python) + const [profileLine, setProfileLine] = useModelState<{row: number; col: number}[]>("profile_line"); + const [profileWidth] = useModelState("profile_width"); + + // Auto-detection trigger + // ───────────────────────────────────────────────────────────────────────── + // Local State (UI-only, not synced to Python) + // ───────────────────────────────────────────────────────────────────────── + const [localKCol, setLocalKCol] = React.useState(roiCenterCol); + const [localKRow, setLocalKRow] = React.useState(roiCenterRow); + const [localPosRow, setLocalPosRow] = React.useState(posRow); + const [localPosCol, setLocalPosCol] = React.useState(posCol); + const [isDraggingDP, setIsDraggingDP] = React.useState(false); + // rAF coalescing for ROI drag: collapse rapid mousemove events into ≤1 + // Python comm message per animation frame. Without this, drag fires 60+ + // events/sec at >100ms Python compute each → queue piles up → laggy UX. + const roiCenterPendingRef = React.useRef<[number, number] | null>(null); + const roiCenterRafRef = React.useRef(null); + const flushRoiCenter = React.useCallback(() => { + if (roiCenterPendingRef.current) { + const [r, c] = roiCenterPendingRef.current; + model.set("roi_center", [r, c]); + model.save_changes(); + roiCenterPendingRef.current = null; + } + roiCenterRafRef.current = null; + }, [model]); + const queueRoiCenter = React.useCallback((row: number, col: number) => { + roiCenterPendingRef.current = [row, col]; + if (roiCenterRafRef.current === null) { + roiCenterRafRef.current = requestAnimationFrame(flushRoiCenter); + } + }, [flushRoiCenter]); + const [isDraggingVI, setIsDraggingVI] = React.useState(false); + const [isDraggingFFT, setIsDraggingFFT] = React.useState(false); + const [fftDragStart, setFftDragStart] = React.useState<{ x: number, y: number, panX: number, panY: number } | null>(null); + const [isDraggingResize, setIsDraggingResize] = React.useState(false); + const [isDraggingResizeInner, setIsDraggingResizeInner] = React.useState(false); // For annular inner handle + const [isHoveringResize, setIsHoveringResize] = React.useState(false); + const [isHoveringResizeInner, setIsHoveringResizeInner] = React.useState(false); + const resizeAspectRef = React.useRef(null); + // VI ROI drag/resize states (same pattern as DP) + const [isDraggingViRoi, setIsDraggingViRoi] = React.useState(false); + const [isDraggingViRoiResize, setIsDraggingViRoiResize] = React.useState(false); + const [isHoveringViRoiResize, setIsHoveringViRoiResize] = React.useState(false); + // Independent colormaps for DP and VI panels + const [showDpColorbar, setShowDpColorbar] = useModelState("dp_show_colorbar"); + const [dpColormap, setDpColormap] = useModelState("dp_colormap"); + const [viColormap, setViColormap] = useModelState("vi_colormap"); + // vmin/vmax percentile clipping (0-100) + const [dpVminPct, setDpVminPct] = useModelState("dp_vmin_pct"); + const [dpVmaxPct, setDpVmaxPct] = useModelState("dp_vmax_pct"); + const [viVminPct, setViVminPct] = useModelState("vi_vmin_pct"); + const [viVmaxPct, setViVmaxPct] = useModelState("vi_vmax_pct"); + // Absolute intensity bounds (override percentile sliders when both set) + const [traitDpVmin] = useModelState("dp_vmin"); + const [traitDpVmax] = useModelState("dp_vmax"); + const [traitViVmin] = useModelState("vi_vmin"); + const [traitViVmax] = useModelState("vi_vmax"); + // Scale mode: "linear" | "log" + const [dpScaleMode, setDpScaleMode] = useModelState<"linear" | "log">("dp_scale_mode"); + const [viScaleMode, setViScaleMode] = useModelState<"linear" | "log">("vi_scale_mode"); + // VI auto-contrast (1st/99th percentile clip) + Smooth (CSS bilinear blit). + // DP doesn't need them — Bragg spots read best with the slider's percentile + // range and nearest-neighbor blit. + const [viAutoContrast, setViAutoContrast] = useModelState("vi_auto_contrast"); + const [viSmooth, setViSmooth] = useModelState("vi_smooth"); + + // VI ROI state (real-space region selection for summed DP) - synced with Python + const [viRoiMode, setViRoiMode] = useModelState("vi_roi_mode"); + const [viRoiCenterRow, setViRoiCenterRow] = useModelState("vi_roi_center_row"); + const [viRoiCenterCol, setViRoiCenterCol] = useModelState("vi_roi_center_col"); + const [viRoiRadius, setViRoiRadius] = useModelState("vi_roi_radius"); + const [viRoiWidth, setViRoiWidth] = useModelState("vi_roi_width"); + const [viRoiHeight, setViRoiHeight] = useModelState("vi_roi_height"); + // Local VI ROI center for smooth dragging + const [localViRoiCenterRow, setLocalViRoiCenterRow] = React.useState(viRoiCenterRow || 0); + const [localViRoiCenterCol, setLocalViRoiCenterCol] = React.useState(viRoiCenterCol || 0); + const [viRoiDpBytes] = useModelState("vi_roi_dp_bytes"); + const [viRoiReduce, setViRoiReduce] = useModelState("vi_roi_reduce"); + // dp_stats are computed in JS from frameBytes (Python side no longer + // syncs a dp_stats trait — saves 4 trait sync round-trips per click). + const [viStats, setViStats] = React.useState([0, 0, 0, 0]); + const [viDataMin, setViDataMin] = React.useState(0); + const [viDataMax, setViDataMax] = React.useState(1); + const [showFft, setShowFft] = useModelState("show_fft"); + const [fftWindow, setFftWindow] = useModelState("fft_window"); + const [showControls] = useModelState("show_controls"); + + const effectiveShowFft = showFft; + + // ROI FFT state (VI ROI crops virtual image for FFT) + const [fftCropDims, setFftCropDims] = React.useState<{ cropWidth: number; cropHeight: number; fftWidth: number; fftHeight: number } | null>(null); + const roiFftActive = effectiveShowFft && viRoiMode !== "off"; + + // Canvas resize state + const [canvasSize, setCanvasSize] = React.useState(CANVAS_SIZE); + const [isResizingCanvas, setIsResizingCanvas] = React.useState(false); + const [resizeCanvasStart, setResizeCanvasStart] = React.useState<{ x: number; y: number; size: number } | null>(null); + + // Export + const [, setGifExportRequested] = useModelState("_gif_export_requested"); + const [gifData] = useModelState("_gif_data"); + const [gifMetadataJson] = useModelState("_gif_metadata_json"); + const [exporting, setExporting] = React.useState(false); + const [dpExportAnchor, setDpExportAnchor] = React.useState(null); + const [viExportAnchor, setViExportAnchor] = React.useState(null); + + // Cursor readout state + const [cursorInfo, setCursorInfo] = React.useState<{ row: number; col: number; value: number; panel: string } | null>(null); + + // DP Line profile state + const [profileActive, setProfileActive] = React.useState(false); + const [profileData, setProfileData] = React.useState(null); + const [profileHeight, setProfileHeight] = React.useState(76); + const [isResizingProfile, setIsResizingProfile] = React.useState(false); + const profileResizeStart = React.useRef<{ startY: number; startHeight: number } | null>(null); + const profileCanvasRef = React.useRef(null); + const profileBaseImageRef = React.useRef(null); + const profileLayoutRef = React.useRef<{ padLeft: number; plotW: number; padTop: number; plotH: number; gMin: number; gMax: number; totalDist: number; xUnit: string } | null>(null); + const profilePoints = profileLine || []; + const rawDpDataRef = React.useRef(null); + const dpClickStartRef = React.useRef<{ x: number; y: number } | null>(null); + const [draggingDpProfileEndpoint, setDraggingDpProfileEndpoint] = React.useState<0 | 1 | null>(null); + const [isDraggingDpProfileLine, setIsDraggingDpProfileLine] = React.useState(false); + const [hoveredDpProfileEndpoint, setHoveredDpProfileEndpoint] = React.useState<0 | 1 | null>(null); + const [isHoveringDpProfileLine, setIsHoveringDpProfileLine] = React.useState(false); + const dpProfileDragStartRef = React.useRef<{ row: number; col: number; p0: { row: number; col: number }; p1: { row: number; col: number } } | null>(null); + const dpDragOffsetRef = React.useRef<{ dRow: number; dCol: number }>({ dRow: 0, dCol: 0 }); + + // VI Line profile state + const [viProfileActive, setViProfileActive] = React.useState(false); + const [viProfileData, setViProfileData] = React.useState(null); + const [viProfilePoints, setViProfilePoints] = React.useState>([]); + const [viProfileHeight, setViProfileHeight] = React.useState(76); + const [isResizingViProfile, setIsResizingViProfile] = React.useState(false); + const viProfileResizeStart = React.useRef<{ startY: number; startHeight: number } | null>(null); + const viProfileCanvasRef = React.useRef(null); + const viProfileBaseImageRef = React.useRef(null); + const viProfileLayoutRef = React.useRef<{ padLeft: number; plotW: number; padTop: number; plotH: number; gMin: number; gMax: number; totalDist: number; xUnit: string } | null>(null); + const rawViDataRef = React.useRef(null); + const viClickStartRef = React.useRef<{ x: number; y: number } | null>(null); + const [draggingViProfileEndpoint, setDraggingViProfileEndpoint] = React.useState<0 | 1 | null>(null); + const [isDraggingViProfileLine, setIsDraggingViProfileLine] = React.useState(false); + const [hoveredViProfileEndpoint, setHoveredViProfileEndpoint] = React.useState<0 | 1 | null>(null); + const [isHoveringViProfileLine, setIsHoveringViProfileLine] = React.useState(false); + const viProfileDragStartRef = React.useRef<{ row: number; col: number; p0: { row: number; col: number }; p1: { row: number; col: number } } | null>(null); + const viRoiDragOffsetRef = React.useRef<{ dRow: number; dCol: number }>({ dRow: 0, dCol: 0 }); + + // Theme detection + const { themeInfo, colors: themeColors } = useTheme(); + const roiColors = themeInfo.theme === "dark" ? DARK_ROI_COLORS : LIGHT_ROI_COLORS; + const accentGreen = themeInfo.theme === "dark" ? "#0f0" : "#1a7a1a"; + + // Themed typography — applies theme colors to module-level font sizes + const typo = React.useMemo(() => ({ + label: { ...typography.label, color: themeColors.textMuted }, + labelSmall: { ...typography.labelSmall, color: themeColors.textMuted }, + value: { ...typography.value, color: themeColors.textMuted }, + title: { ...typography.title, color: themeColors.accent }, + }), [themeColors]); + + // Compute VI canvas dimensions to respect aspect ratio of rectangular scans + const viCanvasWidth = shapeRows > shapeCols ? Math.round(canvasSize * (shapeCols / shapeRows)) : canvasSize; + const viCanvasHeight = shapeCols > shapeRows ? Math.round(canvasSize * (shapeRows / shapeCols)) : canvasSize; + + // Histogram data - use state to ensure re-renders (both are Float32Array now) + const [dpHistogramData, setDpHistogramData] = React.useState(null); + const [viHistogramData, setViHistogramData] = React.useState(null); + + // DP stats computed JS-side from frame_bytes (was Python trait pre-refactor; + // moving to JS skips 4 sync trait round-trips per scan-position click). + const [dpStats, setDpStats] = React.useState([0, 0, 0, 0]); + + // Parse DP frame bytes for histogram (float32 now) + React.useEffect(() => { + if (!frameBytes) return; + // Parse as Float32Array since Python now sends raw float32 + const rawData = new Float32Array(frameBytes.buffer, frameBytes.byteOffset, frameBytes.byteLength / 4); + // Store raw data for profile sampling + if (!rawDpDataRef.current || rawDpDataRef.current.length !== rawData.length) { + rawDpDataRef.current = new Float32Array(rawData.length); + } + rawDpDataRef.current.set(rawData); + // Compute stats JS-side (replaces removed Python dp_stats trait) + const s = computeStats(rawData); + setDpStats([s.mean, s.min, s.max, s.std]); + // Apply scale transformation for histogram display + const scaledData = new Float32Array(rawData.length); + if (dpScaleMode === "log") { + for (let i = 0; i < rawData.length; i++) { + scaledData[i] = Math.log1p(Math.max(0, rawData[i])); + } + } else { + scaledData.set(rawData); + } + setDpHistogramData(scaledData); + }, [frameBytes, dpScaleMode]); + + // GPU FFT state + const gpuFFTRef = React.useRef(null); + const [gpuReady, setGpuReady] = React.useState(false); + + // Path animation timer + React.useEffect(() => { + if (!pathPlaying || pathLength === 0) return; + + const timer = setInterval(() => { + setPathIndex((prev: number) => { + const next = prev + 1; + if (next >= pathLength) { + if (pathLoop) { + return 0; // Loop back to start + } else { + setPathPlaying(false); // Stop at end + return prev; + } + } + return next; + }); + }, pathIntervalMs); + + return () => clearInterval(timer); + }, [pathPlaying, pathLength, pathIntervalMs, pathLoop, setPathIndex, setPathPlaying]); + + // Frame animation timer (5D time/tilt series) + const frameBounceDir = React.useRef(1); + React.useEffect(() => { + frameBounceDir.current = frameReverse ? -1 : 1; + }, [frameReverse]); + + React.useEffect(() => { + if (!framePlaying || nFrames <= 1) return; + + const intervalMs = 1000 / Math.max(0.1, frameFps); + const timer = setInterval(() => { + setFrameIdx((prev: number) => { + let next: number; + if (frameBoomerang) { + next = prev + frameBounceDir.current; + if (next >= nFrames) { frameBounceDir.current = -1; next = nFrames - 2; } + if (next < 0) { frameBounceDir.current = 1; next = 1; } + next = Math.max(0, Math.min(nFrames - 1, next)); + } else { + next = prev + (frameReverse ? -1 : 1); + if (next >= nFrames) { + if (frameLoop) return 0; + setFramePlaying(false); + return prev; + } + if (next < 0) { + if (frameLoop) return nFrames - 1; + setFramePlaying(false); + return prev; + } + } + return next; + }); + }, intervalMs); + + return () => clearInterval(timer); + }, [framePlaying, nFrames, frameFps, frameLoop, frameReverse, frameBoomerang, setFrameIdx, setFramePlaying]); + + // Initialize WebGPU FFT on mount + React.useEffect(() => { + getWebGPUFFT().then(fft => { + if (fft) { + gpuFFTRef.current = fft; + setGpuReady(true); + } + }); + }, []); + + // Root element ref (theme-aware styling handled via CSS variables) + const rootRef = React.useRef(null); + + // Zoom state + const [dpZoom, setDpZoom] = React.useState(1); + const [dpPanX, setDpPanX] = React.useState(0); + const [dpPanY, setDpPanY] = React.useState(0); + const [viZoom, setViZoom] = React.useState(1); + const [viPanX, setViPanX] = React.useState(0); + const [viPanY, setViPanY] = React.useState(0); + const [fftZoom, setFftZoom] = React.useState(1); + const [fftPanX, setFftPanX] = React.useState(0); + const [fftPanY, setFftPanY] = React.useState(0); + const [fftScaleMode, setFftScaleMode] = useModelState<"linear" | "log">("fft_scale_mode"); + const [fftColormap, setFftColormap] = useModelState("fft_colormap"); + const [fftAuto, setFftAuto] = useModelState("fft_auto"); + const [fftVminPct, setFftVminPct] = useModelState("fft_vmin_pct"); + const [fftVmaxPct, setFftVmaxPct] = useModelState("fft_vmax_pct"); + const [fftStats, setFftStats] = React.useState(null); // [mean, min, max, std] + const [fftHistogramData, setFftHistogramData] = React.useState(null); + const [fftDataMin, setFftDataMin] = React.useState(0); + const [fftDataMax, setFftDataMax] = React.useState(1); + const [fftClickInfo, setFftClickInfo] = React.useState<{ + row: number; col: number; distPx: number; + spatialFreq: number | null; dSpacing: number | null; + } | null>(null); + const fftClickStartRef = React.useRef<{ x: number; y: number } | null>(null); + + const isTypingTarget = React.useCallback((target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + return target.closest("input, textarea, select, [role='textbox'], [contenteditable='true']") !== null; + }, []); + + const handleRootMouseDownCapture = React.useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (target?.closest("canvas")) rootRef.current?.focus(); + }, []); + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (isTypingTarget(e.target)) return; + + const step = e.shiftKey ? 10 : 1; + let handled = false; + + switch (e.key) { + case "ArrowUp": + setPosRow(Math.max(0, posRow - step)); + handled = true; + break; + case "ArrowDown": + setPosRow(Math.min(shapeRows - 1, posRow + step)); + handled = true; + break; + case "ArrowLeft": + setPosCol(Math.max(0, posCol - step)); + handled = true; + break; + case "ArrowRight": + setPosCol(Math.min(shapeCols - 1, posCol + step)); + handled = true; + break; + case " ": // Space bar + if (pathLength > 0) { + setPathPlaying(!pathPlaying); + handled = true; + } + break; + case "r": + case "R": + setDpZoom(1); setDpPanX(0); setDpPanY(0); + setViZoom(1); setViPanX(0); setViPanY(0); + setFftZoom(1); setFftPanX(0); setFftPanY(0); + handled = true; + break; + case "[": + if (nFrames > 1) { + setFrameIdx(Math.max(0, frameIdx - 1)); + handled = true; + } + break; + case "]": + if (nFrames > 1) { + setFrameIdx(Math.min(nFrames - 1, frameIdx + 1)); + handled = true; + } + break; + case "Escape": + rootRef.current?.blur(); + handled = true; + break; + } + + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }, [ + frameIdx, isTypingTarget, nFrames, pathLength, + pathPlaying, posCol, posRow, setFrameIdx, setPathPlaying, setPosCol, setPosRow, shapeCols, shapeRows, + ]); + + // Sync local state + React.useEffect(() => { + if (!isDraggingDP && !isDraggingResize) { setLocalKCol(roiCenterCol); setLocalKRow(roiCenterRow); } + }, [roiCenterCol, roiCenterRow, isDraggingDP, isDraggingResize]); + + React.useEffect(() => { + if (!isDraggingVI) { setLocalPosRow(posRow); setLocalPosCol(posCol); } + }, [posRow, posCol, isDraggingVI]); + + // Sync VI ROI local state + React.useEffect(() => { + if (!isDraggingViRoi && !isDraggingViRoiResize) { + setLocalViRoiCenterRow(viRoiCenterRow || shapeRows / 2); + setLocalViRoiCenterCol(viRoiCenterCol || shapeCols / 2); + } + }, [viRoiCenterRow, viRoiCenterCol, isDraggingViRoi, isDraggingViRoiResize, shapeRows, shapeCols]); + + // Canvas refs + const dpCanvasRef = React.useRef(null); + const dpOverlayRef = React.useRef(null); + const dpUiRef = React.useRef(null); // High-DPI UI overlay for scale bar + const dpOffscreenRef = React.useRef(null); + const dpImageDataRef = React.useRef(null); + const virtualCanvasRef = React.useRef(null); + const virtualOverlayRef = React.useRef(null); + const viUiRef = React.useRef(null); // High-DPI UI overlay for scale bar + const viOffscreenRef = React.useRef(null); + const viImageDataRef = React.useRef(null); + const fftCanvasRef = React.useRef(null); + const fftOverlayRef = React.useRef(null); + const fftOffscreenRef = React.useRef(null); + const fftImageDataRef = React.useRef(null); + + // Offscreen version counters — bump when colormap/data changes, cheap draw effects depend on these + const [dpOffscreenVersion, setDpOffscreenVersion] = React.useState(0); + const [viOffscreenVersion, setViOffscreenVersion] = React.useState(0); + const [fftOffscreenVersion, setFftOffscreenVersion] = React.useState(0); + + // Cached colorbar vmin/vmax — computed in expensive DP effect, reused in UI overlay without recomputing + const dpColorbarVminRef = React.useRef(0); + const dpColorbarVmaxRef = React.useRef(1); + + // Device pixel ratio for high-DPI UI overlays + const DPR = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + + // ───────────────────────────────────────────────────────────────────────── + // Effects: Canvas Rendering & Animation + // ───────────────────────────────────────────────────────────────────────── + + // Prevent page scroll when scrolling on canvases + // Re-run when showFft changes since FFT canvas is conditionally rendered + React.useEffect(() => { + const preventDefault = (e: WheelEvent) => e.preventDefault(); + const overlays = [dpOverlayRef.current, virtualOverlayRef.current, fftOverlayRef.current]; + overlays.forEach(el => el?.addEventListener("wheel", preventDefault, { passive: false })); + return () => overlays.forEach(el => el?.removeEventListener("wheel", preventDefault)); + }, [effectiveShowFft]); + + // Store raw data for filtering/FFT + const rawVirtualImageRef = React.useRef(null); + const fftWorkRealRef = React.useRef(null); + const fftWorkImagRef = React.useRef(null); + const fftMagnitudeRef = React.useRef(null); + const fftMagCacheRef = React.useRef(null); + + // Parse virtual image bytes into Float32Array and apply scale for histogram + React.useEffect(() => { + if (!virtualImageBytes) return; + // Parse as Float32Array + const numFloats = virtualImageBytes.byteLength / 4; + const rawData = new Float32Array(virtualImageBytes.buffer, virtualImageBytes.byteOffset, numFloats); + + // Store a copy for filtering/FFT (rawData is a view, we need a copy) + let storedData = rawVirtualImageRef.current; + if (!storedData || storedData.length !== numFloats) { + storedData = new Float32Array(numFloats); + rawVirtualImageRef.current = storedData; + } + storedData.set(rawData); + + // Also store for VI profile sampling + if (!rawViDataRef.current || rawViDataRef.current.length !== numFloats) { + rawViDataRef.current = new Float32Array(numFloats); + } + rawViDataRef.current.set(rawData); + + // Compute stats + min/max JS-side (replaces removed Python vi_stats / vi_data_min / vi_data_max traits). + // Python sending bytes + 4 separate stat traits caused a comm-message ordering race on rapid + // preset clicks: bytes from click N could arrive with min/max from click N-1, normalizing + // the colormap to the wrong range and producing a uniform-color VI flash. + const s = computeStats(rawData); + setViStats([s.mean, s.min, s.max, s.std]); + setViDataMin(s.min); + setViDataMax(s.max); + + // Apply scale transformation for histogram display + const scaledData = new Float32Array(numFloats); + if (viScaleMode === "log") { + for (let i = 0; i < numFloats; i++) { + scaledData[i] = Math.log1p(Math.max(0, rawData[i])); + } + } else { + scaledData.set(rawData); + } + setViHistogramData(scaledData); + }, [virtualImageBytes, viScaleMode]); + + // Render DP with zoom (use summed DP when VI ROI is active) + // Expensive: colormap + data processing → cached offscreen canvas + React.useEffect(() => { + // Determine which bytes to display: summed DP (if VI ROI active) or single frame + const usesViRoiDp = viRoiMode && viRoiMode !== "off" && viRoiDpBytes && viRoiDpBytes.byteLength > 0; + const sourceBytes = usesViRoiDp ? viRoiDpBytes : frameBytes; + if (!sourceBytes) return; + + const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno; + + // Parse raw float32 data and apply scale transformation + const rawData = new Float32Array(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength / 4); + let scaled: Float32Array; + if (dpScaleMode === "log") { + scaled = new Float32Array(rawData.length); + for (let i = 0; i < rawData.length; i++) { + scaled[i] = Math.log1p(Math.max(0, rawData[i])); + } + } else { + scaled = rawData; + } + + const { min: dataMin, max: dataMax } = findDataRange(scaled); + + let vmin: number, vmax: number; + if (traitDpVmin != null && traitDpVmax != null) { + if (dpScaleMode === "log") { + vmin = Math.log1p(Math.max(traitDpVmin, 0)); + vmax = Math.log1p(Math.max(traitDpVmax, 0)); + } else { + vmin = traitDpVmin; + vmax = traitDpVmax; + } + } else { + ({ vmin, vmax } = sliderRange(dataMin, dataMax, dpVminPct, dpVmaxPct)); + } + + let offscreen = dpOffscreenRef.current; + if (!offscreen) { + offscreen = document.createElement("canvas"); + dpOffscreenRef.current = offscreen; + } + const sizeChanged = offscreen.width !== detCols || offscreen.height !== detRows; + if (sizeChanged) { + offscreen.width = detCols; + offscreen.height = detRows; + dpImageDataRef.current = null; + } + const offCtx = offscreen.getContext("2d"); + if (!offCtx) return; + + let imgData = dpImageDataRef.current; + if (!imgData) { + imgData = offCtx.createImageData(detCols, detRows); + dpImageDataRef.current = imgData; + } + applyColormap(scaled, imgData.data, lut, vmin, vmax); + offCtx.putImageData(imgData, 0, 0); + // Cache colorbar range for the UI overlay (avoids recomputing findDataRange on every zoom/pan) + dpColorbarVminRef.current = vmin; + dpColorbarVmaxRef.current = vmax; + setDpOffscreenVersion(v => v + 1); + }, [frameBytes, viRoiDpBytes, viRoiMode, detRows, detCols, dpColormap, dpVminPct, dpVmaxPct, dpScaleMode, traitDpVmin, traitDpVmax]); + + // Cheap: zoom/pan redraw — just drawImage from cached offscreen + // useLayoutEffect prevents black flash when canvas dimensions change (resize) + React.useLayoutEffect(() => { + const offscreen = dpOffscreenRef.current; + if (!offscreen || !dpCanvasRef.current) return; + const canvas = dpCanvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.translate(dpPanX, dpPanY); + ctx.scale(dpZoom, dpZoom); + ctx.drawImage(offscreen, 0, 0); + ctx.restore(); + }, [dpOffscreenVersion, dpZoom, dpPanX, dpPanY]); + + // Render DP overlay - just clear (ROI shapes now drawn on high-DPI UI canvas) + React.useEffect(() => { + if (!dpOverlayRef.current) return; + const canvas = dpOverlayRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + // All visual overlays (crosshair, ROI shapes, scale bar) are now on dpUiRef for crisp rendering + }, [localKCol, localKRow, isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, dpZoom, dpPanX, dpPanY, roiMode, roiRadius, roiRadiusInner, roiWidth, roiHeight, detRows, detCols]); + + // Expensive: VI colormap + data processing → cached offscreen canvas + React.useEffect(() => { + if (!rawVirtualImageRef.current) return; + + const width = shapeCols; + const height = shapeRows; + const filtered = rawVirtualImageRef.current; + + let scaled = filtered; + if (viScaleMode === "log") { + scaled = new Float32Array(filtered.length); + for (let i = 0; i < filtered.length; i++) { + scaled[i] = Math.log1p(Math.max(0, filtered[i])); + } + } + + // Compute min/max from the data we just received. Do NOT use Python's + // viDataMin/viDataMax traits here: they arrive as separate comm messages + // and can be stale on rapid preset clicks (BF↔ABF), causing the render + // to apply the WRONG normalization range and produce a uniform white/black + // VI panel until comm catches up. findDataRange on a scan-shape buffer + // (~64K-256K floats) is sub-millisecond. + const r = findDataRange(scaled); + const dataMin = r.min; + const dataMax = r.max; + + // Apply absolute bounds or percentile clipping + let vmin: number, vmax: number; + if (traitViVmin != null && traitViVmax != null) { + if (viScaleMode === "log") { + vmin = Math.log1p(Math.max(traitViVmin, 0)); + vmax = Math.log1p(Math.max(traitViVmax, 0)); + } else { + vmin = traitViVmin; + vmax = traitViVmax; + } + } else if (viAutoContrast) { + ({ vmin, vmax } = percentileClip(scaled, 1, 99)); + } else { + ({ vmin, vmax } = sliderRange(dataMin, dataMax, viVminPct, viVmaxPct)); + } + + const lut = COLORMAPS[viColormap] || COLORMAPS.inferno; + let offscreen = viOffscreenRef.current; + if (!offscreen) { + offscreen = document.createElement("canvas"); + viOffscreenRef.current = offscreen; + } + const sizeChanged = offscreen.width !== width || offscreen.height !== height; + if (sizeChanged) { + offscreen.width = width; + offscreen.height = height; + viImageDataRef.current = null; + } + const offCtx = offscreen.getContext("2d"); + if (!offCtx) return; + + let imageData = viImageDataRef.current; + if (!imageData) { + imageData = offCtx.createImageData(width, height); + viImageDataRef.current = imageData; + } + applyColormap(scaled, imageData.data, lut, vmin, vmax); + offCtx.putImageData(imageData, 0, 0); + setViOffscreenVersion(v => v + 1); + }, [virtualImageBytes, shapeRows, shapeCols, viColormap, viVminPct, viVmaxPct, viScaleMode, traitViVmin, traitViVmax, viAutoContrast]); + + // Cheap: VI zoom/pan redraw — just drawImage from cached offscreen + React.useLayoutEffect(() => { + const offscreen = viOffscreenRef.current; + if (!offscreen || !virtualCanvasRef.current) return; + const canvas = virtualCanvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = viSmooth; + if (viSmooth) ctx.imageSmoothingQuality = "high"; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.translate(viPanX, viPanY); + ctx.scale(viZoom, viZoom); + ctx.drawImage(offscreen, 0, 0); + ctx.restore(); + }, [viOffscreenVersion, viZoom, viPanX, viPanY, viSmooth]); + + // Render virtual image overlay (just clear - crosshair drawn on high-DPI UI canvas) + React.useEffect(() => { + if (!virtualOverlayRef.current) return; + const canvas = virtualOverlayRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Crosshair and scale bar now drawn on high-DPI UI canvas (viUiRef) + }, [localPosRow, localPosCol, isDraggingVI, viZoom, viPanX, viPanY, pixelSize, shapeRows, shapeCols]); + + // Compute FFT (expensive, async — only re-run on data/GPU changes) + const fftRealRef = React.useRef(null); + const fftImagRef = React.useRef(null); + const [fftVersion, setFftVersion] = React.useState(0); + + React.useEffect(() => { + if (!rawVirtualImageRef.current || !effectiveShowFft) { setFftCropDims(null); return; } + let cancelled = false; + let width = shapeCols; + let height = shapeRows; + let sourceData = rawVirtualImageRef.current; + let origCropW = 0, origCropH = 0; + + // ROI FFT: crop virtual image to VI ROI region and pre-pad to power-of-2. + // Use localViRoiCenter* (updated immediately on drag) instead of the synced + // model traits, which lag by one comm roundtrip after a compound trait write. + // Without this, FFT visibly stalls during rapid VI ROI drag. + if (roiFftActive) { + const cRow = localViRoiCenterRow ?? viRoiCenterRow; + const cCol = localViRoiCenterCol ?? viRoiCenterCol; + const crop = cropSingleROI(sourceData, shapeCols, shapeRows, viRoiMode, cRow, cCol, viRoiRadius, viRoiWidth, viRoiHeight); + if (crop) { + origCropW = crop.cropW; + origCropH = crop.cropH; + // Apply Hann window to crop at native dimensions BEFORE zero-padding + if (fftWindow) applyHannWindow2D(crop.cropped, crop.cropW, crop.cropH); + const padW = nextPow2(crop.cropW); + const padH = nextPow2(crop.cropH); + const padded = new Float32Array(padW * padH); + for (let y = 0; y < crop.cropH; y++) { + for (let x = 0; x < crop.cropW; x++) { + padded[y * padW + x] = crop.cropped[y * crop.cropW + x]; + } + } + sourceData = padded; + width = padW; + height = padH; + } + } + + // Pre-pad non-power-of-2 full images so fft2d doesn't truncate frequency data + if (!roiFftActive) { + const padW = nextPow2(width); + const padH = nextPow2(height); + if (padW !== width || padH !== height) { + const padded = new Float32Array(padW * padH); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + padded[y * padW + x] = sourceData[y * width + x]; + } + } + sourceData = padded; + width = padW; + height = padH; + } + } + + const fftW = width, fftH = height; + if (gpuFFTRef.current && gpuReady) { + const runGpuFFT = async () => { + const real = sourceData.slice(); + const imag = new Float32Array(real.length); + const { real: fReal, imag: fImag } = await gpuFFTRef.current!.fft2D(real, imag, fftW, fftH, false); + if (cancelled) return; + fftshift(fReal, fftW, fftH); + fftshift(fImag, fftW, fftH); + fftRealRef.current = fReal; + fftImagRef.current = fImag; + if (origCropW > 0) { + setFftCropDims({ cropWidth: origCropW, cropHeight: origCropH, fftWidth: fftW, fftHeight: fftH }); + } else if (fftW !== shapeCols || fftH !== shapeRows) { + setFftCropDims({ cropWidth: shapeCols, cropHeight: shapeRows, fftWidth: fftW, fftHeight: fftH }); + } else { + setFftCropDims(null); + } + setFftVersion(v => v + 1); + }; + runGpuFFT(); + return () => { cancelled = true; }; + } else { + const len = sourceData.length; + let real = fftWorkRealRef.current; + if (!real || real.length !== len) { real = new Float32Array(len); fftWorkRealRef.current = real; } + real.set(sourceData); + let imag = fftWorkImagRef.current; + if (!imag || imag.length !== len) { imag = new Float32Array(len); fftWorkImagRef.current = imag; } else { imag.fill(0); } + fft2d(real, imag, fftW, fftH, false); + fftshift(real, fftW, fftH); + fftshift(imag, fftW, fftH); + fftRealRef.current = real; + fftImagRef.current = imag; + if (origCropW > 0) { + setFftCropDims({ cropWidth: origCropW, cropHeight: origCropH, fftWidth: fftW, fftHeight: fftH }); + } else if (fftW !== shapeCols || fftH !== shapeRows) { + setFftCropDims({ cropWidth: shapeCols, cropHeight: shapeRows, fftWidth: fftW, fftHeight: fftH }); + } else { + setFftCropDims(null); + } + setFftVersion(v => v + 1); + } + }, [virtualImageBytes, shapeRows, shapeCols, gpuReady, effectiveShowFft, roiFftActive, viRoiMode, viRoiCenterRow, viRoiCenterCol, localViRoiCenterRow, localViRoiCenterCol, viRoiRadius, viRoiWidth, viRoiHeight, fftWindow]); + + // Expensive: FFT magnitude + histogram + colormap → cached offscreen canvas + React.useEffect(() => { + if (!fftRealRef.current || !fftImagRef.current) return; + if (!effectiveShowFft) return; + + const width = fftCropDims?.fftWidth ?? shapeCols; + const height = fftCropDims?.fftHeight ?? shapeRows; + const real = fftRealRef.current; + const imag = fftImagRef.current; + const lut = COLORMAPS[fftColormap] || COLORMAPS.inferno; + + // Compute magnitude with scale mode + let magnitude = fftMagnitudeRef.current; + if (!magnitude || magnitude.length !== real.length) { + magnitude = new Float32Array(real.length); + fftMagnitudeRef.current = magnitude; + } + // Cache raw magnitude for peak-snap before applying scale transform + let rawMag = fftMagCacheRef.current; + if (!rawMag || rawMag.length !== real.length) { + rawMag = new Float32Array(real.length); + fftMagCacheRef.current = rawMag; + } + for (let i = 0; i < real.length; i++) { + const mag = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]); + rawMag[i] = mag; + if (fftScaleMode === "log") { magnitude[i] = Math.log1p(mag); } + else { magnitude[i] = mag; } + } + + let displayMin: number, displayMax: number; + if (fftAuto) { + ({ min: displayMin, max: displayMax } = autoEnhanceFFT(magnitude, width, height)); + } else { + ({ min: displayMin, max: displayMax } = findDataRange(magnitude)); + } + setFftDataMin(displayMin); + setFftDataMax(displayMax); + const magStats = computeStats(magnitude); + setFftStats([magStats.mean, displayMin, displayMax, magStats.std]); + setFftHistogramData(magnitude.slice()); + + // Render to offscreen canvas + let offscreen = fftOffscreenRef.current; + if (!offscreen) { offscreen = document.createElement("canvas"); fftOffscreenRef.current = offscreen; } + if (offscreen.width !== width || offscreen.height !== height) { + offscreen.width = width; offscreen.height = height; fftImageDataRef.current = null; + } + const offCtx = offscreen.getContext("2d"); + if (!offCtx) return; + let imgData = fftImageDataRef.current; + if (!imgData) { imgData = offCtx.createImageData(width, height); fftImageDataRef.current = imgData; } + + const { vmin, vmax } = sliderRange(displayMin, displayMax, fftVminPct, fftVmaxPct); + applyColormap(magnitude, imgData.data, lut, vmin, vmax); + offCtx.putImageData(imgData, 0, 0); + setFftOffscreenVersion(v => v + 1); + }, [effectiveShowFft, fftVersion, fftScaleMode, fftAuto, fftVminPct, fftVmaxPct, fftColormap, shapeRows, shapeCols, fftCropDims]); + + // Cheap: FFT zoom/pan redraw — just drawImage from cached offscreen + React.useLayoutEffect(() => { + if (!fftCanvasRef.current) return; + const canvas = fftCanvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const offscreen = fftOffscreenRef.current; + if (!offscreen || !effectiveShowFft) { ctx.clearRect(0, 0, canvas.width, canvas.height); return; } + const fftW = offscreen.width; + const fftH = offscreen.height; + const canvasW = canvas.width; + const canvasH = canvas.height; + // Use bilinear smoothing when FFT dims differ from canvas (non-pow2 padding or ROI crop). + // Stretch offscreen to fill canvas via the 9-arg drawImage form: ROI FFT crops produce a + // small offscreen (e.g. 64×64) that would otherwise blit at native size in the corner. + ctx.imageSmoothingEnabled = fftW !== canvasW || fftH !== canvasH; + ctx.clearRect(0, 0, canvasW, canvasH); + ctx.save(); + ctx.translate(fftPanX, fftPanY); + ctx.scale(fftZoom, fftZoom); + ctx.drawImage(offscreen, 0, 0, fftW, fftH, 0, 0, canvasW, canvasH); + ctx.restore(); + }, [fftOffscreenVersion, fftZoom, fftPanX, fftPanY, effectiveShowFft]); + + // Render FFT overlay with d-spacing crosshair marker + React.useEffect(() => { + if (!fftOverlayRef.current) return; + const canvas = fftOverlayRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // D-spacing crosshair marker + if (fftClickInfo && effectiveShowFft) { + const fftW = fftCropDims?.fftWidth ?? shapeCols; + const fftH = fftCropDims?.fftHeight ?? shapeRows; + ctx.save(); + // Forward mapping: image col/row → canvas x/y (matches stretched drawImage). + const screenX = fftPanX + fftZoom * (fftClickInfo.col * canvas.width / fftW); + const screenY = fftPanY + fftZoom * (fftClickInfo.row * canvas.height / fftH); + ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; + ctx.shadowColor = "rgba(0, 0, 0, 0.6)"; + ctx.shadowBlur = 2; + ctx.lineWidth = 1.5; + // Scale crosshair size relative to canvas (not zoom-dependent) + const r = 8 * Math.max(fftW, fftH) / 450; + const gap = 3 * Math.max(fftW, fftH) / 450; + const dotR = 4 * Math.max(fftW, fftH) / 450; + ctx.beginPath(); + ctx.moveTo(screenX - r, screenY); ctx.lineTo(screenX - gap, screenY); + ctx.moveTo(screenX + gap, screenY); ctx.lineTo(screenX + r, screenY); + ctx.moveTo(screenX, screenY - r); ctx.lineTo(screenX, screenY - gap); + ctx.moveTo(screenX, screenY + gap); ctx.lineTo(screenX, screenY + r); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(screenX, screenY, dotR, 0, Math.PI * 2); + ctx.stroke(); + if (fftClickInfo.dSpacing != null) { + const d = fftClickInfo.dSpacing; + const label = d >= 10 ? `d = ${(d / 10).toFixed(2)} nm` : `d = ${d.toFixed(2)} \u00C5`; + const fontSize = Math.max(10, Math.round(11 * Math.max(fftW, fftH) / 450)); + ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`; + ctx.fillStyle = "white"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(label, screenX + r + 4, screenY - gap); + } + ctx.restore(); + } + }, [fftZoom, fftPanX, fftPanY, effectiveShowFft, fftClickInfo, shapeCols, shapeRows, fftCropDims]); + + // Clear FFT click info when virtual image changes (scan position, VI ROI, etc.) + React.useEffect(() => { + setFftClickInfo(null); + }, [virtualImageBytes]); + + // ───────────────────────────────────────────────────────────────────────── + // High-DPI Scale Bar UI Overlays + // ───────────────────────────────────────────────────────────────────────── + + // DP scale bar + crosshair + ROI overlay + profile line (high-DPI) + React.useEffect(() => { + if (!dpUiRef.current) return; + // Draw scale bar first (clears canvas) + const kUnit = kCalibrated ? kPixelUnit : "px"; + drawScaleBarHiDPI(dpUiRef.current, DPR, dpZoom, kPixelSize || 1, kUnit, detCols); + // Draw ROI overlay (circle, square, rect, annular) or point crosshair + if (roiMode === "point") { + drawDpCrosshairHiDPI(dpUiRef.current, DPR, localKCol, localKRow, dpZoom, dpPanX, dpPanY, detCols, detRows, isDraggingDP, roiColors); + } else { + drawRoiOverlayHiDPI( + dpUiRef.current, DPR, roiMode, + localKCol, localKRow, roiRadius, roiRadiusInner, roiWidth, roiHeight, + dpZoom, dpPanX, dpPanY, detCols, detRows, + isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, + roiColors + ); + } + + // Profile line overlay + if (profileActive && profilePoints.length > 0) { + const canvas = dpUiRef.current; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.save(); + ctx.scale(DPR, DPR); + const cssW = canvas.width / DPR; + const cssH = canvas.height / DPR; + const scaleX = cssW / detCols; + const scaleY = cssH / detRows; + const toScreenX = (col: number) => col * dpZoom * scaleX + dpPanX * scaleX; + const toScreenY = (row: number) => row * dpZoom * scaleY + dpPanY * scaleY; + + // Draw point A + const ax = toScreenX(profilePoints[0].col); + const ay = toScreenY(profilePoints[0].row); + ctx.fillStyle = themeColors.accent; + ctx.beginPath(); + ctx.arc(ax, ay, 4, 0, Math.PI * 2); + ctx.fill(); + + if (profilePoints.length === 2) { + const bx = toScreenX(profilePoints[1].col); + const by = toScreenY(profilePoints[1].row); + + // Draw band when profile width > 1 + if (profileWidth > 1) { + const dc = profilePoints[1].col - profilePoints[0].col; + const dr = profilePoints[1].row - profilePoints[0].row; + const lineLen = Math.sqrt(dc * dc + dr * dr); + if (lineLen > 0) { + const halfW = (profileWidth - 1) / 2; + const perpR = -dc / lineLen * halfW; + const perpC = dr / lineLen * halfW; + ctx.fillStyle = themeColors.accent + "20"; + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(toScreenX(profilePoints[0].col + perpC), toScreenY(profilePoints[0].row + perpR)); + ctx.lineTo(toScreenX(profilePoints[1].col + perpC), toScreenY(profilePoints[1].row + perpR)); + ctx.lineTo(toScreenX(profilePoints[1].col - perpC), toScreenY(profilePoints[1].row - perpR)); + ctx.lineTo(toScreenX(profilePoints[0].col - perpC), toScreenY(profilePoints[0].row - perpR)); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([]); + } + } + + // Draw line A->B + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + + // Draw point B + ctx.fillStyle = themeColors.accent; + ctx.beginPath(); + ctx.arc(bx, by, 4, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + } + + // Colorbar overlay — uses cached vmin/vmax from the expensive DP offscreen effect + if (showDpColorbar) { + const canvas = dpUiRef.current; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.save(); + ctx.scale(DPR, DPR); + const cssW = canvas.width / DPR; + const cssH = canvas.height / DPR; + const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno; + drawColorbar(ctx, cssW, cssH, lut, dpColorbarVminRef.current, dpColorbarVmaxRef.current, dpScaleMode === "log"); + ctx.restore(); + } + } + }, [dpZoom, dpPanX, dpPanY, kPixelSize, kCalibrated, detRows, detCols, roiMode, roiRadius, roiRadiusInner, roiWidth, roiHeight, localKCol, localKRow, isDraggingDP, isDraggingResize, isDraggingResizeInner, isHoveringResize, isHoveringResizeInner, + profileActive, profilePoints, profileWidth, themeColors, showDpColorbar, dpColormap, dpScaleMode, dpVminPct, dpVmaxPct, canvasSize, roiColors]); + + // VI scale bar + crosshair + ROI + profile lines (high-DPI) + React.useEffect(() => { + if (!viUiRef.current) return; + // Draw scale bar first (clears canvas) + drawScaleBarHiDPI(viUiRef.current, DPR, viZoom, pixelSize || 1, pixelUnit || "px", shapeCols); + // Draw crosshair only when ROI is off (ROI replaces the crosshair) + if (!viRoiMode || viRoiMode === "off") { + drawViPositionMarker(viUiRef.current, DPR, localPosRow, localPosCol, viZoom, viPanX, viPanY, shapeCols, shapeRows, isDraggingVI); + } else { + // Draw VI ROI instead of crosshair + drawViRoiOverlayHiDPI( + viUiRef.current, DPR, viRoiMode, + localViRoiCenterRow, localViRoiCenterCol, viRoiRadius || 5, viRoiWidth || 10, viRoiHeight || 10, + viZoom, viPanX, viPanY, shapeCols, shapeRows, + isDraggingViRoi, isDraggingViRoiResize, isHoveringViRoiResize + ); + } + // Draw VI profile lines + if (viProfileActive && viProfilePoints.length > 0) { + const canvas = viUiRef.current; + const ctx = canvas.getContext("2d"); + if (ctx) { + const cssW = canvas.width / DPR; + const cssH = canvas.height / DPR; + const scaleX = cssW / shapeCols; + const scaleY = cssH / shapeRows; + ctx.save(); + ctx.scale(DPR, DPR); + ctx.strokeStyle = "#a0f"; + ctx.lineWidth = 2; + ctx.shadowColor = "rgba(0,0,0,0.5)"; + ctx.shadowBlur = 2; + if (viProfilePoints.length >= 1) { + const p0 = viProfilePoints[0]; + const x0 = p0.col * viZoom * scaleX + viPanX * scaleX; + const y0 = p0.row * viZoom * scaleY + viPanY * scaleY; + ctx.beginPath(); + ctx.arc(x0, y0, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.fillText("1", x0 + 6, y0 - 6); + } + if (viProfilePoints.length === 2) { + const p0 = viProfilePoints[0], p1 = viProfilePoints[1]; + const x0 = p0.col * viZoom * scaleX + viPanX * scaleX; + const y0 = p0.row * viZoom * scaleY + viPanY * scaleY; + const x1 = p1.col * viZoom * scaleX + viPanX * scaleX; + const y1 = p1.row * viZoom * scaleY + viPanY * scaleY; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(x1, y1, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.fillText("2", x1 + 6, y1 - 6); + } + ctx.restore(); + } + } + }, [viZoom, viPanX, viPanY, pixelSize, shapeRows, shapeCols, localPosRow, localPosCol, isDraggingVI, + viRoiMode, localViRoiCenterRow, localViRoiCenterCol, viRoiRadius, viRoiWidth, viRoiHeight, + isDraggingViRoi, isDraggingViRoiResize, isHoveringViRoiResize, canvasSize, viProfileActive, viProfilePoints]); + + // ── DP Profile computation ── + React.useEffect(() => { + if (profilePoints.length === 2 && rawDpDataRef.current) { + const p0 = profilePoints[0], p1 = profilePoints[1]; + setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, p0.row, p0.col, p1.row, p1.col, profileWidth)); + if (!profileActive) setProfileActive(true); + } else { + setProfileData(null); + } + }, [profilePoints, profileWidth, frameBytes]); + + // ── VI Profile computation ── + React.useEffect(() => { + if (viProfilePoints.length === 2 && rawViDataRef.current && shapeCols > 0 && shapeRows > 0) { + const p0 = viProfilePoints[0], p1 = viProfilePoints[1]; + setViProfileData(sampleLineProfile(rawViDataRef.current, shapeCols, shapeRows, p0.row, p0.col, p1.row, p1.col, 1)); + } else { + setViProfileData(null); + } + }, [viProfilePoints, virtualImageBytes, shapeCols, shapeRows]); + + // ── Profile sparkline rendering ── + React.useEffect(() => { + const canvas = profileCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const cssW = canvasSize; + const cssH = profileHeight; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + ctx.scale(dpr, dpr); + + const isDark = themeInfo.theme === "dark"; + ctx.fillStyle = isDark ? "#1a1a1a" : "#f0f0f0"; + ctx.fillRect(0, 0, cssW, cssH); + + if (!profileData || profileData.length < 2) { + ctx.font = "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#555" : "#999"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Click two points on the DP to draw a profile", cssW / 2, cssH / 2); + profileBaseImageRef.current = null; + profileLayoutRef.current = null; + return; + } + + const padLeft = 40; + const padRight = 8; + const padTop = 6; + const padBottom = 18; + const plotW = cssW - padLeft - padRight; + const plotH = cssH - padTop - padBottom; + + let gMin = Infinity, gMax = -Infinity; + for (let i = 0; i < profileData.length; i++) { + if (profileData[i] < gMin) gMin = profileData[i]; + if (profileData[i] > gMax) gMax = profileData[i]; + } + const range = gMax - gMin || 1; + + // X-axis: calibrated distance + let totalDist = profileData.length - 1; + let xUnit = "px"; + if (profilePoints.length === 2) { + const dx = profilePoints[1].col - profilePoints[0].col; + const dy = profilePoints[1].row - profilePoints[0].row; + const distPx = Math.sqrt(dx * dx + dy * dy); + if (kCalibrated && kPixelSize > 0) { + totalDist = distPx * kPixelSize; + xUnit = kPixelUnit; + } else { + totalDist = distPx; + } + } + + // Draw axes + ctx.strokeStyle = isDark ? "#555" : "#bbb"; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(padLeft, padTop); + ctx.lineTo(padLeft, padTop + plotH); + ctx.lineTo(padLeft + plotW, padTop + plotH); + ctx.stroke(); + + // Draw profile curve + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < profileData.length; i++) { + const x = padLeft + (i / (profileData.length - 1)) * plotW; + const y = padTop + plotH - ((profileData[i] - gMin) / range) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Draw x-axis ticks + const tickY = padTop + plotH; + ctx.strokeStyle = isDark ? "#555" : "#bbb"; + ctx.lineWidth = 0.5; + const idealTicks = Math.max(2, Math.floor(plotW / 70)); + const tickStep = roundToNiceValue(totalDist / idealTicks); + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#888" : "#666"; + ctx.textBaseline = "top"; + const ticks: number[] = []; + for (let v = 0; v <= totalDist + tickStep * 0.01; v += tickStep) { + if (v > totalDist * 1.001) break; + ticks.push(v); + } + for (let i = 0; i < ticks.length; i++) { + const v = ticks[i]; + const frac = totalDist > 0 ? v / totalDist : 0; + const x = padLeft + frac * plotW; + ctx.beginPath(); ctx.moveTo(x, tickY); ctx.lineTo(x, tickY + 3); ctx.stroke(); + ctx.textAlign = frac < 0.05 ? "left" : frac > 0.95 ? "right" : "center"; + const label = v % 1 === 0 ? v.toFixed(0) : v.toFixed(1); + ctx.fillText(i === ticks.length - 1 ? `${label} ${xUnit}` : label, x, tickY + 4); + } + + // Y-axis min/max labels (left margin) + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#888" : "#666"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(gMax), 2, padTop); + ctx.textBaseline = "bottom"; + ctx.fillText(formatNumber(gMin), 2, padTop + plotH); + + // Save base image and layout for hover + profileBaseImageRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + profileLayoutRef.current = { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit }; + }, [profileData, profilePoints, kPixelSize, kCalibrated, themeInfo.theme, themeColors.accent, canvasSize, profileHeight]); + + // DP Profile hover handlers + const handleProfileMouseMove = React.useCallback((e: React.MouseEvent) => { + const canvas = profileCanvasRef.current; + const base = profileBaseImageRef.current; + const layout = profileLayoutRef.current; + if (!canvas || !base || !layout || !profileData) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit } = layout; + const range = gMax - gMin || 1; + + // Restore base image + ctx.putImageData(base, 0, 0); + + if (cssX < padLeft || cssX > padLeft + plotW) return; + const frac = (cssX - padLeft) / plotW; + + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Vertical crosshair + const isDark = themeInfo.theme === "dark"; + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(cssX, padTop); + ctx.lineTo(cssX, padTop + plotH); + ctx.stroke(); + ctx.setLineDash([]); + + // Dot on curve + value + const dataIdx = Math.min(profileData.length - 1, Math.max(0, Math.round(frac * (profileData.length - 1)))); + const val = profileData[dataIdx]; + const y = padTop + plotH - ((val - gMin) / range) * plotH; + ctx.fillStyle = themeColors.accent; + ctx.beginPath(); + ctx.arc(cssX, y, 3, 0, Math.PI * 2); + ctx.fill(); + + // Value readout label + const dist = frac * totalDist; + const label = `${formatNumber(val)} @ ${dist.toFixed(1)} ${xUnit}`; + ctx.font = "bold 9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + const textW = ctx.measureText(label).width; + const labelX = Math.min(cssX + 6, padLeft + plotW - textW - 2); + const labelY = padTop + 2; + ctx.fillStyle = isDark ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)"; + ctx.fillRect(labelX - 2, labelY - 1, textW + 4, 11); + ctx.fillStyle = isDark ? "#fff" : "#000"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(label, labelX, labelY); + + ctx.restore(); + }, [profileData, themeInfo.theme, themeColors.accent]); + + const handleProfileMouseLeave = React.useCallback(() => { + const canvas = profileCanvasRef.current; + const base = profileBaseImageRef.current; + if (!canvas || !base) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.putImageData(base, 0, 0); + }, []); + + // DP Profile resize handlers + React.useEffect(() => { + if (!isResizingProfile) return; + const handleMouseMove = (e: MouseEvent) => { + if (!profileResizeStart.current) return; + const deltaY = e.clientY - profileResizeStart.current.startY; + const newHeight = Math.max(40, Math.min(300, profileResizeStart.current.startHeight + deltaY)); + setProfileHeight(newHeight); + }; + const handleMouseUp = () => { + setIsResizingProfile(false); + profileResizeStart.current = null; + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizingProfile]); + + // ── VI Profile sparkline rendering ── + React.useEffect(() => { + const canvas = viProfileCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const cssW = viCanvasWidth; + const cssH = viProfileHeight; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + ctx.scale(dpr, dpr); + + const isDark = themeInfo.theme === "dark"; + ctx.fillStyle = isDark ? "#1a1a1a" : "#f0f0f0"; + ctx.fillRect(0, 0, cssW, cssH); + + if (!viProfileData || viProfileData.length < 2) { + ctx.font = "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#555" : "#999"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Click two points on the VI to draw a profile", cssW / 2, cssH / 2); + viProfileBaseImageRef.current = null; + viProfileLayoutRef.current = null; + return; + } + + const padLeft = 40; + const padRight = 8; + const padTop = 6; + const padBottom = 18; + const plotW = cssW - padLeft - padRight; + const plotH = cssH - padTop - padBottom; + + let gMin = Infinity, gMax = -Infinity; + for (let i = 0; i < viProfileData.length; i++) { + if (viProfileData[i] < gMin) gMin = viProfileData[i]; + if (viProfileData[i] > gMax) gMax = viProfileData[i]; + } + const range = gMax - gMin || 1; + + // X-axis: calibrated distance + let totalDist = viProfileData.length - 1; + let xUnit = "px"; + if (viProfilePoints.length === 2 && pixelSize > 0) { + const dx = viProfilePoints[1].col - viProfilePoints[0].col; + const dy = viProfilePoints[1].row - viProfilePoints[0].row; + const distPx = Math.sqrt(dx * dx + dy * dy); + totalDist = distPx * pixelSize; + xUnit = pixelUnit; + } + + // Draw axes + ctx.strokeStyle = isDark ? "#555" : "#bbb"; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(padLeft, padTop); + ctx.lineTo(padLeft, padTop + plotH); + ctx.lineTo(padLeft + plotW, padTop + plotH); + ctx.stroke(); + + // Draw profile curve + ctx.strokeStyle = themeColors.accent; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < viProfileData.length; i++) { + const x = padLeft + (i / (viProfileData.length - 1)) * plotW; + const y = padTop + plotH - ((viProfileData[i] - gMin) / range) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Draw x-axis ticks + const tickY = padTop + plotH; + ctx.strokeStyle = isDark ? "#555" : "#bbb"; + ctx.lineWidth = 0.5; + const idealTicks = Math.max(2, Math.floor(plotW / 70)); + const tickStep = roundToNiceValue(totalDist / idealTicks); + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#888" : "#666"; + ctx.textBaseline = "top"; + const ticks: number[] = []; + for (let v = 0; v <= totalDist + tickStep * 0.01; v += tickStep) { + if (v > totalDist * 1.001) break; + ticks.push(v); + } + for (let i = 0; i < ticks.length; i++) { + const v = ticks[i]; + const frac = totalDist > 0 ? v / totalDist : 0; + const x = padLeft + frac * plotW; + ctx.beginPath(); ctx.moveTo(x, tickY); ctx.lineTo(x, tickY + 3); ctx.stroke(); + ctx.textAlign = frac < 0.05 ? "left" : frac > 0.95 ? "right" : "center"; + const label = v % 1 === 0 ? v.toFixed(0) : v.toFixed(1); + ctx.fillText(i === ticks.length - 1 ? `${label} ${xUnit}` : label, x, tickY + 4); + } + + // Y-axis min/max labels (left margin) + ctx.font = "9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillStyle = isDark ? "#888" : "#666"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(formatNumber(gMax), 2, padTop); + ctx.textBaseline = "bottom"; + ctx.fillText(formatNumber(gMin), 2, padTop + plotH); + + // Save base image and layout for hover + viProfileBaseImageRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + viProfileLayoutRef.current = { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit }; + }, [viProfileData, viProfilePoints, pixelSize, themeInfo.theme, themeColors.accent, viCanvasWidth, viProfileHeight]); + + // VI Profile hover handlers + const handleViProfileMouseMove = React.useCallback((e: React.MouseEvent) => { + const canvas = viProfileCanvasRef.current; + const base = viProfileBaseImageRef.current; + const layout = viProfileLayoutRef.current; + if (!canvas || !base || !layout || !viProfileData) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const { padLeft, plotW, padTop, plotH, gMin, gMax, totalDist, xUnit } = layout; + const range = gMax - gMin || 1; + + // Restore base image + ctx.putImageData(base, 0, 0); + + if (cssX < padLeft || cssX > padLeft + plotW) return; + const frac = (cssX - padLeft) / plotW; + + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Vertical crosshair + const isDark = themeInfo.theme === "dark"; + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(cssX, padTop); + ctx.lineTo(cssX, padTop + plotH); + ctx.stroke(); + ctx.setLineDash([]); + + // Dot on curve + value + const dataIdx = Math.min(viProfileData.length - 1, Math.max(0, Math.round(frac * (viProfileData.length - 1)))); + const val = viProfileData[dataIdx]; + const y = padTop + plotH - ((val - gMin) / range) * plotH; + ctx.fillStyle = themeColors.accent; + ctx.beginPath(); + ctx.arc(cssX, y, 3, 0, Math.PI * 2); + ctx.fill(); + + // Value readout label + const dist = frac * totalDist; + const label = `${formatNumber(val)} @ ${dist.toFixed(1)} ${xUnit}`; + ctx.font = "bold 9px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + const textW = ctx.measureText(label).width; + const labelX = Math.min(cssX + 6, padLeft + plotW - textW - 2); + const labelY = padTop + 2; + ctx.fillStyle = isDark ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)"; + ctx.fillRect(labelX - 2, labelY - 1, textW + 4, 11); + ctx.fillStyle = isDark ? "#fff" : "#000"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(label, labelX, labelY); + + ctx.restore(); + }, [viProfileData, themeInfo.theme, themeColors.accent]); + + const handleViProfileMouseLeave = React.useCallback(() => { + const canvas = viProfileCanvasRef.current; + const base = viProfileBaseImageRef.current; + if (!canvas || !base) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.putImageData(base, 0, 0); + }, []); + + // VI Profile resize handlers + React.useEffect(() => { + if (!isResizingViProfile) return; + const handleMouseMove = (e: MouseEvent) => { + if (!viProfileResizeStart.current) return; + const deltaY = e.clientY - viProfileResizeStart.current.startY; + const newHeight = Math.max(40, Math.min(300, viProfileResizeStart.current.startHeight + deltaY)); + setViProfileHeight(newHeight); + }; + const handleMouseUp = () => { + setIsResizingViProfile(false); + viProfileResizeStart.current = null; + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizingViProfile]); + + // Generic zoom handler + const createZoomHandler = ( + setZoom: React.Dispatch>, + setPanX: React.Dispatch>, + setPanY: React.Dispatch>, + zoom: number, panX: number, panY: number, + canvasRef: React.RefObject, + ) => (e: React.WheelEvent) => { + e.preventDefault(); + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const mouseX = (e.clientX - rect.left) * (canvas.width / rect.width); + const mouseY = (e.clientY - rect.top) * (canvas.height / rect.height); + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * zoomFactor)); + const zoomRatio = newZoom / zoom; + setZoom(newZoom); + setPanX(mouseX - (mouseX - panX) * zoomRatio); + setPanY(mouseY - (mouseY - panY) * zoomRatio); + }; + + // ───────────────────────────────────────────────────────────────────────── + // Mouse Handlers + // ───────────────────────────────────────────────────────────────────────── + + // Helper: convert screen-pixel hit radius to image-pixel radius + // handleRadius=6 CSS px drawn, hit area ~10 CSS px → convert to image coords + const dpHitRadius = RESIZE_HIT_AREA_PX * Math.max(detCols, detRows) / canvasSize / dpZoom; + + // Helper: check if point is near the outer resize handle + const isNearResizeHandle = (imgX: number, imgY: number): boolean => { + if (roiMode === "rect") { + // For rectangle, check near bottom-right corner + const handleX = roiCenterCol + roiWidth / 2; + const handleY = roiCenterRow + roiHeight / 2; + const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); + return dist < dpHitRadius; + } + if ((roiMode !== "circle" && roiMode !== "square" && roiMode !== "annular") || !roiRadius) return false; + const offset = roiMode === "square" ? roiRadius : roiRadius * CIRCLE_HANDLE_ANGLE; + const handleX = roiCenterCol + offset; + const handleY = roiCenterRow + offset; + const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); + return dist < dpHitRadius; + }; + + // Helper: check if point is near the inner resize handle (annular mode only) + const isNearResizeHandleInner = (imgX: number, imgY: number): boolean => { + if (roiMode !== "annular" || !roiRadiusInner) return false; + const offset = roiRadiusInner * CIRCLE_HANDLE_ANGLE; + const handleX = roiCenterCol + offset; + const handleY = roiCenterRow + offset; + const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); + return dist < dpHitRadius; + }; + + // Helper: check if point is near VI ROI resize handle (same logic as DP) + // Hit area is capped to avoid overlap with center for small ROIs + const viHitRadius = RESIZE_HIT_AREA_PX * Math.max(shapeRows, shapeCols) / canvasSize / viZoom; + const isNearViRoiResizeHandle = (imgX: number, imgY: number): boolean => { + if (!viRoiMode || viRoiMode === "off") return false; + if (viRoiMode === "rect") { + const halfH = (viRoiHeight || 10) / 2; + const halfW = (viRoiWidth || 10) / 2; + const handleX = localViRoiCenterRow + halfH; + const handleY = localViRoiCenterCol + halfW; + const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); + const cornerDist = Math.sqrt(halfW ** 2 + halfH ** 2); + const hitArea = Math.min(viHitRadius, cornerDist * 0.5); + return dist < hitArea; + } + if (viRoiMode === "circle" || viRoiMode === "square") { + const radius = viRoiRadius || 5; + const offset = viRoiMode === "square" ? radius : radius * CIRCLE_HANDLE_ANGLE; + const handleX = localViRoiCenterRow + offset; + const handleY = localViRoiCenterCol + offset; + const dist = Math.sqrt((imgX - handleX) ** 2 + (imgY - handleY) ** 2); + // Cap hit area to 50% of radius so center remains draggable + const hitArea = Math.min(viHitRadius, radius * 0.5); + return dist < hitArea; + } + return false; + }; + + // Helper: check if point is inside the DP ROI area + const isInsideDpRoi = (imgX: number, imgY: number): boolean => { + if (roiMode === "point") return false; + const dx = imgX - roiCenterCol; + const dy = imgY - roiCenterRow; + if (roiMode === "circle") return Math.sqrt(dx * dx + dy * dy) <= (roiRadius || 5); + if (roiMode === "square") return Math.abs(dx) <= (roiRadius || 5) && Math.abs(dy) <= (roiRadius || 5); + if (roiMode === "annular") { const d = Math.sqrt(dx * dx + dy * dy); return d <= (roiRadius || 20) && d >= (roiRadiusInner || 5); } + if (roiMode === "rect") return Math.abs(dx) <= (roiWidth || 10) / 2 && Math.abs(dy) <= (roiHeight || 10) / 2; + return false; + }; + + // Helper: check if point is inside the VI ROI area + const isInsideViRoi = (imgX: number, imgY: number): boolean => { + if (!viRoiMode || viRoiMode === "off") return false; + const dx = imgY - localViRoiCenterCol; + const dy = imgX - localViRoiCenterRow; + if (viRoiMode === "circle") return Math.sqrt(dx * dx + dy * dy) <= (viRoiRadius || 5); + if (viRoiMode === "square") return Math.abs(dx) <= (viRoiRadius || 5) && Math.abs(dy) <= (viRoiRadius || 5); + if (viRoiMode === "rect") return Math.abs(dx) <= (viRoiWidth || 10) / 2 && Math.abs(dy) <= (viRoiHeight || 10) / 2; + return false; + }; + + // Mouse handlers + const handleDpMouseDown = (e: React.MouseEvent) => { + dpClickStartRef.current = { x: e.clientX, y: e.clientY }; + const canvas = dpOverlayRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); + const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); + const imgX = (screenX - dpPanX) / dpZoom; + const imgY = (screenY - dpPanY) / dpZoom; + + // When profile mode is active, use profile interactions only + if (profileActive) { + if (profilePoints.length === 2) { + const p0 = profilePoints[0]; + const p1 = profilePoints[1]; + const hitRadius = 10 / dpZoom; + const d0 = Math.sqrt((imgX - p0.col) ** 2 + (imgY - p0.row) ** 2); + const d1 = Math.sqrt((imgX - p1.col) ** 2 + (imgY - p1.row) ** 2); + if (d0 <= hitRadius || d1 <= hitRadius) { + setDraggingDpProfileEndpoint(d0 <= d1 ? 0 : 1); + setIsDraggingDP(false); + return; + } + if (pointToSegmentDistance(imgX, imgY, p0.col, p0.row, p1.col, p1.row) <= hitRadius) { + setIsDraggingDpProfileLine(true); + dpProfileDragStartRef.current = { + row: imgY, + col: imgX, + p0: { row: p0.row, col: p0.col }, + p1: { row: p1.row, col: p1.col }, + }; + setIsDraggingDP(false); + return; + } + } + setIsDraggingDP(false); + return; + } + + // Check if clicking on resize handle (inner first, then outer) + if (isNearResizeHandleInner(imgX, imgY)) { + setIsDraggingResizeInner(true); + return; + } + if (isNearResizeHandle(imgX, imgY)) { + e.preventDefault(); + resizeAspectRef.current = roiMode === "rect" && roiWidth > 0 && roiHeight > 0 ? roiWidth / roiHeight : null; + setIsDraggingResize(true); + return; + } + + setIsDraggingDP(true); + // If clicking inside the ROI, drag with offset (grab-and-drag) + if (roiMode !== "off" && roiMode !== "point" && isInsideDpRoi(imgX, imgY)) { + dpDragOffsetRef.current = { dRow: imgY - roiCenterRow, dCol: imgX - roiCenterCol }; + return; + } + // Clicking outside ROI — teleport center to click position + dpDragOffsetRef.current = { dRow: 0, dCol: 0 }; + setLocalKCol(imgX); setLocalKRow(imgY); + // Use compound roi_center trait [row, col] - single observer fires in Python + const newCol = Math.round(Math.max(0, Math.min(detCols - 1, imgX))); + const newRow = Math.round(Math.max(0, Math.min(detRows - 1, imgY))); + model.set("roi_active", true); + model.set("roi_center", [newRow, newCol]); + model.save_changes(); + }; + + const handleDpMouseMove = (e: React.MouseEvent) => { + const canvas = dpOverlayRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); + const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); + const imgX = (screenX - dpPanX) / dpZoom; + const imgY = (screenY - dpPanY) / dpZoom; + + // Fast path: skip cursor readout during any active drag — avoids setCursorInfo re-renders + const anyDrag = isDraggingDP || isDraggingResize || isDraggingResizeInner + || draggingDpProfileEndpoint !== null || isDraggingDpProfileLine; + + // Cursor readout: look up raw DP value at pixel position + if (!anyDrag) { + const pxCol = Math.floor(imgX); + const pxRow = Math.floor(imgY); + if (pxCol >= 0 && pxCol < detCols && pxRow >= 0 && pxRow < detRows && frameBytes) { + const usesViRoiDp = viRoiMode && viRoiMode !== "off" && viRoiDpBytes && viRoiDpBytes.byteLength > 0; + const sourceBytes = usesViRoiDp ? viRoiDpBytes : frameBytes; + const raw = new Float32Array(sourceBytes.buffer, sourceBytes.byteOffset, sourceBytes.byteLength / 4); + setCursorInfo({ row: pxRow, col: pxCol, value: raw[pxRow * detCols + pxCol], panel: "DP" }); + } else { + setCursorInfo(null); + } + } + + if (profileActive && profilePoints.length === 2) { + const p0 = profilePoints[0]; + const p1 = profilePoints[1]; + const hitRadius = 10 / dpZoom; + const d0 = Math.sqrt((imgX - p0.col) ** 2 + (imgY - p0.row) ** 2); + const d1 = Math.sqrt((imgX - p1.col) ** 2 + (imgY - p1.row) ** 2); + if (draggingDpProfileEndpoint !== null) { + if (!rawDpDataRef.current) return; + const clampedRow = Math.max(0, Math.min(detRows - 1, imgY)); + const clampedCol = Math.max(0, Math.min(detCols - 1, imgX)); + const next = [ + draggingDpProfileEndpoint === 0 ? { row: clampedRow, col: clampedCol } : profilePoints[0], + draggingDpProfileEndpoint === 1 ? { row: clampedRow, col: clampedCol } : profilePoints[1], + ]; + setProfileLine(next); + setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, next[0].row, next[0].col, next[1].row, next[1].col, profileWidth)); + return; + } + if (isDraggingDpProfileLine && dpProfileDragStartRef.current) { + if (!rawDpDataRef.current) return; + const drag = dpProfileDragStartRef.current; + let deltaRow = imgY - drag.row; + let deltaCol = imgX - drag.col; + const minRow = Math.min(drag.p0.row, drag.p1.row); + const maxRow = Math.max(drag.p0.row, drag.p1.row); + const minCol = Math.min(drag.p0.col, drag.p1.col); + const maxCol = Math.max(drag.p0.col, drag.p1.col); + deltaRow = Math.max(deltaRow, -minRow); + deltaRow = Math.min(deltaRow, (detRows - 1) - maxRow); + deltaCol = Math.max(deltaCol, -minCol); + deltaCol = Math.min(deltaCol, (detCols - 1) - maxCol); + const next = [ + { row: drag.p0.row + deltaRow, col: drag.p0.col + deltaCol }, + { row: drag.p1.row + deltaRow, col: drag.p1.col + deltaCol }, + ]; + setProfileLine(next); + setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, next[0].row, next[0].col, next[1].row, next[1].col, profileWidth)); + return; + } + const nextHoveredEndpoint: 0 | 1 | null = d0 <= hitRadius ? 0 : d1 <= hitRadius ? 1 : null; + const nextHoverLine = nextHoveredEndpoint === null && pointToSegmentDistance(imgX, imgY, p0.col, p0.row, p1.col, p1.row) <= hitRadius; + setHoveredDpProfileEndpoint(nextHoveredEndpoint); + setIsHoveringDpProfileLine(nextHoverLine); + return; + } else { + if (hoveredDpProfileEndpoint !== null) setHoveredDpProfileEndpoint(null); + if (isHoveringDpProfileLine) setIsHoveringDpProfileLine(false); + } + + // Handle inner resize dragging (annular mode) + if (isDraggingResizeInner) { + const dx = Math.abs(imgX - roiCenterCol); + const dy = Math.abs(imgY - roiCenterRow); + const newRadius = Math.sqrt(dx ** 2 + dy ** 2); + // Inner radius must be less than outer radius + setRoiRadiusInner(Math.max(1, Math.min(roiRadius - 1, Math.round(newRadius)))); + return; + } + + // Handle outer resize dragging - use model state center, not local values + if (isDraggingResize) { + const dx = Math.abs(imgX - roiCenterCol); + const dy = Math.abs(imgY - roiCenterRow); + if (roiMode === "rect") { + let newW = Math.max(2, Math.round(dx * 2)); + let newH = Math.max(2, Math.round(dy * 2)); + if (e.shiftKey && resizeAspectRef.current != null) { + const aspect = resizeAspectRef.current; + if (newW / newH > aspect) newH = Math.max(2, Math.round(newW / aspect)); + else newW = Math.max(2, Math.round(newH * aspect)); + } + setRoiWidth(newW); + setRoiHeight(newH); + } else { + const newRadius = roiMode === "square" ? Math.max(dx, dy) : Math.sqrt(dx ** 2 + dy ** 2); + // For annular mode, outer radius must be greater than inner radius + const minRadius = roiMode === "annular" ? (roiRadiusInner || 0) + 1 : 1; + setRoiRadius(Math.max(minRadius, Math.round(newRadius))); + } + return; + } + + // Check hover state for resize handles + if (!isDraggingDP) { + setIsHoveringResizeInner(isNearResizeHandleInner(imgX, imgY)); + setIsHoveringResize(isNearResizeHandle(imgX, imgY)); + return; + } + + const centerCol = imgX - dpDragOffsetRef.current.dCol; + const centerRow = imgY - dpDragOffsetRef.current.dRow; + setLocalKCol(centerCol); setLocalKRow(centerRow); + // rAF-coalesced — sends only the latest roi_center per frame. + const newCol = Math.round(Math.max(0, Math.min(detCols - 1, centerCol))); + const newRow = Math.round(Math.max(0, Math.min(detRows - 1, centerRow))); + queueRoiCenter(newRow, newCol); + }; + + const handleDpMouseUp = (e: React.MouseEvent) => { + if (draggingDpProfileEndpoint !== null || isDraggingDpProfileLine) { + setDraggingDpProfileEndpoint(null); + setIsDraggingDpProfileLine(false); + dpProfileDragStartRef.current = null; + dpClickStartRef.current = null; + setIsDraggingDP(false); + setIsDraggingResize(false); + setIsDraggingResizeInner(false); + setHoveredDpProfileEndpoint(null); + setIsHoveringDpProfileLine(false); + return; + } + + // Profile click capture + if (profileActive && dpClickStartRef.current) { + const dx = e.clientX - dpClickStartRef.current.x; + const dy = e.clientY - dpClickStartRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) < 3) { + const canvas = dpOverlayRef.current; + if (canvas && rawDpDataRef.current) { + const rect = canvas.getBoundingClientRect(); + const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); + const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); + const imgCol = (screenX - dpPanX) / dpZoom; + const imgRow = (screenY - dpPanY) / dpZoom; + if (imgCol >= 0 && imgCol < detCols && imgRow >= 0 && imgRow < detRows) { + const pt = { row: imgRow, col: imgCol }; + if (profilePoints.length === 0 || profilePoints.length === 2) { + setProfileLine([pt]); + setProfileData(null); + } else { + const p0 = profilePoints[0]; + setProfileLine([p0, pt]); + setProfileData(sampleLineProfile(rawDpDataRef.current, detCols, detRows, p0.row, p0.col, pt.row, pt.col, profileWidth)); + } + } + } + } + } + dpClickStartRef.current = null; + setIsDraggingDP(false); setIsDraggingResize(false); setIsDraggingResizeInner(false); + setDraggingDpProfileEndpoint(null); + setIsDraggingDpProfileLine(false); + setHoveredDpProfileEndpoint(null); + setIsHoveringDpProfileLine(false); + dpProfileDragStartRef.current = null; + }; + const handleDpMouseLeave = () => { + dpClickStartRef.current = null; + setIsDraggingDP(false); setIsDraggingResize(false); setIsDraggingResizeInner(false); + setDraggingDpProfileEndpoint(null); + setIsDraggingDpProfileLine(false); + setHoveredDpProfileEndpoint(null); + setIsHoveringDpProfileLine(false); + dpProfileDragStartRef.current = null; + setIsHoveringResize(false); setIsHoveringResizeInner(false); + setCursorInfo(prev => prev?.panel === "DP" ? null : prev); + }; + const handleDpDoubleClick = () => { + setDpZoom(1); + setDpPanX(0); + setDpPanY(0); + }; + + const handleViMouseDown = (e: React.MouseEvent) => { + const canvas = virtualOverlayRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); + const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); + const imgX = (screenY - viPanY) / viZoom; + const imgY = (screenX - viPanX) / viZoom; + + // VI Profile mode - click to set points + if (viProfileActive) { + viClickStartRef.current = { x: screenX, y: screenY }; + if (viProfilePoints.length === 2) { + const p0 = viProfilePoints[0]; + const p1 = viProfilePoints[1]; + const hitRadius = 10 / viZoom; + const d0 = Math.sqrt((imgY - p0.col) ** 2 + (imgX - p0.row) ** 2); + const d1 = Math.sqrt((imgY - p1.col) ** 2 + (imgX - p1.row) ** 2); + if (d0 <= hitRadius || d1 <= hitRadius) { + setDraggingViProfileEndpoint(d0 <= d1 ? 0 : 1); + setIsDraggingVI(false); + return; + } + if (pointToSegmentDistance(imgY, imgX, p0.col, p0.row, p1.col, p1.row) <= hitRadius) { + setIsDraggingViProfileLine(true); + viProfileDragStartRef.current = { + row: imgX, + col: imgY, + p0: { row: p0.row, col: p0.col }, + p1: { row: p1.row, col: p1.col }, + }; + setIsDraggingVI(false); + return; + } + } + return; + } + + // Check if VI ROI mode is active - same logic as DP + if (viRoiMode && viRoiMode !== "off") { + // Check if clicking on resize handle + if (isNearViRoiResizeHandle(imgX, imgY)) { + setIsDraggingViRoiResize(true); + return; + } + + // Grab-and-drag if clicking inside VI ROI, otherwise teleport + setIsDraggingViRoi(true); + if (isInsideViRoi(imgX, imgY)) { + viRoiDragOffsetRef.current = { dRow: imgX - localViRoiCenterRow, dCol: imgY - localViRoiCenterCol }; + } else { + viRoiDragOffsetRef.current = { dRow: 0, dCol: 0 }; + setLocalViRoiCenterRow(imgX); + setLocalViRoiCenterCol(imgY); + setViRoiCenterRow(Math.round(Math.max(0, Math.min(shapeRows - 1, imgX)))); + setViRoiCenterCol(Math.round(Math.max(0, Math.min(shapeCols - 1, imgY)))); + } + return; + } + + // Regular position selection (when ROI is off) + setIsDraggingVI(true); + setLocalPosRow(imgX); setLocalPosCol(imgY); + // Batch X and Y updates into a single sync + const newX = Math.round(Math.max(0, Math.min(shapeRows - 1, imgX))); + const newY = Math.round(Math.max(0, Math.min(shapeCols - 1, imgY))); + model.set("pos_row", newX); + model.set("pos_col", newY); + model.save_changes(); + }; + + const handleViMouseMove = (e: React.MouseEvent) => { + const canvas = virtualOverlayRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const screenX = (e.clientX - rect.left) * (canvas.width / rect.width); + const screenY = (e.clientY - rect.top) * (canvas.height / rect.height); + const imgX = (screenY - viPanY) / viZoom; + const imgY = (screenX - viPanX) / viZoom; + + // Fast path: skip cursor readout during any active drag — avoids setCursorInfo re-renders + const anyViDrag = isDraggingVI || isDraggingViRoi || isDraggingViRoiResize + || draggingViProfileEndpoint !== null || isDraggingViProfileLine; + + // Cursor readout: look up raw VI value at pixel position + // imgX = row, imgY = col (swapped coordinate convention) + if (!anyViDrag) { + const pxRow = Math.floor(imgX); + const pxCol = Math.floor(imgY); + if (pxRow >= 0 && pxRow < shapeRows && pxCol >= 0 && pxCol < shapeCols && rawVirtualImageRef.current) { + const raw = rawVirtualImageRef.current; + setCursorInfo({ row: pxRow, col: pxCol, value: raw[pxRow * shapeCols + pxCol], panel: "VI" }); + } else { + setCursorInfo(prev => prev?.panel === "VI" ? null : prev); + } + } + + if (viProfileActive && viProfilePoints.length === 2) { + const p0 = viProfilePoints[0]; + const p1 = viProfilePoints[1]; + const hitRadius = 10 / viZoom; + const d0 = Math.sqrt((imgY - p0.col) ** 2 + (imgX - p0.row) ** 2); + const d1 = Math.sqrt((imgY - p1.col) ** 2 + (imgX - p1.row) ** 2); + if (draggingViProfileEndpoint !== null) { + const clampedRow = Math.max(0, Math.min(shapeRows - 1, imgX)); + const clampedCol = Math.max(0, Math.min(shapeCols - 1, imgY)); + const next = [ + draggingViProfileEndpoint === 0 ? { row: clampedRow, col: clampedCol } : viProfilePoints[0], + draggingViProfileEndpoint === 1 ? { row: clampedRow, col: clampedCol } : viProfilePoints[1], + ]; + setViProfilePoints(next); + return; + } + if (isDraggingViProfileLine && viProfileDragStartRef.current) { + const drag = viProfileDragStartRef.current; + let deltaRow = imgX - drag.row; + let deltaCol = imgY - drag.col; + const minRow = Math.min(drag.p0.row, drag.p1.row); + const maxRow = Math.max(drag.p0.row, drag.p1.row); + const minCol = Math.min(drag.p0.col, drag.p1.col); + const maxCol = Math.max(drag.p0.col, drag.p1.col); + deltaRow = Math.max(deltaRow, -minRow); + deltaRow = Math.min(deltaRow, (shapeRows - 1) - maxRow); + deltaCol = Math.max(deltaCol, -minCol); + deltaCol = Math.min(deltaCol, (shapeCols - 1) - maxCol); + const next = [ + { row: drag.p0.row + deltaRow, col: drag.p0.col + deltaCol }, + { row: drag.p1.row + deltaRow, col: drag.p1.col + deltaCol }, + ]; + setViProfilePoints(next); + return; + } + const nextHoveredEndpoint: 0 | 1 | null = d0 <= hitRadius ? 0 : d1 <= hitRadius ? 1 : null; + const nextHoverLine = nextHoveredEndpoint === null && pointToSegmentDistance(imgY, imgX, p0.col, p0.row, p1.col, p1.row) <= hitRadius; + setHoveredViProfileEndpoint(nextHoveredEndpoint); + setIsHoveringViProfileLine(nextHoverLine); + return; + } else { + if (hoveredViProfileEndpoint !== null) setHoveredViProfileEndpoint(null); + if (isHoveringViProfileLine) setIsHoveringViProfileLine(false); + } + + // Handle VI ROI resize dragging (same pattern as DP) + if (isDraggingViRoiResize) { + const dx = Math.abs(imgX - localViRoiCenterRow); + const dy = Math.abs(imgY - localViRoiCenterCol); + if (viRoiMode === "rect") { + setViRoiWidth(Math.max(2, Math.round(dy * 2))); + setViRoiHeight(Math.max(2, Math.round(dx * 2))); + } else if (viRoiMode === "square") { + const newHalfSize = Math.max(dx, dy); + setViRoiRadius(Math.max(1, Math.round(newHalfSize))); + } else { + // circle + const newRadius = Math.sqrt(dx ** 2 + dy ** 2); + setViRoiRadius(Math.max(1, Math.round(newRadius))); + } + return; + } + + // Check hover state for resize handles (same as DP) + if (!isDraggingViRoi) { + setIsHoveringViRoiResize(isNearViRoiResizeHandle(imgX, imgY)); + if (viRoiMode && viRoiMode !== "off") return; // Don't update position when ROI active + } + + // Handle VI ROI center dragging (same as DP — with offset) + if (isDraggingViRoi) { + const centerRow = imgX - viRoiDragOffsetRef.current.dRow; + const centerCol = imgY - viRoiDragOffsetRef.current.dCol; + setLocalViRoiCenterRow(centerRow); + setLocalViRoiCenterCol(centerCol); + // Compound trait update — single observer fires Python-side; reduced DP is + // never computed against split-trait state (old col + new row, or vice versa). + const newViX = Math.round(Math.max(0, Math.min(shapeRows - 1, centerRow))); + const newViY = Math.round(Math.max(0, Math.min(shapeCols - 1, centerCol))); + model.set("vi_roi_center", [newViX, newViY]); + model.save_changes(); + return; + } + + // Handle regular position dragging (when ROI is off) + if (!isDraggingVI) return; + setLocalPosRow(imgX); setLocalPosCol(imgY); + // Batch position updates into a single sync + const newX = Math.round(Math.max(0, Math.min(shapeRows - 1, imgX))); + const newY = Math.round(Math.max(0, Math.min(shapeCols - 1, imgY))); + model.set("pos_row", newX); + model.set("pos_col", newY); + model.save_changes(); + }; + + const handleViMouseUp = (e: React.MouseEvent) => { + if (draggingViProfileEndpoint !== null || isDraggingViProfileLine) { + setDraggingViProfileEndpoint(null); + setIsDraggingViProfileLine(false); + viProfileDragStartRef.current = null; + viClickStartRef.current = null; + setIsDraggingVI(false); + setIsDraggingViRoi(false); + setIsDraggingViRoiResize(false); + setHoveredViProfileEndpoint(null); + setIsHoveringViProfileLine(false); + return; + } + + // VI Profile mode - complete point selection + if (viProfileActive && viClickStartRef.current) { + const canvas = virtualOverlayRef.current; + if (canvas) { + const rect = canvas.getBoundingClientRect(); + const endX = (e.clientX - rect.left) * (canvas.width / rect.width); + const endY = (e.clientY - rect.top) * (canvas.height / rect.height); + const dx = endX - viClickStartRef.current.x; + const dy = endY - viClickStartRef.current.y; + const wasDrag = Math.sqrt(dx * dx + dy * dy) > 3; + + if (!wasDrag) { + // Click to add point + const imgX = (endY - viPanY) / viZoom; + const imgY = (endX - viPanX) / viZoom; + const pt = { row: Math.round(Math.max(0, Math.min(shapeRows - 1, imgX))), col: Math.round(Math.max(0, Math.min(shapeCols - 1, imgY))) }; + if (viProfilePoints.length < 2) { + setViProfilePoints([...viProfilePoints, pt]); + } else { + setViProfilePoints([pt]); + } + } + } + viClickStartRef.current = null; + } + + setDraggingViProfileEndpoint(null); + setIsDraggingViProfileLine(false); + setHoveredViProfileEndpoint(null); + setIsHoveringViProfileLine(false); + viProfileDragStartRef.current = null; + setIsDraggingVI(false); + setIsDraggingViRoi(false); + setIsDraggingViRoiResize(false); + }; + const handleViMouseLeave = () => { + viClickStartRef.current = null; + setDraggingViProfileEndpoint(null); + setIsDraggingViProfileLine(false); + setHoveredViProfileEndpoint(null); + setIsHoveringViProfileLine(false); + viProfileDragStartRef.current = null; + setIsDraggingVI(false); + setIsDraggingViRoi(false); + setIsDraggingViRoiResize(false); + setIsHoveringViRoiResize(false); + setCursorInfo(prev => prev?.panel === "VI" ? null : prev); + }; + const handleViDoubleClick = () => { + setViZoom(1); + setViPanX(0); + setViPanY(0); + }; + const handleFftDoubleClick = () => { + setFftZoom(1); + setFftPanX(0); + setFftPanY(0); + setFftClickInfo(null); + }; + + // FFT drag-to-pan handlers + const handleFftMouseDown = (e: React.MouseEvent) => { + fftClickStartRef.current = { x: e.clientX, y: e.clientY }; + setIsDraggingFFT(true); + setFftDragStart({ x: e.clientX, y: e.clientY, panX: fftPanX, panY: fftPanY }); + }; + + const handleFftMouseMove = (e: React.MouseEvent) => { + if (!isDraggingFFT || !fftDragStart) return; + const canvas = fftOverlayRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const dx = (e.clientX - fftDragStart.x) * scaleX; + const dy = (e.clientY - fftDragStart.y) * scaleY; + setFftPanX(fftDragStart.panX + dx); + setFftPanY(fftDragStart.panY + dy); + }; + + const handleFftMouseUp = (e: React.MouseEvent) => { + // Click detection for d-spacing measurement + if (fftClickStartRef.current) { + const dx = e.clientX - fftClickStartRef.current.x; + const dy = e.clientY - fftClickStartRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) < 3) { + // Convert screen coords to FFT image coords + const canvas = fftOverlayRef.current; + if (canvas) { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const canvasX = (e.clientX - rect.left) * scaleX; + const canvasY = (e.clientY - rect.top) * scaleY; + const fftW = fftCropDims?.fftWidth ?? shapeCols; + const fftH = fftCropDims?.fftHeight ?? shapeRows; + // Reverse the render transform: canvas coords -> image coords. + // Render: translate(panX, panY); scale(zoom); drawImage(offscreen, 0,0,fftW,fftH, 0,0,canvasW,canvasH) + // So: canvasX = panX + zoom * (imgCol * canvasW / fftW) → imgCol = (canvasX - panX) / zoom * fftW / canvasW + let imgCol = ((canvasX - fftPanX) / fftZoom) * (fftW / canvas.width); + let imgRow = ((canvasY - fftPanY) / fftZoom) * (fftH / canvas.height); + // Bounds check + if (imgCol >= 0 && imgCol < fftW && imgRow >= 0 && imgRow < fftH) { + // Snap to nearest peak in FFT magnitude + if (fftMagCacheRef.current) { + const snapped = findFFTPeak(fftMagCacheRef.current, fftW, fftH, imgCol, imgRow, FFT_SNAP_RADIUS); + imgCol = snapped.col; + imgRow = snapped.row; + } + const halfW = Math.floor(fftW / 2); + const halfH = Math.floor(fftH / 2); + const dcol = imgCol - halfW; + const drow = imgRow - halfH; + const distPx = Math.sqrt(dcol * dcol + drow * drow); + if (distPx < 1) { + setFftClickInfo(null); // Clicked on DC center + } else { + let spatialFreq: number | null = null; + let dSpacing: number | null = null; + if (pixelSize > 0) { + const paddedW = nextPow2(fftW); + const paddedH = nextPow2(fftH); + const binC = ((Math.round(imgCol) - halfW) % fftW + fftW) % fftW; + const binR = ((Math.round(imgRow) - halfH) % fftH + fftH) % fftH; + const freqC = binC <= paddedW / 2 ? binC / (paddedW * pixelSize) : (binC - paddedW) / (paddedW * pixelSize); + const freqR = binR <= paddedH / 2 ? binR / (paddedH * pixelSize) : (binR - paddedH) / (paddedH * pixelSize); + spatialFreq = Math.sqrt(freqC * freqC + freqR * freqR); + dSpacing = spatialFreq > 0 ? 1 / spatialFreq : null; + } + setFftClickInfo({ row: imgRow, col: imgCol, distPx, spatialFreq, dSpacing }); + } + } + } + } + fftClickStartRef.current = null; + } + setIsDraggingFFT(false); + setFftDragStart(null); + }; + const handleFftMouseLeave = () => { fftClickStartRef.current = null; setIsDraggingFFT(false); setFftDragStart(null); }; + + // ── Canvas resize handlers ── + const handleCanvasResizeStart = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsResizingCanvas(true); + setResizeCanvasStart({ x: e.clientX, y: e.clientY, size: canvasSize }); + }; + + React.useEffect(() => { + if (!isResizingCanvas) return; + let rafId = 0; + let latestSize = resizeCanvasStart ? resizeCanvasStart.size : canvasSize; + const handleMouseMove = (e: MouseEvent) => { + if (!resizeCanvasStart) return; + const delta = Math.max(e.clientX - resizeCanvasStart.x, e.clientY - resizeCanvasStart.y); + latestSize = Math.max(CANVAS_SIZE, resizeCanvasStart.size + delta); + if (!rafId) { + rafId = requestAnimationFrame(() => { + rafId = 0; + setCanvasSize(latestSize); + }); + } + }; + const handleMouseUp = () => { + cancelAnimationFrame(rafId); + setCanvasSize(latestSize); + setIsResizingCanvas(false); + setResizeCanvasStart(null); + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + cancelAnimationFrame(rafId); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizingCanvas, resizeCanvasStart]); + + // ───────────────────────────────────────────────────────────────────────── + // Render + // ───────────────────────────────────────────────────────────────────────── + + // Export DP handler + const handleExportDP = async () => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const zip = new JSZip(); + const metadata = { + metadata_version: "1.0", + widget_name: "Show4DSTEM", + widget_version: widgetVersion || "unknown", + exported_at: new Date().toISOString(), + view: "diffraction", + format: "zip", + export_kind: "single_view_png_zip", + position: { row: posRow, col: posCol }, + frame_idx: frameIdx, + n_frames: nFrames, + scan_shape: { rows: shapeRows, cols: shapeCols }, + detector_shape: { rows: detRows, cols: detCols }, + roi: { + active: roiMode !== "off", + mode: roiMode, + center_row: roiCenterRow, + center_col: roiCenterCol, + radius: roiRadius, + radius_inner: roiRadiusInner, + width: roiWidth, + height: roiHeight, + }, + vi_roi: { + mode: viRoiMode, + center_row: viRoiCenterRow, + center_col: viRoiCenterCol, + radius: viRoiRadius, + width: viRoiWidth, + height: viRoiHeight, + }, + calibration: { + pixel_size_angstrom: pixelSize, + pixel_size_unit: "Å/px", + k_pixel_size: kPixelSize, + k_pixel_size_unit: kCalibrated ? "mrad/px" : "px/px", + k_calibrated: kCalibrated, + center_row: centerRow, + center_col: centerCol, + bf_radius: bfRadius, + }, + display: { + diffraction: { + colormap: dpColormap, + scale_mode: dpScaleMode, + vmin_pct: dpVminPct, + vmax_pct: dpVmaxPct, + }, + }, + }; + zip.file("metadata.json", JSON.stringify(metadata, null, 2)); + const canvasToBlob = (canvas: HTMLCanvasElement): Promise => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png')); + if (dpCanvasRef.current) zip.file("diffraction_pattern.png", await canvasToBlob(dpCanvasRef.current)); + const zipBlob = await zip.generateAsync({ type: "blob" }); + downloadBlob(zipBlob, `dp_export_${timestamp}.zip`); + }; + + // Export VI handler + const handleExportVI = async () => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const zip = new JSZip(); + const metadata = { + metadata_version: "1.0", + widget_name: "Show4DSTEM", + widget_version: widgetVersion || "unknown", + exported_at: new Date().toISOString(), + view: "all", + format: "zip", + export_kind: "multi_panel_png_zip", + position: { row: posRow, col: posCol }, + frame_idx: frameIdx, + n_frames: nFrames, + scan_shape: { rows: shapeRows, cols: shapeCols }, + detector_shape: { rows: detRows, cols: detCols }, + roi: { + active: roiMode !== "off", + mode: roiMode, + center_row: roiCenterRow, + center_col: roiCenterCol, + radius: roiRadius, + radius_inner: roiRadiusInner, + width: roiWidth, + height: roiHeight, + }, + vi_roi: { + mode: viRoiMode, + center_row: viRoiCenterRow, + center_col: viRoiCenterCol, + radius: viRoiRadius, + width: viRoiWidth, + height: viRoiHeight, + }, + calibration: { + pixel_size_angstrom: pixelSize, + pixel_size_unit: "Å/px", + k_pixel_size: kPixelSize, + k_pixel_size_unit: kCalibrated ? "mrad/px" : "px/px", + k_calibrated: kCalibrated, + center_row: centerRow, + center_col: centerCol, + bf_radius: bfRadius, + }, + display: { + diffraction: { + colormap: dpColormap, + scale_mode: dpScaleMode, + vmin_pct: dpVminPct, + vmax_pct: dpVmaxPct, + }, + virtual: { + colormap: viColormap, + scale_mode: viScaleMode, + vmin_pct: viVminPct, + vmax_pct: viVmaxPct, + }, + fft: { + colormap: fftColormap, + scale_mode: fftScaleMode, + auto: fftAuto, + vmin_pct: fftVminPct, + vmax_pct: fftVmaxPct, + }, + }, + }; + zip.file("metadata.json", JSON.stringify(metadata, null, 2)); + const canvasToBlob = (canvas: HTMLCanvasElement): Promise => new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png')); + if (virtualCanvasRef.current) zip.file("virtual_image.png", await canvasToBlob(virtualCanvasRef.current)); + if (dpCanvasRef.current) zip.file("diffraction_pattern.png", await canvasToBlob(dpCanvasRef.current)); + if (fftCanvasRef.current) zip.file("fft.png", await canvasToBlob(fftCanvasRef.current)); + const zipBlob = await zip.generateAsync({ type: "blob" }); + downloadBlob(zipBlob, `4dstem_export_${timestamp}.zip`); + }; + + // ── DP Figure Export ── + const handleDpExportFigure = (withColorbar: boolean) => { + setDpExportAnchor(null); + const frameData = rawDpDataRef.current; + if (!frameData) return; + const processed = dpScaleMode === "log" ? applyLogScale(frameData) : frameData; + const lut = COLORMAPS[dpColormap] || COLORMAPS.inferno; + const { min: dMin, max: dMax } = findDataRange(processed); + let vmin: number, vmax: number; + if (traitDpVmin != null && traitDpVmax != null) { + if (dpScaleMode === "log") { + vmin = Math.log1p(Math.max(traitDpVmin, 0)); + vmax = Math.log1p(Math.max(traitDpVmax, 0)); + } else { + vmin = traitDpVmin; + vmax = traitDpVmax; + } + } else { + ({ vmin, vmax } = sliderRange(dMin, dMax, dpVminPct, dpVmaxPct)); + } + const offscreen = renderToOffscreen(processed, detCols, detRows, lut, vmin, vmax); + if (!offscreen) return; + const kPxAngstrom = kPixelSize > 0 && kCalibrated ? kPixelSize : 0; + const figCanvas = exportFigure({ + imageCanvas: offscreen, + title: `DP at (${posRow}, ${posCol})`, + lut, + vmin, + vmax, + logScale: dpScaleMode === "log", + pixelSize: kPxAngstrom > 0 ? kPxAngstrom : undefined, + showColorbar: withColorbar, + showScaleBar: kPxAngstrom > 0, + }); + canvasToPDF(figCanvas).then((blob) => downloadBlob(blob, "show4dstem_dp_figure.pdf")).catch(console.error); + }; + + const handleDpExportPng = () => { + setDpExportAnchor(null); + if (!dpCanvasRef.current) return; + dpCanvasRef.current.toBlob((b) => { if (b) downloadBlob(b, "show4dstem_dp.png"); }, "image/png"); + }; + + const handleDpExportGif = () => { + setDpExportAnchor(null); + setExporting(true); + setGifExportRequested(true); + }; + + // ── VI Figure Export ── + const handleViExportFigure = (withColorbar: boolean) => { + setViExportAnchor(null); + if (!virtualCanvasRef.current) return; + const viCanvas = virtualCanvasRef.current; + const pixelSizeAngstrom = pixelSize > 0 ? pixelSize : 0; + const figCanvas = exportFigure({ + imageCanvas: viCanvas, + title: "Virtual Image", + showColorbar: withColorbar, + showScaleBar: pixelSizeAngstrom > 0, + pixelSize: pixelSizeAngstrom > 0 ? pixelSizeAngstrom : undefined, + }); + canvasToPDF(figCanvas).then((blob) => downloadBlob(blob, "show4dstem_vi_figure.pdf")).catch(console.error); + }; + + const handleViExportPng = () => { + setViExportAnchor(null); + if (!virtualCanvasRef.current) return; + virtualCanvasRef.current.toBlob((b) => { if (b) downloadBlob(b, "show4dstem_vi.png"); }, "image/png"); + }; + + // Download GIF when data arrives from Python + React.useEffect(() => { + if (!gifData || gifData.byteLength === 0) return; + downloadDataView(gifData, "show4dstem_dp_animation.gif", "image/gif"); + const metaText = (gifMetadataJson || "").trim(); + if (metaText) { + downloadBlob(new Blob([metaText], { type: "application/json" }), "show4dstem_dp_animation.json"); + } + setExporting(false); + }, [gifData, gifMetadataJson]); + + + // Theme-aware select style + const themedSelect = { + ...controlPanel.select, + bgcolor: themeColors.controlBg, + color: themeColors.text, + "& .MuiSelect-select": { py: 0.5 }, + "& .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.border }, + "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: themeColors.accent }, + }; + + const themedMenuProps = { + ...upwardMenuProps, + PaperProps: { sx: { bgcolor: themeColors.controlBg, color: themeColors.text, border: `1px solid ${themeColors.border}` } }, + }; + + const keyboardShortcutItems: [string, string][] = [ + ["↑ / ↓", "Move scan row"], + ["← / →", "Move scan col"], + ["Shift+Arrows", "Move ×10"], + ...(nFrames > 1 ? [["[ / ]", `Prev / next ${frameDimLabel.toLowerCase()}`] as [string, string]] : []), + ["Space", "Play / pause"], + ["R", "Reset all zoom/pan"], + ["Esc", "Release keyboard focus"], + ["Scroll", "Zoom"], + ["Dbl-click", "Reset view"], + ]; + + return ( + + {/* HEADER */} + + {title || "4D-STEM Explorer"} + {nFrames > 1 && ({frameLabels && frameLabels.length > frameIdx ? frameLabels[frameIdx] : `${frameDimLabel} ${frameIdx + 1}/${nFrames}`})} + + Controls + DP: Diffraction pattern I(kx,ky) at scan position. Drag to move ROI center. + Detector: ROI mask shape — defines which DP pixels are integrated for the virtual image. + BF/ABF/ADF: Preset detector configurations (bright-field, annular bright-field, annular dark-field). + Image: Virtual image — integrated intensity within detector ROI at each scan position. + FFT: Spatial frequency content of the virtual image. Auto masks DC + clips to 99.9th percentile. + Smooth: CSS bilinear blit on the VI canvas. No data change — browser smooths the upscale visually. Off = nearest-neighbor (sharp pixel boundaries). + Auto: Percentile contrast (1st–99th). Clips outliers automatically. + Profile: Click two points on DP to draw a line intensity profile. + {nFrames > 1 && <> + Frame Playback ({frameDimLabel}) + Loop: Loop playback. Bounce: Ping-pong — alternates forward and reverse. + FPS: Adjust playback speed (1–30 frames per second). + } + Keyboard + + } theme={themeInfo.theme} /> + + + {/* MAIN CONTENT: DP | VI | FFT (three columns when FFT shown) */} + + {/* LEFT COLUMN: DP Panel */} + + {/* DP Header */} + + + DP at ({Math.round(localPosRow)}, {Math.round(localPosCol)}) + k: ({Math.round(localKRow)}, {Math.round(localKCol)}) + + + Profile: + { + const on = e.target.checked; + setProfileActive(on); + if (!on) { + setProfileLine([]); + setProfileData(null); + setHoveredDpProfileEndpoint(null); + setIsHoveringDpProfileLine(false); + } + }} size="small" sx={switchStyles.small} /> + + + + setDpExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> + handleDpExportFigure(true)} sx={{ fontSize: 12 }}>PDF + colorbar + handleDpExportFigure(false)} sx={{ fontSize: 12 }}>PDF + PNG + { setDpExportAnchor(null); handleExportDP(); }} sx={{ fontSize: 12 }}>ZIP (PNG + metadata) + {pathLength > 0 && GIF (path animation)} + + + + + {/* DP Canvas */} + + + + + {cursorInfo && cursorInfo.panel === "DP" && ( + + + ({cursorInfo.row}, {cursorInfo.col}) {formatNumber(cursorInfo.value)} + + + )} + + + + {/* DP Stats Bar */} + {dpStats && dpStats.length === 4 && ( + + Mean {formatStat(dpStats[0])} + Min {formatStat(dpStats[1])} + Max {formatStat(dpStats[2])} + Std {formatStat(dpStats[3])} + + { model.set("_preset_request", "bf"); model.save_changes(); }} sx={{ color: roiColors.textColor, fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>BF + { model.set("_preset_request", "abf"); model.save_changes(); }} sx={{ color: "#4af", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>ABF + { model.set("_preset_request", "adf"); model.save_changes(); }} sx={{ color: "#fa4", fontSize: 11, fontWeight: "bold", cursor: "pointer", "&:hover": { textDecoration: "underline" } }}>ADF + + )} + + {/* Profile sparkline */} + {profileActive && ( + + + { + setIsResizingProfile(true); + profileResizeStart.current = { startY: e.clientY, startHeight: profileHeight }; + }} + sx={{ width: canvasSize, height: 4, cursor: "ns-resize", borderTop: `1px solid ${themeColors.border}`, borderLeft: `1px solid ${themeColors.border}`, borderRight: `1px solid ${themeColors.border}`, borderBottom: `1px solid ${themeColors.border}`, bgcolor: themeColors.controlBg, "&:hover": { bgcolor: themeColors.accent } }} + /> + + )} + + {/* DP Controls - two rows with histogram on right */} + {showControls && ( + + {/* Left: two rows of controls */} + + {/* Row 1: Detector + slider */} + + Detector: + + {(roiMode === "circle" || roiMode === "square" || roiMode === "annular") && ( + <> + { + if (roiMode === "annular") { + const [inner, outer] = v as number[]; + setRoiRadiusInner(Math.min(inner, outer - 1)); + setRoiRadius(Math.max(outer, inner + 1)); + } else { + const next = Array.isArray(v) ? v[0] : v; + setRoiRadius(next); + } + }} + min={1} + max={Math.min(detRows, detCols) / 2} + size="small" + sx={{ ...sliderStyles.small, width: roiMode === "annular" ? 67 : 47, mx: 1 }} + /> + + {roiMode === "annular" ? `${Math.round(roiRadiusInner)}-${Math.round(roiRadius)}px` : `${Math.round(roiRadius)}px`} + + + )} + + {/* Row 2: Color + Scale + Colorbar */} + + Color: + + Scale: + + Colorbar: + setShowDpColorbar(e.target.checked)} size="small" sx={switchStyles.small} /> + + + {/* Right: Histogram spanning both rows */} + + { setDpVminPct(min); setDpVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme} dataMin={dpGlobalMin} dataMax={dpGlobalMax} /> + + + )} + + + {/* SECOND COLUMN: VI Panel */} + + {/* VI Header */} + + + {shapeRows}×{shapeCols} | {detRows}×{detCols} + + + FFT: + setShowFft(e.target.checked)} size="small" sx={switchStyles.small} /> + Profile: + { + const on = e.target.checked; + setViProfileActive(on); + if (!on) { + setViProfilePoints([]); + setHoveredViProfileEndpoint(null); + setIsHoveringViProfileLine(false); + } + }} size="small" sx={switchStyles.small} /> + + + + setViExportAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ zIndex: 9999 }}> + handleViExportFigure(true)} sx={{ fontSize: 12 }}>PDF + colorbar + handleViExportFigure(false)} sx={{ fontSize: 12 }}>PDF + PNG + { setViExportAnchor(null); handleExportVI(); }} sx={{ fontSize: 12 }}>ZIP (all panels + metadata) + + + + + {/* VI Canvas */} + + + + + {cursorInfo && cursorInfo.panel === "VI" && ( + + + ({cursorInfo.row}, {cursorInfo.col}) {formatNumber(cursorInfo.value)} + + + )} + + + + {/* VI Stats Bar — stats on left, Auto/Smooth toggles on right edge */} + {viStats && viStats.length === 4 && ( + + Mean {formatStat(viStats[0])} + Min {formatStat(viStats[1])} + Max {formatStat(viStats[2])} + Std {formatStat(viStats[3])} + + Auto: + setViAutoContrast(e.target.checked)} size="small" sx={switchStyles.small} /> + Smooth: + setViSmooth(e.target.checked)} size="small" sx={switchStyles.small} /> + + + )} + + {/* VI Profile sparkline */} + {viProfileActive && ( + + + { + setIsResizingViProfile(true); + viProfileResizeStart.current = { startY: e.clientY, startHeight: viProfileHeight }; + }} + sx={{ width: viCanvasWidth, height: 4, cursor: "ns-resize", borderTop: `1px solid ${themeColors.border}`, borderLeft: `1px solid ${themeColors.border}`, borderRight: `1px solid ${themeColors.border}`, borderBottom: `1px solid ${themeColors.border}`, bgcolor: themeColors.controlBg, "&:hover": { bgcolor: themeColors.accent } }} + /> + + )} + + {/* VI Controls - Two rows with histogram on right */} + {showControls && ( + + {/* Left: Two rows of controls */} + + {/* Row 1: ROI selector */} + + ROI: + + {viRoiMode && viRoiMode !== "off" && ( + <> + {(viRoiMode === "circle" || viRoiMode === "square") && ( + <> + setViRoiRadius(v as number)} + min={1} + max={Math.min(shapeRows, shapeCols) / 2} + size="small" + sx={{ ...sliderStyles.small, width: 53, mx: 1 }} + /> + + {Math.round(viRoiRadius || 5)}px + + + )} + + + )} + + {/* Row 2: Color + Scale */} + + Color: + + Scale: + + + + {/* Right: Histogram spanning both rows */} + + { setViVminPct(min); setViVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme} dataMin={viDataMin} dataMax={viDataMax} /> + + + )} + + + {/* THIRD COLUMN: FFT Panel (conditionally shown) */} + {effectiveShowFft && ( + + {/* FFT Header */} + + {roiFftActive && fftCropDims ? `ROI FFT (${fftCropDims.cropWidth}\u00D7${fftCropDims.cropHeight})` : "FFT"} + + + + + + {/* FFT Canvas */} + + + + + + + {/* FFT Stats Bar */} + {fftStats && fftStats.length === 4 && ( + + Mean {formatStat(fftStats[0])} + Min {formatStat(fftStats[1])} + Max {formatStat(fftStats[2])} + Std {formatStat(fftStats[3])} + + )} + + {/* FFT D-spacing readout */} + {fftClickInfo && ( + + + Spot ({fftClickInfo.row.toFixed(1)}, {fftClickInfo.col.toFixed(1)}) + + + dist {fftClickInfo.distPx.toFixed(1)} px + + {fftClickInfo.dSpacing != null && ( + + d = {fftClickInfo.dSpacing >= 10 ? `${(fftClickInfo.dSpacing / 10).toFixed(2)} nm` : `${fftClickInfo.dSpacing.toFixed(2)} \u00C5`} + + )} + {fftClickInfo.spatialFreq != null && ( + + q = {fftClickInfo.spatialFreq.toFixed(4)} {"\u00C5\u207B\u00B9"} + + )} + + )} + + {/* FFT Controls - Two rows with histogram on right */} + {showControls && ( + + {/* Left: Two rows of controls */} + + {/* Row 1: Scale + Clip */} + + Scale: + + Auto: + setFftAuto(e.target.checked)} size="small" sx={switchStyles.small} /> + {fftCropDims && ( + <> + Win: + setFftWindow(e.target.checked)} size="small" sx={switchStyles.small} /> + + )} + + {/* Row 2: Color */} + + Color: + + + + {/* Right: Histogram spanning both rows */} + + {fftHistogramData && ( + { setFftVminPct(min); setFftVmaxPct(max); }} width={110} height={58} theme={themeInfo.theme} dataMin={fftDataMin} dataMax={fftDataMax} /> + )} + + + )} + + )} + + + {/* BOTTOM CONTROLS */} + + {/* Frame controls (5D time/tilt series) — matches Show3D playback */} + {showControls && nFrames > 1 && (<> + + {frameDimLabel}: + + { setFrameReverse(true); setFramePlaying(true); }} sx={{ color: frameReverse && framePlaying ? themeColors.accent : themeColors.textMuted, p: 0.25 }}> + + + setFramePlaying(!framePlaying)} sx={{ color: themeColors.accent, p: 0.25 }}> + {framePlaying ? : } + + { setFrameReverse(false); setFramePlaying(true); }} sx={{ color: !frameReverse && framePlaying ? themeColors.accent : themeColors.textMuted, p: 0.25 }}> + + + { setFramePlaying(false); setFrameIdx(0); }} sx={{ color: themeColors.textMuted, p: 0.25 }}> + + + + { setFramePlaying(false); setFrameIdx(v as number); }} min={0} max={Math.max(0, nFrames - 1)} size="small" sx={{ flex: 1, minWidth: 60, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {frameLabels && frameLabels.length > frameIdx ? frameLabels[frameIdx] : `${frameIdx + 1}/${nFrames}`} + + + fps + setFrameFps(v as number)} size="small" sx={{ ...sliderStyles.small, width: 35, flexShrink: 0 }} /> + {Math.round(frameFps)} + Loop + setFrameLoop(!frameLoop)} sx={{ ...switchStyles.small, flexShrink: 0 }} /> + Bounce + setFrameBoomerang(!frameBoomerang)} sx={{ ...switchStyles.small, flexShrink: 0 }} /> + + )} + + {/* Path animation slider */} + {showControls && pathLength > 0 && ( + + + setPathPlaying(!pathPlaying)} sx={{ color: themeColors.accent, p: 0.25 }}> + {pathPlaying ? : } + + { setPathPlaying(false); setPathIndex(0); }} sx={{ color: themeColors.textMuted, p: 0.25 }}> + + + + { setPathPlaying(false); setPathIndex(v as number); }} min={0} max={Math.max(0, pathLength - 1)} size="small" sx={{ flex: 1, minWidth: 60, "& .MuiSlider-thumb": { width: 10, height: 10 } }} /> + {pathIndex + 1}/{pathLength} + Loop: + { model.set("path_loop", v); model.save_changes(); }} size="small" sx={switchStyles.small} /> + + )} + + ); +} + +export const render = createRender(Show4DSTEM); diff --git a/widget/js/stats.ts b/widget/js/stats.ts new file mode 100644 index 00000000..36ce661c --- /dev/null +++ b/widget/js/stats.ts @@ -0,0 +1,121 @@ +/** Find min/max range of a Float32Array, filtering out NaN and Infinity. */ +export function findDataRange(data: Float32Array): { min: number; max: number } { + let min = Infinity, max = -Infinity; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (!isFinite(v)) continue; + if (v < min) min = v; + if (v > max) max = v; + } + // If no finite values found, return zeros + if (min === Infinity) return { min: 0, max: 0 }; + return { min, max }; +} + +/** Apply log1p scale: result[i] = log(1 + max(0, data[i])). Returns a new array. */ +export function applyLogScale(data: Float32Array): Float32Array { + const result = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + result[i] = Math.log1p(Math.max(0, data[i])); + } + return result; +} + +/** Apply log1p scale into a pre-allocated buffer. Avoids per-frame allocation. */ +export function applyLogScaleInPlace(data: Float32Array, out: Float32Array): Float32Array { + for (let i = 0; i < data.length; i++) { + out[i] = Math.log1p(Math.max(0, data[i])); + } + return out; +} + +/** Percentile-based clipping using O(n) histogram approach. + * Also returns data min/max so callers can skip a redundant findDataRange scan. */ +export function percentileClip( + data: Float32Array, pLow: number, pHigh: number, +): { vmin: number; vmax: number; min: number; max: number } { + const len = data.length; + if (len === 0) return { vmin: 0, vmax: 0, min: 0, max: 0 }; + + // Pass 1: find min/max + let min = Infinity, max = -Infinity; + for (let i = 0; i < len; i++) { + const v = data[i]; + if (v < min) min = v; + if (v > max) max = v; + } + if (min === max) return { vmin: min, vmax: max, min, max }; + + // Pass 2: build histogram + const NUM_BINS = 1024; + const bins = new Uint32Array(NUM_BINS); + const range = max - min; + const scale = (NUM_BINS - 1) / range; + for (let i = 0; i < len; i++) { + bins[Math.floor((data[i] - min) * scale)]++; + } + + // Walk cumulative histogram to find percentile values + const lowCount = Math.floor(len * (pLow / 100)); + const highCount = Math.ceil(len * (pHigh / 100)); + let cumSum = 0; + let vmin = min, vmax = max; + for (let i = 0; i < NUM_BINS; i++) { + cumSum += bins[i]; + if (cumSum >= lowCount) { vmin = min + (i / (NUM_BINS - 1)) * range; break; } + } + cumSum = 0; + for (let i = 0; i < NUM_BINS; i++) { + cumSum += bins[i]; + if (cumSum >= highCount) { vmax = min + (i / (NUM_BINS - 1)) * range; break; } + } + return { vmin, vmax, min, max }; +} + +/** Compute mean, min, max, and standard deviation of a Float32Array. */ +export function computeStats(data: Float32Array): { mean: number; min: number; max: number; std: number } { + if (data.length === 0) return { mean: 0, min: 0, max: 0, std: 0 }; + let sum = 0, min = Infinity, max = -Infinity; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + const mean = sum / data.length; + let variance = 0; + for (let i = 0; i < data.length; i++) variance += (data[i] - mean) ** 2; + const std = Math.sqrt(variance / data.length); + return { mean, min, max, std }; +} + +/** Convert histogram slider percentages (0-100) to vmin/vmax in data space. */ +export function sliderRange( + dataMin: number, dataMax: number, vminPct: number, vmaxPct: number, +): { vmin: number; vmax: number } { + const range = dataMax - dataMin; + return { + vmin: dataMin + (vminPct / 100) * range, + vmax: dataMin + (vmaxPct / 100) * range, + }; +} + +/** Compute normalized histogram bins from Float32Array. Returns array of 0-1 values. */ +export function computeHistogramFromBytes(data: Float32Array | null, numBins = 256): number[] { + if (!data || data.length === 0) return new Array(numBins).fill(0); + const bins = new Array(numBins).fill(0); + let min = Infinity, max = -Infinity; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } + } + if (!isFinite(min) || !isFinite(max) || min === max) return bins; + const range = max - min; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (isFinite(v)) bins[Math.min(numBins - 1, Math.floor(((v - min) / range) * numBins))]++; + } + const maxCount = Math.max(...bins); + if (maxCount > 0) for (let i = 0; i < numBins; i++) bins[i] /= maxCount; + return bins; +} diff --git a/widget/js/theme.ts b/widget/js/theme.ts new file mode 100644 index 00000000..f13123d5 --- /dev/null +++ b/widget/js/theme.ts @@ -0,0 +1,149 @@ +/** + * Shared theme detection and color system for all widgets. + * Detects JupyterLab, VS Code, Colab, Classic Jupyter, and OS preferences. + */ + +import { useState, useEffect, useMemo } from "react"; + +// ============================================================================ +// Types +// ============================================================================ +export type Environment = "jupyterlab" | "vscode" | "colab" | "jupyter-classic" | "unknown"; +export type Theme = "light" | "dark"; + +export interface ThemeInfo { + environment: Environment; + theme: Theme; +} + +export interface ThemeColors { + bg: string; + bgAlt: string; + text: string; + textMuted: string; + border: string; + controlBg: string; + accent: string; +} + +// ============================================================================ +// Color palettes +// ============================================================================ +export const DARK_COLORS: ThemeColors = { + bg: "#1e1e1e", + bgAlt: "#1a1a1a", + text: "#e0e0e0", + textMuted: "#888", + border: "#3a3a3a", + controlBg: "#252525", + accent: "#5af", +}; + +export const LIGHT_COLORS: ThemeColors = { + bg: "#ffffff", + bgAlt: "#f5f5f5", + text: "#1e1e1e", + textMuted: "#666", + border: "#ccc", + controlBg: "#f0f0f0", + accent: "#0066cc", +}; + +export function getThemeColors(theme: Theme): ThemeColors { + return theme === "dark" ? DARK_COLORS : LIGHT_COLORS; +} + +// ============================================================================ +// Theme detection +// ============================================================================ + +/** Check if a CSS color string is dark (luminance < 0.5) */ +export function isColorDark(color: string): boolean { + const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!match) return true; + const [, r, g, b] = match.map(Number); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance < 0.5; +} + +export function detectTheme(): ThemeInfo { + // 1. JupyterLab - has data-jp-theme-light attribute + const jpThemeLight = document.body.dataset.jpThemeLight; + if (jpThemeLight !== undefined) { + return { + environment: "jupyterlab", + theme: jpThemeLight === "true" ? "light" : "dark", + }; + } + + // 2. VS Code - has vscode-* classes on body or html + const bodyClasses = document.body.className; + const htmlClasses = document.documentElement.className; + if (bodyClasses.includes("vscode-") || htmlClasses.includes("vscode-")) { + const isDark = bodyClasses.includes("vscode-dark") || htmlClasses.includes("vscode-dark"); + return { + environment: "vscode", + theme: isDark ? "dark" : "light", + }; + } + + // 3. Google Colab - has specific markers + if (document.querySelector('colab-shaded-scroller') || document.body.classList.contains('colaboratory')) { + const bg = getComputedStyle(document.body).backgroundColor; + return { + environment: "colab", + theme: isColorDark(bg) ? "dark" : "light", + }; + } + + // 4. Classic Jupyter Notebook - has #notebook element + if (document.getElementById('notebook')) { + const bodyBg = getComputedStyle(document.body).backgroundColor; + return { + environment: "jupyter-classic", + theme: isColorDark(bodyBg) ? "dark" : "light", + }; + } + + // 5. Fallback: check OS preference, then computed background + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)')?.matches; + if (prefersDark !== undefined) { + return { + environment: "unknown", + theme: prefersDark ? "dark" : "light", + }; + } + + // Final fallback: check body background luminance + const bg = getComputedStyle(document.body).backgroundColor; + return { + environment: "unknown", + theme: isColorDark(bg) ? "dark" : "light", + }; +} + +// ============================================================================ +// React hook +// ============================================================================ +export function useTheme(): { themeInfo: ThemeInfo; colors: ThemeColors } { + const [themeInfo, setThemeInfo] = useState(() => detectTheme()); + + useEffect(() => { + const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); + const handleChange = () => setThemeInfo(detectTheme()); + mediaQuery?.addEventListener?.('change', handleChange); + + const observer = new MutationObserver(() => setThemeInfo(detectTheme())); + observer.observe(document.body, { attributes: true, attributeFilter: ['data-jp-theme-light', 'class'] }); + + return () => { + mediaQuery?.removeEventListener?.('change', handleChange); + observer.disconnect(); + }; + }, []); + + // Memoize by theme string so `colors` is referentially stable across renders — + // effects/components that depend on `colors` only re-run when the theme flips. + const colors = useMemo(() => getThemeColors(themeInfo.theme), [themeInfo.theme]); + return { themeInfo, colors }; +} diff --git a/widget/package-lock.json b/widget/package-lock.json index 4e039394..ff1510fd 100644 --- a/widget/package-lock.json +++ b/widget/package-lock.json @@ -6,22 +6,30 @@ "": { "name": "quantem-widget-frontend", "dependencies": { - "@anywidget/react": "^0.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@anywidget/react": "^0.2.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.6", + "jszip": "^3.10.1", + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { - "@anywidget/vite": "^0.2.0", - "@vitejs/plugin-react": "^4.3.0", - "vite": "^5.2.0" + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.4", + "@webgpu/types": "^0.1.68", + "esbuild": "^0.21.3", + "typescript": "^5.8.3" } }, "node_modules/@anywidget/react": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@anywidget/react/-/react-0.1.0.tgz", - "integrity": "sha512-Hh6wbMGXsgTz2xz1I9/h40M3b6uDWLjmh3sJ4tNjfppDl5y9Sw1jyuQGhNzwViOtszmyjYEJZ4kWHdPFUXUanw==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@anywidget/react/-/react-0.2.2.tgz", + "integrity": "sha512-MjrbbUimaX72ATL9IrrZeMZL+IHVzeW5lMlgZywDAMj4/zUVQ7MeBSaijq7V9genze7sM3Y0WnUyB+Mc7KnWWw==", + "license": "MIT", "dependencies": { - "@anywidget/types": "^0.2.0" + "@anywidget/types": "^0.4.0" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", @@ -31,28 +39,18 @@ } }, "node_modules/@anywidget/types": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@anywidget/types/-/types-0.2.0.tgz", - "integrity": "sha512-+XtK4uwxRd4JpuevUMhirrbvC0V4yCA/i0lEjhmSAtOaxiXIg/vBKzaSonDuoZ1a9LEjUXTW2+m7w+ULgsJYvg==" - }, - "node_modules/@anywidget/vite": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@anywidget/vite/-/vite-0.2.2.tgz", - "integrity": "sha512-6qYispivu+VAvWJbWSsZujGAQXLW5Xve/cNA/zrz+RXeg31xfJ5bH5ylqp6UC+GpFZL8azRN6HQS24Z0IgURLw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "vite": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@anywidget/types/-/types-0.4.0.tgz", + "integrity": "sha512-Qno/7V0lKHCMq3DJuSKKHMwilFKPSe8wFftL5xWmgaMCc938mNTtv+i19UrvDfpj9cQTnlPqyXy8t3JOgQ8laA==", + "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -60,57 +58,14 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -119,71 +74,24 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -192,7 +100,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -202,44 +109,18 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -248,66 +129,41 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -315,10 +171,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -328,6 +183,152 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -723,29 +724,16 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -755,435 +743,278 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.10.tgz", + "integrity": "sha512-vrOpWRmPJSuwLo23J62wggEm/jvGdzqctej+UOCtgDUz6nZJQuj3ByPccVyaa7eQmwAzUwKN56FQPMKkqbj1GA==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@mui/icons-material": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.10.tgz", + "integrity": "sha512-Au0ma4NSKGKNiimukj8UT/W1x2Qx6Qwn2RvFGykiSqVLYBNlIOPbjnIMvrwLGLu89EEpTVdu/ys/OduZR+tWqw==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.10", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@mui/material": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", + "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.10", + "@mui/system": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.10", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.10", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@mui/private-theming": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.10.tgz", + "integrity": "sha512-j3EZN+zOctxUISvJSmsEPo5o2F8zse4l5vRkBY+ps6UtnL6J7o14kUaI4w7gwo73id9e3cDNMVQK/9BVaMHVBw==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.10", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "node_modules/@mui/system": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.10.tgz", + "integrity": "sha512-/sfPpdpJaQn7BSF+avjIdHSYmxHp0UOBYNxSG9QGKfMOD6sLANCpRPCnanq1Pe0lFf0NHkO2iUk0TNzdWC1USQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.10", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.10", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0" + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, + "node_modules/@mui/utils": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.10.tgz", + "integrity": "sha512-7y2eIfy0h7JPz+Yy4pS+wgV68d46PuuxDqKBN4Q8VlPQSsCAGwroMCV6xWyc7g9dvEp8ZNFsknc59GHWO+r6Ow==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1193,105 +1024,87 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@types/react": "*" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } + "license": "BSD-3-Clause" }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=10", + "npm": ">=6" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1302,7 +1115,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1316,12 +1128,33 @@ } } }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, "node_modules/esbuild": { "version": "0.21.5", @@ -1362,41 +1195,115 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.4" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1407,7 +1314,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -1416,19 +1322,39 @@ "node": ">=6" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" + "dependencies": { + "immediate": "~3.0.5" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1441,294 +1367,271 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=6" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.5" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", - "dev": true, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "resolve": "bin/resolve" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">= 0.4" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" + "engines": { + "node": ">=4" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "safe-buffer": "~5.1.0" } }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } } } } diff --git a/widget/package.json b/widget/package.json index 4abc343d..8ceb653b 100644 --- a/widget/package.json +++ b/widget/package.json @@ -2,17 +2,25 @@ "name": "quantem-widget-frontend", "type": "module", "scripts": { - "dev": "vite build --watch", - "build": "vite build" + "dev": "npm run build -- --watch", + "build": "node scripts/build.mjs", + "typecheck": "tsc --noEmit" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "@anywidget/react": "^0.1.0" + "@anywidget/react": "^0.2.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.6", + "jszip": "^3.10.1", + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { - "vite": "^5.2.0", - "@anywidget/vite": "^0.2.0", - "@vitejs/plugin-react": "^4.3.0" + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.4", + "@webgpu/types": "^0.1.68", + "esbuild": "^0.21.3", + "typescript": "^5.8.3" } } diff --git a/widget/pyproject.toml b/widget/pyproject.toml index c0fb7a63..738c7bc9 100644 --- a/widget/pyproject.toml +++ b/widget/pyproject.toml @@ -10,6 +10,11 @@ license = "MIT" requires-python = ">=3.11" dependencies = [ "anywidget>=0.9.0", + "numpy>=2.0.0", + "traitlets>=5.0.0", + "torch>=2.0.0", + "matplotlib>=3.7.0", + "Pillow>=10.0.0", ] [tool.hatch.build.targets.wheel] diff --git a/widget/scripts/build.mjs b/widget/scripts/build.mjs new file mode 100644 index 00000000..7c8daea9 --- /dev/null +++ b/widget/scripts/build.mjs @@ -0,0 +1,46 @@ +// Bundle each widget as a self-contained ESM file. +// anywidget loads bundles via Blob URL; relative imports break in that context. +// esbuild flattens everything into one file per widget. + +import { build, context } from "esbuild"; +import { rmSync, copyFileSync, mkdirSync, existsSync } from "fs"; + +const watch = process.argv.includes("--watch"); +const widgets = [ + { name: "show2d" }, + { name: "show4dstem" }, +]; + +rmSync("src/quantem/widget/static", { recursive: true, force: true }); +mkdirSync("src/quantem/widget/static", { recursive: true }); + +const baseOpts = { + bundle: true, + format: "esm", + jsx: "automatic", + target: "es2022", + define: { "process.env.NODE_ENV": '"production"' }, + loader: { ".css": "text" }, + minify: true, + sourcemap: false, + legalComments: "none", +}; + +for (const w of widgets) { + const opts = { + ...baseOpts, + entryPoints: [`js/${w.name}/index.tsx`], + outfile: `src/quantem/widget/static/${w.name}.js`, + }; + if (watch) { + const ctx = await context(opts); + await ctx.watch(); + console.log(`watching ${w.name}...`); + } else { + const start = Date.now(); + await build(opts); + console.log(`built ${w.name}.js (${Date.now() - start}ms)`); + } +} + +if (!watch) console.log("done."); diff --git a/widget/src/quantem/widget/__init__.py b/widget/src/quantem/widget/__init__.py index d4ca85a7..96d8aebc 100644 --- a/widget/src/quantem/widget/__init__.py +++ b/widget/src/quantem/widget/__init__.py @@ -1,24 +1,12 @@ -from importlib.metadata import version -import pathlib -import anywidget -import traitlets +from importlib.metadata import PackageNotFoundError, version -__version__ = version("quantem.widget") +from quantem.widget.show2d import Show2D +from quantem.widget.show4dstem import Show4DSTEM -_static = pathlib.Path(__file__).parent / "static" +try: + __version__ = version("quantem.widget") +except PackageNotFoundError: + # Source-tree imports (e.g. `PYTHONPATH=src pytest`) skip pip install. + __version__ = "0.0.0+local" - -class CounterWidget(anywidget.AnyWidget): - _esm = _static / "index.js" - - count = traitlets.Int(0).tag(sync=True) - - -def show4dstem(): - # TODO: Implement 4D-STEM visualization widget - print("show4dstem: not yet implemented") - - -def counter(): - """Create a minimal counter widget for testing.""" - return CounterWidget() +__all__ = ["Show2D", "Show4DSTEM"] diff --git a/widget/src/quantem/widget/array_utils.py b/widget/src/quantem/widget/array_utils.py new file mode 100644 index 00000000..f4ff7592 --- /dev/null +++ b/widget/src/quantem/widget/array_utils.py @@ -0,0 +1,51 @@ +"""Array utilities for widgets. NumPy + PyTorch input.""" +import numpy as np + + +def to_numpy(data, dtype: np.dtype | None = None) -> np.ndarray: + """Convert NumPy / PyTorch / Dataset to NumPy.""" + try: + import torch + is_tensor = isinstance(data, torch.Tensor) + except ImportError: + is_tensor = False + if is_tensor: + result = data.detach().cpu().numpy() + elif isinstance(data, np.ndarray): + result = data + else: + # Last-resort fallback covers Dataset.__array__, dlpack-compatible objects, etc. + try: + result = np.asarray(data) + except Exception as e: + raise TypeError( + f"to_numpy expected a NumPy array or PyTorch tensor, got {type(data).__name__}." + ) from e + if dtype is not None: + result = np.asarray(result, dtype=dtype) + return result + + +def _resize_image(img: np.ndarray, target_h: int, target_w: int) -> np.ndarray: + """Center-pad image to (target_h, target_w) with zeros. For gallery alignment.""" + h, w = img.shape[-2:] + if h == target_h and w == target_w: + return img + pad_top = (target_h - h) // 2 + pad_bot = target_h - h - pad_top + pad_left = (target_w - w) // 2 + pad_right = target_w - w - pad_left + return np.pad(img, ((pad_top, pad_bot), (pad_left, pad_right)), mode="constant", constant_values=0) + + +def bin2d(img: np.ndarray, factor: int, mode: str = "mean") -> np.ndarray: + """Reduce 2D image by integer binning factor. mean or sum of f×f blocks.""" + if factor <= 1: + return img + h, w = img.shape[-2:] + h2, w2 = h - h % factor, w - w % factor + img = img[..., :h2, :w2] + blocks = img.reshape(*img.shape[:-2], h2 // factor, factor, w2 // factor, factor) + if mode == "sum": + return blocks.sum(axis=(-3, -1)) + return blocks.mean(axis=(-3, -1)) diff --git a/widget/src/quantem/widget/show2d.py b/widget/src/quantem/widget/show2d.py new file mode 100644 index 00000000..e243a51d --- /dev/null +++ b/widget/src/quantem/widget/show2d.py @@ -0,0 +1,1174 @@ +""" +show2d: Static 2D image viewer with optional FFT and histogram analysis. + +For displaying a single image or a static gallery of multiple images. +Unlike Show3D (interactive), Show2D focuses on static visualization. +""" + +import base64 +import io +import json +import math +import os +import pathlib +import warnings +from enum import StrEnum +from typing import Self + +import anywidget +import matplotlib +import matplotlib.patheffects +import matplotlib.pyplot as plt +import numpy as np +import traitlets +from quantem.widget.array_utils import _resize_image, to_numpy +from quantem.widget.state import resolve_widget_version, save_state_file, unwrap_state_payload + +from quantem.core.datastructures import Dataset2d, Dataset3d + + +def _reject_unknown_kwargs(cls, kwargs: dict) -> None: + """Raise TypeError if kwargs contains any key that isn't a declared trait. + + anywidget/traitlets silently accept unknown keys, which let stale notebooks + pass obsolete params like ``pixel_size_angstrom=0.5`` with no warning. This + helper catches typos and renamed-trait references at construction time. + """ + traits = set(cls.class_trait_names()) + unknown = [k for k in kwargs if k not in traits] + if unknown: + key = sorted(unknown)[0] + raise TypeError( + f"{cls.__name__}() got unexpected keyword argument {key!r}. " + f"Check for typos or a renamed parameter (e.g. canvas_size → size, " + f"image_width_px → size, pixel_size_angstrom → pixel_size)." + ) + + +def _round_to_nice(value: float) -> float: + """Round a physical length to a 'nice' value (1, 2, 5, 10, 20, 50, ...).""" + if value <= 0: + return 1.0 + exp = math.floor(math.log10(value)) + base = 10 ** exp + mantissa = value / base + if mantissa < 1.5: + return base + elif mantissa < 3.5: + return 2 * base + elif mantissa < 7.5: + return 5 * base + else: + return 10 * base + + +class Colormap(StrEnum): + INFERNO = "inferno" + VIRIDIS = "viridis" + MAGMA = "magma" + PLASMA = "plasma" + GRAY = "gray" + + +class Show2D(anywidget.AnyWidget): + """ + Static 2D image viewer with optional FFT and histogram analysis. + + Display a single image or multiple images in a gallery layout. + For interactive stack viewing with playback, use Show3D instead. + + Parameters + ---------- + data : array_like + 2D array (height, width) for single image, or + 3D array (N, height, width) for multiple images displayed as gallery. + labels : list of str, optional + Labels for each image in gallery mode. + title : str, optional + Title to display above the image(s). + cmap : str, default "inferno" + Colormap name ("magma", "viridis", "gray", "inferno", "plasma"). + sampling : float or tuple of float, optional + Pixel size per axis ``(row, col)``. Scalar broadcasts to both axes. + Used for scale bar display. Defaults to ``(1, 1)``. + units : str or list of str, optional + Unit string per axis. Scalar broadcasts to both. Common: ``"A"``, + ``"nm"``, ``"pixels"``. Defaults to ``["pixels", "pixels"]``. + show_fft : bool, default False + Show FFT and histogram panels. + show_stats : bool, default True + Show statistics (mean, min, max, std). + log_scale : bool, default False + Use log scale for intensity mapping. + auto_contrast : bool, default False + Use percentile-based contrast. + vmin : float, optional + Absolute minimum intensity for color mapping. When both vmin and vmax + are set, all gallery images share the same intensity scale: essential + for A/B visual comparison. + vmax : float, optional + Absolute maximum intensity for color mapping. + ncols : int, default 3 + Number of columns in gallery mode. + size : int, default 0 + Canvas rendering size in CSS pixels (the on-screen width of each image). + ``0`` uses the frontend default: 500 px for a single image, 300 px per + image in gallery mode. Pass e.g. ``size=800`` to enlarge for a + presentation, or ``size=200`` to compress alongside a control panel. + This controls **display only**: the underlying image resolution is + never resampled; zooming into a 4K image preserves every pixel. + Attributes + ---------- + render_total_ms : int or None + End-to-end wall clock from constructor start to first browser paint, + populated by a JS→Python round-trip after the first canvas render. + ``None`` until the browser has actually painted; also printed to stdout + when it fires. Use to triage "is it Python, wire, or the browser?" + during live acquisitions. + render_python_build_ms : int or None + Subset of ``render_total_ms`` covering Python ``__init__`` only. + render_wire_js_ms : int or None + Subset covering everything after Python returns: Comm transfer, JS + decode, colormap, and canvas paint. + + Examples + -------- + >>> import numpy as np + >>> from quantem.widget import Show2D + + Single 2D NumPy array: + + >>> Show2D(np.random.rand(512, 512)) + + PyTorch tensor (CPU or GPU, any dtype): + + >>> import torch + >>> Show2D(torch.rand(512, 512)) + + 3D NumPy stack ``(N, H, W)`` rendered as a gallery: + + >>> Show2D(np.random.rand(6, 256, 256), ncols=3) + + List of arrays with different shapes (center-padded to a common canvas): + + >>> Show2D([np.random.rand(256, 256), np.random.rand(300, 400)]) + + quantem ``Dataset2d``: title, sampling, units auto-extracted: + + >>> from quantem.core.datastructures import Dataset2d + >>> ds = Dataset2d.from_array(np.random.rand(512, 512)) + >>> Show2D(ds) + + quantem ``Dataset3d``: gallery view of N frames with calibration: + + >>> from quantem.core.datastructures import Dataset3d + >>> ds = Dataset3d.from_array(np.random.rand(6, 256, 256)) + >>> Show2D(ds, ncols=3) + + A/B comparison with shared contrast and linked zoom/pan: + + >>> a, b = np.random.rand(512, 512), np.random.rand(512, 512) + >>> Show2D([a, b], vmin=0, vmax=1, link_zoom=True, link_pan=True) + + Per-image absolute contrast (one ``vmin``/``vmax`` per image): + + >>> Show2D([a, b], vmin=[0.0, 0.2], vmax=[1.0, 0.8]) + + Drift comparison: diff mode adds a ``A - B`` panel alongside the originals + (gallery becomes ``[A, B, A - B]``): + + >>> Show2D([a, b], diff_mode=True, link_zoom=True, link_pan=True) + + Large image: display-only canvas size (full resolution preserved): + + >>> Show2D(np.random.rand(4096, 4096), size=800) + + Static export to PDF or PNG (vector PDF for publication figures): + + >>> w = Show2D(np.random.rand(512, 512), sampling=0.5, units="nm") + >>> w.save_image("figure.pdf", dpi=150) + """ + + _esm = pathlib.Path(__file__).parent / "static" / "show2d.js" + + # ========================================================================= + # Core State + # GPU memory budget for display buffers (MB). Each 4K image needs ~192 MB. + # 12×4K = 2304 MB fits. 16+ triggers auto-bin. + _GPU_DISPLAY_BUDGET_MB = 2500 + + # ========================================================================= + widget_version = traitlets.Unicode("unknown").tag(sync=True) + n_images = traitlets.Int(1).tag(sync=True) + height = traitlets.Int(1).tag(sync=True) + width = traitlets.Int(1).tag(sync=True) + _display_bin_factor = traitlets.Int(1).tag(sync=True) # 1 = full-res, 2/4/8 = binned + _gpu_max_buffer_mb = traitlets.Int(0).tag(sync=True) # GPU reports maxBufferSize (JS→Python) + # Flipped True by JS after the first colormap pass has painted to canvas. + # Used by the Python-side truthful timing print (end-to-end wall clock, not just __init__). + _js_rendered = traitlets.Bool(False).tag(sync=True) + frame_bytes = traitlets.Bytes(b"").tag(sync=True) + labels = traitlets.List(traitlets.Unicode()).tag(sync=True) + title = traitlets.Unicode("").tag(sync=True) + cmap = traitlets.Unicode("inferno").tag(sync=True) + ncols = traitlets.Int(3).tag(sync=True) + + # ========================================================================= + # Display Options + # ========================================================================= + log_scale = traitlets.Bool(False).tag(sync=True) + auto_contrast = traitlets.Bool(False).tag(sync=True) + vmin = traitlets.Float(None, allow_none=True).tag(sync=True) + vmax = traitlets.Float(None, allow_none=True).tag(sync=True) + vmins = traitlets.List(trait=traitlets.Float(allow_none=True), allow_none=True, default_value=None).tag(sync=True) + vmaxs = traitlets.List(trait=traitlets.Float(allow_none=True), allow_none=True, default_value=None).tag(sync=True) + + # ========================================================================= + # Scale Bar + # ========================================================================= + pixel_size = traitlets.Float(0.0).tag(sync=True) + pixel_unit = traitlets.Unicode("pixels").tag(sync=True) + scale_bar_visible = traitlets.Bool(True).tag(sync=True) + size = traitlets.Int(0).tag(sync=True) # Canvas rendering size in CSS pixels; 0 = frontend default + smooth = traitlets.Bool(False).tag(sync=True) + initial_zoom = traitlets.Float(1.0).tag(sync=True) + zoom_row = traitlets.Float(None, allow_none=True).tag(sync=True) + zoom_col = traitlets.Float(None, allow_none=True).tag(sync=True) + link_zoom = traitlets.Bool(False).tag(sync=True) + link_pan = traitlets.Bool(False).tag(sync=True) + link_contrast = traitlets.Bool(True).tag(sync=True) + diff_mode = traitlets.Bool(False).tag(sync=True) + diff_reference = traitlets.Int(0).tag(sync=True) + + # ========================================================================= + # UI Visibility + # ========================================================================= + show_controls = traitlets.Bool(True).tag(sync=True) + show_stats = traitlets.Bool(True).tag(sync=True) + stats_mean = traitlets.List(traitlets.Float()).tag(sync=True) + stats_min = traitlets.List(traitlets.Float()).tag(sync=True) + stats_max = traitlets.List(traitlets.Float()).tag(sync=True) + stats_std = traitlets.List(traitlets.Float()).tag(sync=True) + + # ========================================================================= + # Analysis Panels (FFT + Histogram shown together) + # ========================================================================= + show_fft = traitlets.Bool(False).tag(sync=True) + fft_window = traitlets.Bool(True).tag(sync=True) + + # ========================================================================= + # Selected Image (for single-image analysis display) + # ========================================================================= + selected_idx = traitlets.Int(0).tag(sync=True) + + # ========================================================================= + # ROI Selection + # ========================================================================= + roi_active = traitlets.Bool(False).tag(sync=True) + roi_list = traitlets.List([]).tag(sync=True) + roi_selected_idx = traitlets.Int(-1).tag(sync=True) + + # ========================================================================= + # Line Profile + # ========================================================================= + profile_line = traitlets.List(traitlets.Dict()).tag(sync=True) + + # ========================================================================= + # Per-Image Rotation + # ========================================================================= + image_rotations = traitlets.List(traitlets.Int(), []).tag(sync=True) + + def __init__( + self, + data: np.ndarray | list[np.ndarray], + labels: list[str | None] = None, + title: str = "", + cmap: str | Colormap = Colormap.INFERNO, + sampling: float | tuple[float, float] | list[float] | None = None, + units: str | list[str] | None = None, + scale_bar_visible: bool = True, + show_fft: bool = False, + fft_window: bool = True, + show_controls: bool = True, + show_stats: bool = True, + verbose: bool = True, + log_scale: bool = False, + auto_contrast: bool = False, + vmin: float | list | None = None, + vmax: float | list | None = None, + ncols: int = 3, + size: int = 0, + smooth: bool = False, + zoom: float = 1.0, + zoom_row: float | None = None, + zoom_col: float | None = None, + link_zoom: bool | None = None, + link_pan: bool | None = None, + link_contrast: bool = True, + diff_mode: bool = False, + view_box: tuple | list | None = None, + display_bin: int | str = "auto", + state=None, + **kwargs, + ): + import time as _time + _t0 = _time.perf_counter() + # Reject typos and stale kwargs (e.g. image_width_px, pixel_size_angstrom). + # anywidget/traitlets silently ignores unknown keys, which hid the + # pixel_size_angstrom bug in show2d_all_features.ipynb for months. + _reject_unknown_kwargs(type(self), kwargs) + super().__init__(**kwargs) + # hold_sync() batches ALL traitlet assignments into a single comm message + # sent when the context manager exits. Without this, each self.x = y + # fires a separate round-trip over the ZMQ/websocket channel, which + # can add 20+ seconds for a 30-image gallery in VS Code Jupyter. + with self.hold_sync(): + self._init_sync( + data=data, labels=labels, title=title, cmap=cmap, + sampling=sampling, units=units, scale_bar_visible=scale_bar_visible, + show_fft=show_fft, fft_window=fft_window, + show_controls=show_controls, show_stats=show_stats, + log_scale=log_scale, auto_contrast=auto_contrast, + vmin=vmin, vmax=vmax, + ncols=ncols, size=size, smooth=smooth, zoom=zoom, + zoom_row=zoom_row, zoom_col=zoom_col, + link_zoom=link_zoom, link_pan=link_pan, link_contrast=link_contrast, + diff_mode=diff_mode, view_box=view_box, + display_bin=display_bin, verbose=verbose, state=state, _t0=_t0) + + def _init_sync(self, *, data, labels, title, cmap, sampling, units, + scale_bar_visible, show_fft, fft_window, + show_controls, show_stats, log_scale, auto_contrast, + vmin, vmax, + ncols, size, smooth, zoom, zoom_row, zoom_col, + link_zoom, link_pan, link_contrast, diff_mode, view_box, + display_bin, verbose, state, _t0): + import time as _time + self._verbose = verbose + self.widget_version = resolve_widget_version() + self._display_data = None # initialized after data setup + self._display_bin = 1 + + # First-class support for quantem Dataset2d / Dataset3d: + # auto-extract array + sampling + units from the dataset object. + if isinstance(data, (Dataset2d, Dataset3d)) or ( + hasattr(data, "array") and hasattr(data, "name") and hasattr(data, "sampling") + ): + if not title and data.name: + title = data.name + if sampling is None: + sampling = tuple(float(s) for s in data.sampling[-2:]) + if units is None and hasattr(data, "units"): + units = list(data.units[-2:]) + data = data.array + # Same auto-extract for list/tuple of Dataset2d (gallery from per-file load). + elif isinstance(data, (list, tuple)) and len(data) > 0 and ( + isinstance(data[0], (Dataset2d, Dataset3d)) or + (hasattr(data[0], "array") and hasattr(data[0], "sampling")) + ): + first = data[0] + if sampling is None: + sampling = tuple(float(s) for s in first.sampling[-2:]) + if units is None and hasattr(first, "units"): + units = list(first.units[-2:]) + data = [d.array for d in data] + + # Convert NumPy / PyTorch / list inputs to a NumPy array. + if isinstance(data, list): + images = [to_numpy(d) for d in data] + + # Check if all images have the same shape + shapes = [img.shape for img in images] + if len(set(shapes)) > 1: + # Different sizes - resize all to the largest + max_h = max(s[0] for s in shapes) + max_w = max(s[1] for s in shapes) + images = [_resize_image(img, max_h, max_w) for img in images] + + data = np.stack(images) + else: + data = to_numpy(data) + + # Ensure 3D shape (N, H, W) + if data.ndim == 2: + data = data[np.newaxis, ...] + + # Avoid redundant copy: np.asarray is a no-op when already float32 + contiguous + if data.dtype == np.float32: + self._data = np.array(data, dtype=np.float32, copy=True) + else: + self._data = np.asarray(data, dtype=np.float32) + # Store originals for rotation reset: views into _data (no copy). + # Only materialized as independent copies when a rotation is applied. + self._data_original = [self._data[i] for i in range(self._data.shape[0])] + self._originals_are_views = True + self.n_images = int(data.shape[0]) + self.height = int(data.shape[1]) + self.width = int(data.shape[2]) + self.image_rotations = [0] * self.n_images + + # Labels + if labels is None: + self.labels = [f"Image {i+1}" for i in range(self.n_images)] + else: + self.labels = list(labels) + + # Options + self.title = title + self.cmap = cmap + # Resolve sampling + units to scalar pixel_size + pixel_unit (column axis). + # Scalar shorthand: sampling=0.5 → (0.5, 0.5). units="nm" → ["nm", "nm"]. + if sampling is None: + self.pixel_size = 0.0 + elif isinstance(sampling, (int, float)): + self.pixel_size = float(sampling) + else: + self.pixel_size = float(sampling[-1]) + if units is None: + self.pixel_unit = "pixels" + elif isinstance(units, str): + self.pixel_unit = units + else: + self.pixel_unit = str(units[-1]) + self.scale_bar_visible = scale_bar_visible + self.size = size + self.smooth = smooth + # view_box sugar: sets zoom + zoom_row/col to center on box + if view_box is not None: + r0, r1, c0, c1 = [float(v) for v in view_box] + box_h = max(1.0, r1 - r0) + box_w = max(1.0, c1 - c0) + zoom = float(min(self.height / box_h, self.width / box_w)) + zoom_row = (r0 + r1) / 2 + zoom_col = (c0 + c1) / 2 + self.initial_zoom = zoom + self.zoom_row = zoom_row + self.zoom_col = zoom_col + # Auto-link zoom + pan in gallery (n_images >= 2) so dragging one panel + # follows the other — typical compare/diff workflow. Single image: no-op. + self.link_zoom = (self.n_images >= 2) if link_zoom is None else link_zoom + self.link_pan = (self.n_images >= 2) if link_pan is None else link_pan + self.link_contrast = link_contrast + self.diff_mode = diff_mode if self.n_images >= 2 else False + if show_fft and self.height * self.width > 2048 * 2048: + warnings.warn( + f"FFT on {self.height}×{self.width} image ({self.height * self.width / 1e6:.1f}M pixels) " + f"may be slow. Consider using ROI FFT for a sub-region.", + stacklevel=2, + ) + self.show_fft = show_fft + self.fft_window = fft_window + self.show_controls = show_controls + self.show_stats = show_stats + self.log_scale = log_scale + self.auto_contrast = auto_contrast + # Accept scalar OR list for vmin/vmax. List → per-image (vmins/vmaxs). + if isinstance(vmin, (list, tuple)) or isinstance(vmax, (list, tuple)): + n = self.n_images + def _expand(v): + if v is None: + return [None] * n + if isinstance(v, (list, tuple)): + if len(v) != n: + raise ValueError(f"vmin/vmax list has length {len(v)} but n_images is {n}. Pass a list of length {n} or a scalar to apply uniformly.") + return [None if x is None else float(x) for x in v] + return [float(v)] * n + self.vmins = _expand(vmin) + self.vmaxs = _expand(vmax) + self.vmin = None + self.vmax = None + else: + self.vmin = vmin + self.vmax = vmax + self.ncols = ncols + + # Auto-bin for display: keep full-res in _data, send binned to JS. + # GPU memory budget: ~2 GB for display buffers (128 MB per image at 4K). + # At 4K: max ~16 full-res. Beyond that, auto-downsample. + if display_bin == "auto": + # Each 4K image needs ~192 MB GPU buffers (float32 + RGBA + read) + # Tested: 12×4K (2.3 GB) works, 24×4K (4.6 GB) OOMs + # Budget: 2.5 GB allows 12×4K full-res, bins above that + gpu_budget_mb = self._GPU_DISPLAY_BUDGET_MB + per_image_mb = (self.height * self.width * 4 * 3) / (1024 * 1024) # 3 buffers + total_mb = self.n_images * per_image_mb + if total_mb > gpu_budget_mb: + # Find minimum bin factor to fit + for bf in [2, 4, 8]: + binned_mb = self.n_images * per_image_mb / (bf * bf) + if binned_mb <= gpu_budget_mb: + self._display_bin = bf + break + else: + self._display_bin = 8 + elif isinstance(display_bin, int) and display_bin > 1: + self._display_bin = display_bin + + if self._display_bin > 1: + from quantem.widget.array_utils import bin2d + orig_h, orig_w = self._data.shape[1], self._data.shape[2] + self._display_data = bin2d(self._data, factor=self._display_bin, mode="mean") + self.height = int(self._display_data.shape[1]) + self.width = int(self._display_data.shape[2]) + if self.pixel_size > 0: + self.pixel_size = self.pixel_size * self._display_bin + self._display_bin_factor = self._display_bin + if verbose: + print(f" Display bin {self._display_bin}×: {orig_h}×{orig_w} → {self.height}×{self.width} ({self._display_data.nbytes // 1024 // 1024} MB)") + else: + self._display_data = self._data + self._display_bin_factor = 1 + + # Compute initial stats (from full-res data) + self._compute_all_stats() + + # Send display data to JS (possibly binned) + self._update_all_frames() + + self.selected_idx = 0 + + if state is not None: + if isinstance(state, (str, pathlib.Path)): + state = unwrap_state_payload( + json.loads(pathlib.Path(state).read_text()), + require_envelope=True, + ) + else: + state = unwrap_state_payload(state) + self.load_state_dict(state) + + # Stash wall-clock start on the instance; the observer below prints the + # TRUE end-to-end time after JS signals first paint. The Python-only + # __init__ number is misleading for widget UX: a widget is not "done" + # until the browser has painted its first frame. + self._init_t0 = _t0 + self._init_py_elapsed_ms = (_time.perf_counter() - _t0) * 1000 + self.observe(self._on_first_render, names=["_js_rendered"]) + + def _on_first_render(self, change): + import time as _time + if not change.get("new"): + return + total_ms = (_time.perf_counter() - self._init_t0) * 1000 + py_ms = self._init_py_elapsed_ms + shape = (f"{self.n_images}×{self.height}×{self.width}" + if self.n_images > 1 else f"{self.height}×{self.width}") + mem = self._data.nbytes + mem_str = f"{mem / (1 << 20):.0f} MB" if mem >= 1 << 20 else f"{mem / (1 << 10):.0f} KB" + # Expose as attributes so tests and notebooks can assert on them. + # These are the ground truth for "did JS actually paint": if they're + # None, the JS side never signaled first render. + self.render_total_ms = int(total_ms) + self.render_python_build_ms = int(py_ms) + self.render_wire_js_ms = int(total_ms - py_ms) + if not getattr(self, "_verbose", True): + return + print( + f"Show2D: {shape} {mem_str}: " + f"rendered in {total_ms:.0f} ms (Python build {py_ms:.0f} ms, " + f"wire+JS {total_ms - py_ms:.0f} ms)", + flush=True, + ) + # Detach observer: one-shot, we only care about the first paint. + try: + self.unobserve(self._on_first_render, names=["_js_rendered"]) + except (ValueError, KeyError): + pass + + def __repr__(self) -> str: + if self.n_images > 1: + shape = f"{self.n_images}×{self.height}×{self.width}" + return f"Show2D({shape}, idx={self.selected_idx}, cmap={self.cmap})" + return f"Show2D({self.height}×{self.width}, cmap={self.cmap})" + + def _repr_mimebundle_(self, **kwargs): + """Return widget view + (optionally) static PNG fallback. + + Live Jupyter renders the interactive widget; the PNG fallback is only + consumed by nbsphinx / GitHub / nbviewer when the widget view cannot be + rendered. Building the fallback runs matplotlib over every gallery image + (~1.7 s for a 30×512² stack) and that cost pays off only in static builds. + Gate it behind ``QUANTEM_WIDGET_STATIC_FALLBACK=1`` so interactive sessions + return immediately. + """ + bundle = super()._repr_mimebundle_(**kwargs) + if not os.environ.get("QUANTEM_WIDGET_STATIC_FALLBACK"): + return bundle + data_dict = bundle[0] if isinstance(bundle, tuple) else bundle + n = self.n_images + ncols = min(self.ncols, n) + nrows = math.ceil(n / ncols) + cell = 4 + fig, axes = plt.subplots( + nrows, ncols, + figsize=(cell * ncols, cell * nrows), + squeeze=False, + ) + max_preview = 256 + for i in range(nrows * ncols): + r, c = divmod(i, ncols) + ax = axes[r][c] + if i < n: + img = self._data[i] + h, w = img.shape + if h > max_preview or w > max_preview: + step = max(h // max_preview, w // max_preview, 1) + img = img[::step, ::step] + ax.imshow(img, cmap=self.cmap, origin="upper") + ax.set_title(self.labels[i], fontsize=10) + ax.axis("off") + if self.title: + fig.suptitle(self.title, fontsize=12) + fig.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=120, bbox_inches="tight") + plt.close(fig) + data_dict["image/png"] = base64.b64encode(buf.getvalue()).decode("ascii") + if isinstance(bundle, tuple): + return (data_dict, bundle[1]) + return data_dict + + def _normalize_frame(self, frame: np.ndarray) -> np.ndarray: + if self.log_scale: + frame = np.log1p(np.maximum(frame, 0)) + if self.vmin is not None and self.vmax is not None: + vmin = float(self.vmin) + vmax = float(self.vmax) + if self.log_scale: + vmin = float(np.log1p(max(vmin, 0))) + vmax = float(np.log1p(max(vmax, 0))) + elif self.auto_contrast: + vmin = float(np.percentile(frame, 2)) + vmax = float(np.percentile(frame, 98)) + else: + vmin = float(frame.min()) + vmax = float(frame.max()) + if vmax > vmin: + normalized = np.clip((frame - vmin) / (vmax - vmin) * 255, 0, 255) + return normalized.astype(np.uint8) + return np.zeros(frame.shape, dtype=np.uint8) + + def save_image( + self, + path: str | pathlib.Path, + *, + idx: int | None = None, + format: str | None = None, + dpi: int = 150, + title: bool | str = False, + colorbar: bool = False, + scalebar: bool = False, + ) -> pathlib.Path: + """Save current image as PNG, PDF, or TIFF. + + When ``title``, ``colorbar``, or ``scalebar`` are enabled, the output + is a publication-quality figure rendered via matplotlib. Otherwise a + raw colormapped image is saved directly (faster, exact pixel output). + + Parameters + ---------- + path : str or pathlib.Path + Output file path. + idx : int, optional + Image index in gallery mode. Defaults to current selected_idx. + format : str, optional + 'png', 'pdf', or 'tiff'. If omitted, inferred from file extension. + dpi : int, default 150 + Output DPI. + title : bool or str, default False + ``True`` uses the widget title, a string sets a custom title. + colorbar : bool, default False + Include a colorbar showing the intensity mapping. + scalebar : bool, default False + Include a scale bar (requires ``pixel_size > 0``). + + Returns + ------- + pathlib.Path + The written file path. + """ + from matplotlib import colormaps + from PIL import Image + + path = pathlib.Path(path) + fmt = (format or path.suffix.lstrip(".").lower() or "png").lower() + if fmt not in ("png", "pdf", "tiff", "tif"): + raise ValueError(f"Unsupported format: {fmt!r}. Use 'png', 'pdf', or 'tiff'.") + + i = idx if idx is not None else self.selected_idx + if i < 0 or i >= self.n_images: + raise IndexError(f"Image index {i} out of range [0, {self.n_images})") + + frame = self._data[i] + normalized = self._normalize_frame(frame) + cmap_fn = colormaps.get_cmap(self.cmap) + path.parent.mkdir(parents=True, exist_ok=True) + + use_figure = title or colorbar or scalebar + if not use_figure: + rgba = (cmap_fn(normalized / 255.0) * 255).astype(np.uint8) + img = Image.fromarray(rgba) + if fmt == "pdf": + Image.init() + img = img.convert("RGB") + img.save(str(path), dpi=(dpi, dpi)) + return path + + # Publication-quality figure via matplotlib + h, w = frame.shape + aspect = h / w + fig_w = 6 + fig, ax = plt.subplots(figsize=(fig_w, fig_w * aspect)) + im = ax.imshow(normalized, cmap=cmap_fn, vmin=0, vmax=255, origin="upper") + ax.axis("off") + + if title: + label = title if isinstance(title, str) else self.title + if label: + ax.set_title(label, fontsize=14, fontweight="bold", pad=8) + + if colorbar: + # Map 0–255 back to data-space values for tick labels + if self.log_scale: + frame_proc = np.log1p(np.maximum(frame, 0)) + else: + frame_proc = frame + if self.vmin is not None and self.vmax is not None: + dmin = float(self.vmin) + dmax = float(self.vmax) + if self.log_scale: + dmin = float(np.log1p(max(dmin, 0))) + dmax = float(np.log1p(max(dmax, 0))) + elif self.auto_contrast: + dmin = float(np.percentile(frame_proc, 2)) + dmax = float(np.percentile(frame_proc, 98)) + else: + dmin = float(frame_proc.min()) + dmax = float(frame_proc.max()) + cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + n_ticks = 5 + tick_positions = np.linspace(0, 255, n_ticks) + tick_labels = [f"{dmin + (dmax - dmin) * t / 255:.4g}" for t in tick_positions] + cb.set_ticks(tick_positions) + cb.set_ticklabels(tick_labels) + + if scalebar and self.pixel_size > 0: + # Compute a nice scale bar length + target_frac = 0.2 # ~20% of image width + raw_length_px = target_frac * w + raw_length_phys = raw_length_px * self.pixel_size # in Å + nice = _round_to_nice(raw_length_phys) + bar_px = nice / self.pixel_size + if nice >= 10: + label_text = f"{nice / 10:.4g} nm" + else: + label_text = f"{nice:.4g} Å" + margin = 0.03 + bar_y = h * (1 - margin) - 2 + bar_x = w * (1 - margin) - bar_px + ax.plot([bar_x, bar_x + bar_px], [bar_y, bar_y], + color="white", linewidth=3, solid_capstyle="butt") + ax.plot([bar_x, bar_x + bar_px], [bar_y, bar_y], + color="black", linewidth=1, solid_capstyle="butt") + ax.text(bar_x + bar_px / 2, bar_y - h * 0.02, label_text, + color="white", fontsize=10, fontweight="bold", + ha="center", va="bottom", + path_effects=[ + matplotlib.patheffects.withStroke(linewidth=2, foreground="black") + ]) + + fig.savefig(str(path), dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.1) + plt.close(fig) + return path + + def state_dict(self): + return { + "title": self.title, + "cmap": self.cmap, + "log_scale": self.log_scale, + "auto_contrast": self.auto_contrast, + "vmin": self.vmin, + "vmax": self.vmax, + "show_stats": self.show_stats, + "show_fft": self.show_fft, + "fft_window": self.fft_window, + "show_controls": self.show_controls, + "pixel_size": self.pixel_size, + "pixel_unit": self.pixel_unit, + "scale_bar_visible": self.scale_bar_visible, + "size": self.size, + "smooth": self.smooth, + "initial_zoom": self.initial_zoom, + "vmins": self.vmins, + "vmaxs": self.vmaxs, + "link_zoom": self.link_zoom, + "link_pan": self.link_pan, + "link_contrast": self.link_contrast, + "zoom_row": self.zoom_row, + "zoom_col": self.zoom_col, + "diff_mode": self.diff_mode, + "ncols": self.ncols, + "selected_idx": self.selected_idx, + "roi_active": self.roi_active, + "roi_list": self.roi_list, + "roi_selected_idx": self.roi_selected_idx, + "profile_line": self.profile_line, + "image_rotations": list(self.image_rotations), + "display_bin": self._display_bin, + } + + def save(self, path: str): + save_state_file(path, "Show2D", self.state_dict()) + + def load_state_dict(self, state): + for key, val in state.items(): + # Silent migrations for renamed keys in older saved state files. + if key == "pixel_size_angstrom": + key = "pixel_size" + elif key == "canvas_size": + key = "size" + if key == "display_bin": + self._display_bin = val + continue + if hasattr(self, key): + setattr(self, key, val) + + def summary(self): + """Print a human-readable snapshot of the widget's current state. + + Reports image dimensions and pixel size, data min/max/mean, display + settings (colormap, contrast, scale, FFT), active ROIs and profile + line, per-image rotations, and the most recent render timings. + """ + lines = [self.title or "Show2D", "═" * 32] + if self.n_images > 1: + lines.append(f"Image: {self.n_images}×{self.height}×{self.width} ({self.ncols} cols)") + else: + lines.append(f"Image: {self.height}×{self.width}") + if self.pixel_size > 0: + ps = self.pixel_size + if ps >= 10: + lines[-1] += f" ({ps / 10:.2f} nm/px)" + else: + lines[-1] += f" ({ps:.2f} Å/px)" + if hasattr(self, "_data") and self._data is not None: + arr = self._data + lines.append(f"Data: min={float(arr.min()):.4g} max={float(arr.max()):.4g} mean={float(arr.mean()):.4g}") + cmap = self.cmap + scale = "log" if self.log_scale else "linear" + if self.vmin is not None and self.vmax is not None: + contrast = f"vmin={self.vmin:.4g}, vmax={self.vmax:.4g}" + elif self.auto_contrast: + contrast = "auto contrast" + else: + contrast = "manual contrast" + display = f"{cmap} | {contrast} | {scale}" + if self.show_fft: + display += " | FFT" + if not self.fft_window: + display += " (no window)" + lines.append(f"Display: {display}") + if self.roi_active and self.roi_list: + lines.append(f"ROI: {len(self.roi_list)} region(s)") + if self.profile_line: + p0, p1 = self.profile_line[0], self.profile_line[1] + lines.append(f"Profile: ({p0['row']:.0f}, {p0['col']:.0f}) → ({p1['row']:.0f}, {p1['col']:.0f})") + non_zero = [(i, r * 90) for i, r in enumerate(self.image_rotations) if r % 4 != 0] + if non_zero: + parts = [f"#{i}={deg}°" for i, deg in non_zero] + lines.append(f"Rotated: {', '.join(parts)}") + rt = getattr(self, "render_total_ms", None) + if rt is not None: + pb = getattr(self, "render_python_build_ms", 0) + wj = getattr(self, "render_wire_js_ms", 0) + lines.append(f"Rendered: {rt} ms total (Python build {pb} ms, wire+JS {wj} ms)") + else: + lines.append("Rendered: (pending first browser paint)") + print("\n".join(lines)) + + def _compute_all_stats(self): + """Compute statistics for all images (vectorized over all frames).""" + # Vectorized reduction over (H, W) is faster than per-image loops + # for large galleries (e.g. 12×4096×4096: 164ms vs 191ms). + axes = (1, 2) if self._data.ndim == 3 else None + self.stats_mean = np.mean(self._data, axis=axes).ravel().tolist() + self.stats_min = np.min(self._data, axis=axes).ravel().tolist() + self.stats_max = np.max(self._data, axis=axes).ravel().tolist() + self.stats_std = np.std(self._data, axis=axes).ravel().tolist() + + def _update_all_frames(self): + """Send display data to JS (possibly binned for large galleries).""" + data = self._display_data if self._display_data is not None else self._data + self.frame_bytes = data.tobytes() + + def _apply_rotations(self): + """Re-rotate each displayed image from its original by ``image_rotations[i] * 90°``. + + This is purely a display-time reorientation of each 2D image via + ``np.rot90`` — it is NOT scan rotation (which would rotate the + scan grid in a 4D-STEM dataset). Originals are kept in + ``_data_original`` so successive rotations compose from the + unrotated source rather than accumulating interpolation error. + Mixed shapes after rotation are center-padded to a common size. + """ + # Materialize originals as independent copies only when a non-zero + # rotation exists (they start as views into _data to avoid 800MB copy at init) + has_rotation = any( + (self.image_rotations[i] if i < len(self.image_rotations) else 0) % 4 != 0 + for i in range(len(self._data_original)) + ) + # No-rotation fast path: skip 30+ MB of redundant tobytes + stats recomputation + # on every widget init. The observer fires once when image_rotations = [0]*n + # is assigned in __init__; without this guard that triggered a full frame + # rebuild + stats recompute for a no-op. + if not has_rotation and self._originals_are_views: + return + if self._originals_are_views and has_rotation: + self._data_original = [img.copy() for img in self._data_original] + self._originals_are_views = False + rotated = [] + for i, orig in enumerate(self._data_original): + k = self.image_rotations[i] if i < len(self.image_rotations) else 0 + k = k % 4 + if k == 0: + rotated.append(orig) + else: + rotated.append(np.rot90(orig, k=k)) + # If shapes differ after rotation, center-pad all to max dims + shapes = [img.shape for img in rotated] + if len(set(shapes)) > 1: + max_h = max(s[0] for s in shapes) + max_w = max(s[1] for s in shapes) + padded = [] + for img in rotated: + h, w = img.shape + pad_top = (max_h - h) // 2 + pad_bot = max_h - h - pad_top + pad_left = (max_w - w) // 2 + pad_right = max_w - w - pad_left + padded.append(np.pad(img, ((pad_top, pad_bot), (pad_left, pad_right)), mode="constant", constant_values=0)) + rotated = padded + self._data = np.stack(rotated).astype(np.float32) + # Recompute display data if binning is active + if self._display_bin > 1: + from quantem.widget.array_utils import bin2d + self._display_data = bin2d(self._data, factor=self._display_bin, mode="mean") + else: + self._display_data = self._data + display = self._display_data if self._display_data is not None else self._data + self.height = int(display.shape[1]) + self.width = int(display.shape[2]) + self._compute_all_stats() + self._update_all_frames() + + @traitlets.observe("image_rotations") + def _on_image_rotations_changed(self, change): + if hasattr(self, "_data_original"): + self._apply_rotations() + + def rotate(self, idx: int, angle: int) -> Self: + """Rotate image ``idx`` by ``angle`` degrees (CCW-positive, matches np.rot90). + + Rotation convention follows ``np.rot90``:: + + angle | image_rotations | np.rot90 k | direction + ------+-----------------+------------+---------- + 90 | 1 | 1 | 90° CCW + 180 | 2 | 2 | 180° + -90 | 3 | 3 | 90° CW + 360 | 0 | 0 | identity + + Parameters + ---------- + idx : int + Image index in the gallery (0-based). + angle : int + Rotation angle in degrees (must be a multiple of 90). + Positive = counter-clockwise, negative = clockwise. + + Returns + ------- + Self + """ + if angle % 90 != 0: + raise ValueError(f"Rotation angle must be a multiple of 90 (got {angle}). Use 0, 90, 180, 270, or -90, -180, -270.") + if idx < 0 or idx >= self.n_images: + raise IndexError(f"Image index {idx} out of range [0, {self.n_images})") + k = (angle // 90) % 4 + rots = list(self.image_rotations) + while len(rots) < self.n_images: + rots.append(0) + rots[idx] = (rots[idx] + k) % 4 + self.image_rotations = rots + return self + + def _sample_profile(self, row0, col0, row1, col1): + img = self._data[self.selected_idx] + h, w = img.shape + dc, dr = col1 - col0, row1 - row0 + length = (dc**2 + dr**2) ** 0.5 + n = max(2, int(np.ceil(length))) + t = np.linspace(0, 1, n) + cs = col0 + t * dc + rs = row0 + t * dr + ci = np.floor(cs).astype(int) + ri = np.floor(rs).astype(int) + cf = cs - ci + rf = rs - ri + c0c = np.clip(ci, 0, w - 1) + c1c = np.clip(ci + 1, 0, w - 1) + r0c = np.clip(ri, 0, h - 1) + r1c = np.clip(ri + 1, 0, h - 1) + return (img[r0c, c0c] * (1 - cf) * (1 - rf) + + img[r0c, c1c] * cf * (1 - rf) + + img[r1c, c0c] * (1 - cf) * rf + + img[r1c, c1c] * cf * rf).astype(np.float32) + + def set_profile(self, start: tuple, end: tuple): + """Set a line profile between two points (image pixel coordinates). + + Parameters + ---------- + start : tuple of (row, col) + Start point in pixel coordinates. + end : tuple of (row, col) + End point in pixel coordinates. + """ + row0, col0 = start + row1, col1 = end + self.profile_line = [ + {"row": float(row0), "col": float(col0)}, + {"row": float(row1), "col": float(col1)}, + ] + + def clear_profile(self): + """Clear the current line profile.""" + self.profile_line = [] + + def _upsert_selected_roi(self, updates: dict): + rois = list(self.roi_list) + color_cycle = ["#4fc3f7", "#81c784", "#ffb74d", "#ce93d8", "#ef5350", "#ffd54f", "#90a4ae", "#a1887f"] + defaults = { + "shape": "square", + "row": int(self.height // 2), + "col": int(self.width // 2), + "radius": 10, + "radius_inner": 5, + "width": 20, + "height": 20, + "line_width": 2, + "highlight": False, + "visible": True, + "locked": False, + } + if self.roi_selected_idx >= 0 and self.roi_selected_idx < len(rois): + current = {**defaults, **rois[self.roi_selected_idx]} + if not current.get("color"): + current["color"] = color_cycle[self.roi_selected_idx % len(color_cycle)] + rois[self.roi_selected_idx] = {**current, **updates} + else: + rois.append({**defaults, "color": color_cycle[len(rois) % len(color_cycle)], **updates}) + self.roi_selected_idx = len(rois) - 1 + self.roi_list = rois + self.roi_active = True + + def add_roi(self, row: int | None = None, col: int | None = None, shape: str = "square") -> Self: + with self.hold_sync(): + self.roi_selected_idx = -1 + self._upsert_selected_roi({ + "shape": shape, + "row": int(self.height // 2 if row is None else row), + "col": int(self.width // 2 if col is None else col), + }) + return self + + def clear_rois(self) -> Self: + with self.hold_sync(): + self.roi_list = [] + self.roi_selected_idx = -1 + self.roi_active = False + return self + + def delete_selected_roi(self) -> Self: + idx = int(self.roi_selected_idx) + if idx < 0 or idx >= len(self.roi_list): + return self + with self.hold_sync(): + rois = [roi for i, roi in enumerate(self.roi_list) if i != idx] + self.roi_list = rois + self.roi_selected_idx = min(idx, len(rois) - 1) if rois else -1 + if not rois: + self.roi_active = False + return self + + def set_roi(self, row: int, col: int, radius: int = 10) -> Self: + with self.hold_sync(): + self._upsert_selected_roi({"shape": "circle", "row": int(row), "col": int(col), "radius": int(radius)}) + return self + + def roi_circle(self, radius: int = 10) -> Self: + with self.hold_sync(): + self._upsert_selected_roi({"shape": "circle", "radius": int(radius)}) + return self + + def roi_square(self, half_size: int = 10) -> Self: + with self.hold_sync(): + self._upsert_selected_roi({"shape": "square", "radius": int(half_size)}) + return self + + def roi_rectangle(self, width: int = 20, height: int = 10) -> Self: + with self.hold_sync(): + self._upsert_selected_roi({"shape": "rectangle", "width": int(width), "height": int(height)}) + return self + + def roi_annular(self, inner: int = 5, outer: int = 10) -> Self: + with self.hold_sync(): + self._upsert_selected_roi({"shape": "annular", "radius_inner": int(inner), "radius": int(outer)}) + return self + + @property + def profile(self): + """Get profile line endpoints as [(row0, col0), (row1, col1)] or []. + + Returns + ------- + list of tuple + Line endpoints in pixel coordinates, or empty list if no profile. + """ + return [(p["row"], p["col"]) for p in self.profile_line] + + @property + def profile_values(self): + """Get intensity values along the profile line as a numpy array. + + Returns + ------- + np.ndarray or None + Float32 array of sampled intensities, or None if no profile. + """ + if len(self.profile_line) < 2: + return None + p0, p1 = self.profile_line + return self._sample_profile(p0["row"], p0["col"], p1["row"], p1["col"]) + + @property + def profile_distance(self): + """Get total distance of the profile line in calibrated units. + + Returns + ------- + float or None + Distance in angstroms (if pixel_size > 0) or pixels. + None if no profile line is set. + """ + if len(self.profile_line) < 2: + return None + p0, p1 = self.profile_line + dc = p1["col"] - p0["col"] + dr = p1["row"] - p0["row"] + dist_px = (dc**2 + dr**2) ** 0.5 + if self.pixel_size > 0: + return dist_px * self.pixel_size + return dist_px + diff --git a/widget/src/quantem/widget/show4dstem.py b/widget/src/quantem/widget/show4dstem.py new file mode 100644 index 00000000..c3ee61a7 --- /dev/null +++ b/widget/src/quantem/widget/show4dstem.py @@ -0,0 +1,2334 @@ +""" +show4dstem: Fast interactive 4D-STEM viewer widget. + +Single chunked-torch path on every device (CUDA / MPS / CPU). Reductions cast +uint16 → float32 in scan-row chunks bounded by _CHUNK_BYTE_BUDGET, so transient +memory stays the same regardless of total dataset size. + +To reduce data size, bin k-space at the dataset level before viewing: + + dataset = dataset.bin(2, axes=(2, 3)) # 2x2 k-space binning + widget = Show4DSTEM(dataset) +""" + +import json +import math +import pathlib +import time +from typing import TYPE_CHECKING, Any, Self + +if TYPE_CHECKING: + from quantem.core.datastructures import Dataset4dstem + +import anywidget +import numpy as np +import torch +import traitlets +from quantem.widget.array_utils import to_numpy +from quantem.widget.state import ( + build_json_header, + resolve_widget_version, + save_state_file, + unwrap_state_payload, +) + +from quantem.core.config import validate_device + +# Cap transient chunk memory at ~600 MB regardless of detector size. +_CHUNK_BYTE_BUDGET = 600 * 1024 * 1024 + + +def _format_memory(nbytes: int) -> str: + if nbytes >= 1 << 30: + return f"{nbytes / (1 << 30):.1f} GB" + if nbytes >= 1 << 20: + return f"{nbytes / (1 << 20):.0f} MB" + if nbytes >= 1 << 10: + return f"{nbytes / (1 << 10):.0f} KB" + return f"{nbytes} B" + + +# ============================================================================ +# Constants +# ============================================================================ +DEFAULT_BF_RATIO = 0.125 # BF disk radius as fraction of detector size (1/8) +MIN_LOG_VALUE = 1e-10 # Minimum value for log scale to avoid log(0) +DEFAULT_VI_ROI_RATIO = 0.15 # Default VI ROI size as fraction of scan dimension + +class Show4DSTEM(anywidget.AnyWidget): + """ + Fast interactive 4D-STEM viewer with advanced features. + + Optimized for speed with binary transfer and pre-normalization. + Works with NumPy and PyTorch arrays. + + Parameters + ---------- + data : Dataset4dstem or array_like + Dataset4dstem object (calibration auto-extracted), 4D array + of shape (scan_rows, scan_cols, det_rows, det_cols), or 5D array + of shape (n_frames, scan_rows, scan_cols, det_rows, det_cols) + for time-series or tilt-series data. + scan_shape : tuple, optional + If data is flattened (N, det_rows, det_cols), provide scan dimensions. + sampling : tuple of 4 floats, optional + Pixel size per axis ``(scan_row, scan_col, k_row, k_col)``. Scalar + broadcasts to all four axes. Defaults to ``(1, 1, 1, 1)``. + Auto-extracted from ``Dataset4dstem`` if not provided. + units : list of 4 str, optional + Unit string per axis. Common: ``["A", "A", "mrad", "mrad"]``. + Defaults to ``["pixels"] * 4``. Auto-extracted from + ``Dataset4dstem`` if not provided. + center : tuple[float, float], optional + (center_row, center_col) of the diffraction pattern in pixels. + If not provided, defaults to detector center. + bf_radius : float, optional + Bright field disk radius in pixels. If not provided, estimated as 1/8 of detector size. + precompute_virtual_images : bool, default True + Precompute BF/ABF/LAADF/HAADF virtual images for preset switching. + frame_dim_label : str, optional + Label for the frame dimension when 5D data is provided. + Defaults to "Frame". Common values: "Tilt", "Time", "Focus". + Examples + -------- + >>> import numpy as np + >>> from quantem.widget import Show4DSTEM + + 4D NumPy array ``(scan_rows, scan_cols, det_rows, det_cols)``: + + >>> Show4DSTEM(np.random.rand(64, 64, 128, 128)) + + PyTorch tensor (CPU or GPU): + + >>> import torch + >>> Show4DSTEM(torch.rand(64, 64, 128, 128)) + + With explicit calibration (real-space Å, k-space mrad): + + >>> Show4DSTEM(np.random.rand(64, 64, 128, 128), + ... sampling=(2.39, 2.39, 0.46, 0.46), + ... units=["A", "A", "mrad", "mrad"]) + + quantem ``Dataset4dstem`` — calibration + units auto-extracted: + + >>> from quantem.core.datastructures import Dataset4dstem + >>> ds = Dataset4dstem.from_array(np.random.rand(64, 64, 128, 128)) + >>> Show4DSTEM(ds) + + Flattened scan ``(N, det_rows, det_cols)`` with explicit scan shape: + + >>> Show4DSTEM(np.random.rand(4096, 128, 128), scan_shape=(64, 64)) + + Custom BF disk center and radius (overrides auto-detection): + + >>> Show4DSTEM(np.random.rand(64, 64, 128, 128), + ... center=(64, 64), bf_radius=12) + + 5D time-series or tilt-series ``(n_frames, scan_r, scan_c, det_r, det_c)``: + + >>> Show4DSTEM(np.random.rand(20, 64, 64, 128, 128), frame_dim_label="Tilt") + + Raster animation (scan path through 4D dataset): + + >>> w = Show4DSTEM(np.random.rand(64, 64, 128, 128)) + >>> w.raster(step=2, interval_ms=50) + + Static export to PDF or PNG (single panel or all four): + + >>> w = Show4DSTEM(np.random.rand(64, 64, 128, 128)) + >>> w.save_image("dp.pdf", view="diffraction") + >>> w.save_image("all.pdf", view="all") + """ + + _esm = pathlib.Path(__file__).parent / "static" / "show4dstem.js" + + # Position in scan space + widget_version = traitlets.Unicode("unknown").tag(sync=True) + title = traitlets.Unicode("").tag(sync=True) + pos_row = traitlets.Int(0).tag(sync=True) + pos_col = traitlets.Int(0).tag(sync=True) + + # Shape of scan space (for slider bounds) + shape_rows = traitlets.Int(1).tag(sync=True) + shape_cols = traitlets.Int(1).tag(sync=True) + + # Detector shape for frontend + det_rows = traitlets.Int(1).tag(sync=True) + det_cols = traitlets.Int(1).tag(sync=True) + + # Raw float32 frame as bytes (JS handles scale/colormap for real-time interactivity) + frame_bytes = traitlets.Bytes(b"").tag(sync=True) + + # Global min/max for DP normalization (computed once from sampled frames) + dp_global_min = traitlets.Float(0.0).tag(sync=True) + dp_global_max = traitlets.Float(1.0).tag(sync=True) + + # ========================================================================= + # Detector Calibration (for presets and scale bar) + # ========================================================================= + center_col = traitlets.Float(0.0).tag(sync=True) # Detector center col + center_row = traitlets.Float(0.0).tag(sync=True) # Detector center row + bf_radius = traitlets.Float(0.0).tag(sync=True) # BF disk radius (pixels) + + # ========================================================================= + # ROI Drawing (for virtual imaging) + # roi_radius is multi-purpose by mode: + # - circle: radius of circle + # - square: half-size (distance from center to edge) + # - annular: outer radius (roi_radius_inner = inner radius) + # - rect: uses roi_width/roi_height instead + # ========================================================================= + roi_active = traitlets.Bool(False).tag(sync=True) + roi_mode = traitlets.Unicode("point").tag(sync=True) + roi_center_col = traitlets.Float(0.0).tag(sync=True) + roi_center_row = traitlets.Float(0.0).tag(sync=True) + # Compound trait for batched row+col updates (JS sends both at once, 1 observer fires) + roi_center = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0]).tag(sync=True) + roi_radius = traitlets.Float(10.0).tag(sync=True) + roi_radius_inner = traitlets.Float(5.0).tag(sync=True) + roi_width = traitlets.Float(20.0).tag(sync=True) + roi_height = traitlets.Float(10.0).tag(sync=True) + + # ========================================================================= + # Virtual Image (ROI-based, updates as you drag ROI on DP) + # ========================================================================= + virtual_image_bytes = traitlets.Bytes(b"").tag(sync=True) # Raw float32 (JS computes stats + range) + + # ========================================================================= + # VI ROI (real-space region selection for summed DP) + # ========================================================================= + vi_roi_mode = traitlets.Unicode("off").tag(sync=True) # "off", "circle", "rect" + vi_roi_center_row = traitlets.Float(0.0).tag(sync=True) + vi_roi_center_col = traitlets.Float(0.0).tag(sync=True) + # Compound (row, col) trait — JS sets in one call; one observer fires; bytes + # never compute against split-trait state (old col + new row, or vice versa). + vi_roi_center = traitlets.List(traitlets.Float(), default_value=[0.0, 0.0]).tag(sync=True) + vi_roi_radius = traitlets.Float(5.0).tag(sync=True) + vi_roi_width = traitlets.Float(10.0).tag(sync=True) + vi_roi_height = traitlets.Float(10.0).tag(sync=True) + # Reduction over scan positions inside vi_roi: mean is default (size-invariant DP), + # sum scales with area (quantitative counts), max picks brightest position per detector pixel. + vi_roi_reduce = traitlets.Unicode("mean").tag(sync=True) + vi_roi_dp_bytes = traitlets.Bytes(b"").tag(sync=True) # Reduced DP from VI ROI + + # ========================================================================= + # Scale Bar + # ========================================================================= + pixel_size = traitlets.Float(1.0).tag(sync=True) # real-space pixel size (col axis) + pixel_unit = traitlets.Unicode("pixels").tag(sync=True) + k_pixel_size = traitlets.Float(1.0).tag(sync=True) # k-space pixel size (col axis) + k_pixel_unit = traitlets.Unicode("pixels").tag(sync=True) + k_calibrated = traitlets.Bool(False).tag(sync=True) # True if k-space has real units + + # ========================================================================= + # Path Animation (programmatic crosshair control) + # ========================================================================= + path_playing = traitlets.Bool(False).tag(sync=True) + path_index = traitlets.Int(0).tag(sync=True) + path_length = traitlets.Int(0).tag(sync=True) + path_interval_ms = traitlets.Int(100).tag(sync=True) # ms between frames + path_loop = traitlets.Bool(True).tag(sync=True) # loop when reaching end + + # ========================================================================= + # Auto-detection trigger (frontend sets to True, backend resets to False) + # ========================================================================= + + # ========================================================================= + # Statistics for display (mean, min, max, std) + # ========================================================================= + # dp_stats and vi_stats are computed JS-side from frame_bytes / virtual_image_bytes. + # Keeping them out of Python traits eliminates a 4-message comm race that produced + # mismatched bytes/min/max on rapid preset/ROI changes. + + # ========================================================================= + # Display settings (synced for programmatic export parity) + # ========================================================================= + dp_colormap = traitlets.Unicode("inferno").tag(sync=True) + vi_colormap = traitlets.Unicode("inferno").tag(sync=True) + fft_colormap = traitlets.Unicode("inferno").tag(sync=True) + + dp_scale_mode = traitlets.Unicode("linear").tag(sync=True) # "linear" | "log" + vi_scale_mode = traitlets.Unicode("linear").tag(sync=True) # "linear" | "log" + fft_scale_mode = traitlets.Unicode("linear").tag(sync=True) # "linear" | "log" + + dp_vmin_pct = traitlets.Float(0.0).tag(sync=True) + dp_vmax_pct = traitlets.Float(100.0).tag(sync=True) + vi_vmin_pct = traitlets.Float(0.0).tag(sync=True) + vi_vmax_pct = traitlets.Float(100.0).tag(sync=True) + fft_vmin_pct = traitlets.Float(0.0).tag(sync=True) + fft_vmax_pct = traitlets.Float(100.0).tag(sync=True) + + # Absolute intensity bounds (override percentile sliders when both set) + dp_vmin = traitlets.Float(None, allow_none=True).tag(sync=True) + dp_vmax = traitlets.Float(None, allow_none=True).tag(sync=True) + vi_vmin = traitlets.Float(None, allow_none=True).tag(sync=True) + vi_vmax = traitlets.Float(None, allow_none=True).tag(sync=True) + + fft_auto = traitlets.Bool(True).tag(sync=True) + show_fft = traitlets.Bool(False).tag(sync=True) + # Single-trait preset request: JS sets to "bf"/"abf"/"adf"/"haadf" → Python + # observer calls apply_preset() which batches the 5 ROI trait writes + # atomically. Avoids the JS-side ordering race where individual roi_mode/ + # radius/center traits would commit in separate comm messages. + _preset_request = traitlets.Unicode("").tag(sync=True) + fft_window = traitlets.Bool(True).tag(sync=True) + show_controls = traitlets.Bool(True).tag(sync=True) + dp_show_colorbar = traitlets.Bool(False).tag(sync=True) + # VI panel auto-contrast (1st/99th percentile clip) and CSS smoothing. + # DP panel doesn't need either — Bragg spots are best read with nearest- + # neighbor + the slider's percentile range. + vi_auto_contrast = traitlets.Bool(False).tag(sync=True) + vi_smooth = traitlets.Bool(False).tag(sync=True) + + # ========================================================================= + # Frame Animation (5D time/tilt series) + # ========================================================================= + frame_idx = traitlets.Int(0).tag(sync=True) + n_frames = traitlets.Int(1).tag(sync=True) + frame_dim_label = traitlets.Unicode("Frame").tag(sync=True) + frame_labels = traitlets.List(traitlets.Unicode(), []).tag(sync=True) + frame_playing = traitlets.Bool(False).tag(sync=True) + frame_loop = traitlets.Bool(True).tag(sync=True) + frame_fps = traitlets.Float(5.0).tag(sync=True) + frame_reverse = traitlets.Bool(False).tag(sync=True) + frame_boomerang = traitlets.Bool(False).tag(sync=True) + + # Export (GIF) + _gif_export_requested = traitlets.Bool(False).tag(sync=True) + _gif_data = traitlets.Bytes(b"").tag(sync=True) + _gif_metadata_json = traitlets.Unicode("").tag(sync=True) + + # Line Profile (for DP panel) + profile_line = traitlets.List(traitlets.Dict()).tag(sync=True) + profile_width = traitlets.Int(1).tag(sync=True) + + # ========================================================================= + def __init__( + self, + data: "Dataset4dstem | np.ndarray", + scan_shape: tuple[int, int] | None = None, + sampling: tuple[float, ...] | list[float] | None = None, + units: list[str] | tuple[str, ...] | None = None, + center: tuple[float, float] | None = None, + bf_radius: float | None = None, + precompute_virtual_images: bool = True, + frame_dim_label: str | None = None, + frame_labels: list[str] | None = None, + title: str = "", + show_fft: bool = False, + fft_window: bool = True, + show_controls: bool = True, + dp_vmin: float | None = None, + dp_vmax: float | None = None, + vi_vmin: float | None = None, + vi_vmax: float | None = None, + verbose: bool = True, + state=None, + **kwargs, + ): + super().__init__(**kwargs) + self.widget_version = resolve_widget_version() + _t0 = time.perf_counter() + _verbose = verbose + + _io_labels = None + + # Extract underlying array / tensor + auto-calibrate from Dataset input + # (duck-typed via the dual-slot private attributes _tensor / _array). + tensor = getattr(data, "_tensor", None) + array = getattr(data, "_array", None) + if tensor is not None or array is not None: + if not title and getattr(data, "name", ""): + title = str(data.name) + if sampling is None: + sampling = tuple(float(s) for s in data.sampling) + if units is None: + units = list(data.units) + data = tensor if tensor is not None else array + + # Resolve sampling + units (4 axes for 4D-STEM): + # [scan_row, scan_col, k_row, k_col]. Scalar/None broadcast to (1, 1, 1, 1). + if sampling is None: + sampling = (1.0, 1.0, 1.0, 1.0) + elif isinstance(sampling, (int, float)): + sampling = (float(sampling),) * 4 + else: + sampling = tuple(float(s) for s in sampling) + if units is None: + units = ["pixels"] * 4 + elif isinstance(units, str): + units = [units] * 4 + else: + units = [str(u) for u in units] + + self.title = title + self.pixel_size = sampling[1] # scan_col axis (horizontal scale bar) + self.pixel_unit = units[1] if len(units) > 1 else "pixels" + self.k_pixel_size = sampling[3] if len(sampling) > 3 else 1.0 + self.k_pixel_unit = units[3] if len(units) > 3 else "pixels" + # k-space considered calibrated when its unit is real (mrad, 1/Å, etc.). + self.k_calibrated = self.k_pixel_unit not in ("pixels", "") + self.show_fft = show_fft + self.fft_window = fft_window + self.show_controls = show_controls + self.dp_vmin = dp_vmin + self.dp_vmax = dp_vmax + self.vi_vmin = vi_vmin + self.vi_vmax = vi_vmax + # Path animation (configured via set_path() or raster()) + self._path_points: list[tuple[int, int]] = [] + # Suppress per-trait recompute during apply_preset batch writes + self._suppress_roi_recompute = False + # Torch tensor input keeps its device (lets user pin a specific GPU via + # `data.cuda(1)`). NumPy / Dataset input gets default-validated device. + if isinstance(data, torch.Tensor): + self._device = data.device + self._data_pre = data + data_np = None + else: + device_str, _ = validate_device(None) + self._device = torch.device(device_str) + data_np = to_numpy(data) + self._data_pre = None + self._saturation_value = ( + 65535 if data_np.dtype == np.uint16 + else 255 if data_np.dtype == np.uint8 + else None + ) + # Handle dimensionality — 5D loads eagerly for instant frame switching + # Resolve shape from whichever input path we took + shape = tuple(self._data_pre.shape) if self._data_pre is not None else data_np.shape + ndim = len(shape) + _tc = time.perf_counter() + if ndim == 5: + self.n_frames = shape[0] + self._scan_shape = (shape[1], shape[2]) + self._det_shape = (shape[3], shape[4]) + elif ndim == 3: + self.n_frames = 1 + if scan_shape is not None: + self._scan_shape = scan_shape + else: + n = shape[0] + side = int(n ** 0.5) + if side * side != n: + raise ValueError( + f"Cannot infer square scan_shape from N={n}. " + f"Provide scan_shape explicitly." + ) + self._scan_shape = (side, side) + self._det_shape = (shape[1], shape[2]) + elif ndim == 4: + self.n_frames = 1 + self._scan_shape = (shape[0], shape[1]) + self._det_shape = (shape[2], shape[3]) + else: + raise ValueError(f"Show4DSTEM expects a 3D ((N, det_h, det_w) flat-scan), 4D ((scan_h, scan_w, det_h, det_w)), or 5D ((n_frames, scan_h, scan_w, det_h, det_w)) array. Got {ndim}D.") + if self._data_pre is not None: + self._data = self._data_pre if self._data_pre.device == self._device else self._data_pre.to(self._device) + del self._data_pre + else: + self._data = torch.from_numpy(data_np).to(self._device) + # Saturation filter: zero detector pixels at full-scale (65535 / 255). + # PyTorch lacks unsigned int comparison kernels, but uint16 viewed + # as int16 has identical bytes (65535 → -1) and int16 comparison + # works on every device. Apply in scan-row chunks so the transient + # bool mask stays bounded (≤600 MB) and fits constrained-VRAM + # devices (Mac 24 GB unified, etc.). View-write keeps native dtype. + sat = getattr(self, "_saturation_value", None) + view_dtype = ( + torch.int16 if sat is not None and self._data.dtype == torch.uint16 + else torch.int8 if sat is not None and self._data.dtype == torch.uint8 + else None + ) + if view_dtype is not None: + view = self._data.view(view_dtype).reshape(-1, *self._det_shape) + rows = view.shape[0] + # Bool mask transient = positions × det_h × det_w bytes; cap at budget. + pos_per_chunk = max(1, _CHUNK_BYTE_BUDGET // max(1, self._det_shape[0] * self._det_shape[1])) + for i in range(0, rows, pos_per_chunk): + chunk = view[i:i + pos_per_chunk] + chunk.masked_fill_(chunk == -1, 0) + # Keep native dtype (uint8/uint16) to bound memory at ~ data_size. + # Reductions cast in chunks (bounded transient). + if _verbose: + if str(self._device) == "mps": + torch.mps.synchronize() + n_bytes = self._data.element_size() * self._data.numel() + print(f" to {self._device}: {time.perf_counter() - _tc:.2f}s ({n_bytes / 1e9:.1f} GB)") + + self.shape_rows = self._scan_shape[0] + self.shape_cols = self._scan_shape[1] + self.det_rows = self._det_shape[0] + self.det_cols = self._det_shape[1] + # Initial position at center + self.pos_row = self.shape_rows // 2 + self.pos_col = self.shape_cols // 2 + # Frame dimension label (for 5D time/tilt series UI) + self.frame_dim_label = frame_dim_label if frame_dim_label is not None else "Frame" + # Per-frame labels: explicit param > inferred > empty + resolved_labels = frame_labels or _io_labels or [] + self._frame_labels = resolved_labels + if resolved_labels: + self.frame_labels = list(resolved_labels) + # Histogram axis range — first frame is enough (JS does per-frame percentile clipping). + # Cast to float for min/max reductions: PyTorch CUDA lacks integer min/max kernels, + # and the first slice is tiny (144 KB at 192×192) so the cast is free. + first_frame = self._data[0] if self._data.ndim == 5 else self._data + first_frame_sample = first_frame[0] if first_frame.ndim >= 3 else first_frame + if not torch.is_floating_point(first_frame_sample): + first_frame_sample = first_frame_sample.float() + self.dp_global_min = max(float(first_frame_sample.min()), MIN_LOG_VALUE) + self.dp_global_max = float(first_frame_sample.max()) + # Cache coordinate tensors for mask creation (avoid repeated torch.arange) + self._det_row_coords = torch.arange(self.det_rows, device=self._device, dtype=torch.float32)[:, None] + self._det_col_coords = torch.arange(self.det_cols, device=self._device, dtype=torch.float32)[None, :] + self._scan_row_coords = torch.arange(self.shape_rows, device=self._device, dtype=torch.float32)[:, None] + self._scan_col_coords = torch.arange(self.shape_cols, device=self._device, dtype=torch.float32)[None, :] + # Setup center and BF radius + det_size = min(self.det_rows, self.det_cols) + if center is not None and bf_radius is not None: + self.center_row = float(center[0]) + self.center_col = float(center[1]) + self.bf_radius = float(bf_radius) + elif center is not None: + self.center_row = float(center[0]) + self.center_col = float(center[1]) + self.bf_radius = det_size * DEFAULT_BF_RATIO + elif bf_radius is not None: + self.center_col = float(self.det_cols / 2) + self.center_row = float(self.det_rows / 2) + self.bf_radius = float(bf_radius) + else: + # Neither provided - auto-detect from data + # Set defaults first (will be overwritten by auto-detect) + self.center_col = float(self.det_cols / 2) + self.center_row = float(self.det_rows / 2) + self.bf_radius = det_size * DEFAULT_BF_RATIO + # Auto-detect center and bf_radius from the data + _tc = time.perf_counter() + self.auto_detect_center(update_roi=False) + if _verbose: + print(f" auto_detect_center: {time.perf_counter() - _tc:.2f}s") + + # Pre-compute and cache common virtual images (BF, ABF, ADF) + # Each cache stores (bytes, stats) tuple + self._cached_bf_virtual = None + self._cached_abf_virtual = None + self._cached_adf_virtual = None + self._cached_haadf_virtual = None + if precompute_virtual_images and self.n_frames == 1: + self._precompute_common_virtual_images() + + # Update frame when position changes (scale/colormap handled in JS) + self.observe(self._update_frame, names=["pos_row", "pos_col"]) + # Observe individual ROI params + self.observe(self._on_roi_change, names=[ + "roi_center_col", "roi_center_row", "roi_radius", "roi_radius_inner", + "roi_active", "roi_mode", "roi_width", "roi_height" + ]) + # Observe compound roi_center for batched updates from JS + self.observe(self._on_roi_center_change, names=["roi_center"]) + # Invalidate precomputed virtual image caches when calibration changes + self.observe(self._on_calibration_change, names=["center_row", "center_col", "bf_radius"]) + + # Initialize default ROI at BF center — batch to avoid redundant observer callbacks + with self.hold_trait_notifications(): + self.roi_center_col = self.center_col + self.roi_center_row = self.center_row + self.roi_center = [self.center_row, self.center_col] + self.roi_radius = self.bf_radius * 0.5 # Start with half BF radius + self.roi_active = True + + # Compute initial virtual image and frame (once, after all ROI traits are set) + _tc = time.perf_counter() + self._compute_virtual_image_from_roi() + self._update_frame() + if _verbose: + print(f" virtual image + frame: {time.perf_counter() - _tc:.2f}s") + + # Path animation: observe index changes from frontend + self.observe(self._on_path_index_change, names=["path_index"]) + self.observe(self._on_gif_export, names=["_gif_export_requested"]) + + # Frame animation (5D): observe frame_idx changes from frontend + self.observe(self._on_frame_idx_change, names=["frame_idx"]) + self.observe(self._on_preset_request, names=["_preset_request"]) + + # Auto-detect trigger: observe changes from frontend + + # VI ROI: observe changes for summed DP computation + # Initialize VI ROI center to scan center with reasonable default sizes + self.vi_roi_center_row = float(self.shape_rows / 2) + self.vi_roi_center_col = float(self.shape_cols / 2) + # Set initial ROI size based on scan dimension + default_roi_size = max(3, min(self.shape_rows, self.shape_cols) * DEFAULT_VI_ROI_RATIO) + self.vi_roi_radius = float(default_roi_size) + self.vi_roi_width = float(default_roi_size * 2) + self.vi_roi_height = float(default_roi_size) + self.observe(self._on_vi_roi_change, names=[ + "vi_roi_mode", "vi_roi_center_row", "vi_roi_center_col", + "vi_roi_radius", "vi_roi_width", "vi_roi_height", "vi_roi_reduce" + ]) + self.observe(self._on_vi_roi_center_change, names=["vi_roi_center"]) + + if state is not None: + if isinstance(state, (str, pathlib.Path)): + state = unwrap_state_payload( + json.loads(pathlib.Path(state).read_text()), + require_envelope=True, + ) + else: + state = unwrap_state_payload(state) + self.load_state_dict(state) + + if _verbose: + shape = "x".join(str(s) for s in self._data.shape) + print(f"Show4DSTEM: {shape} {self._device}, {time.perf_counter() - _t0:.2f}s total") + + def __repr__(self) -> str: + shape = ( + f"({self.n_frames}, {self.shape_rows}, {self.shape_cols}, {self.det_rows}, {self.det_cols})" + if self.n_frames > 1 + else f"({self.shape_rows}, {self.shape_cols}, {self.det_rows}, {self.det_cols})" + ) + frame_info = f", {self.frame_dim_label.lower()}={self.frame_idx}" if self.n_frames > 1 else "" + title_info = f", title='{self.title}'" if self.title else "" + return ( + f"Show4DSTEM(shape={shape}, " + f"sampling=({self.pixel_size} {self.pixel_unit}, {self.k_pixel_size} {self.k_pixel_unit}), " + f"pos=({self.pos_row}, {self.pos_col}){frame_info}{title_info})" + ) + + def state_dict(self): + return { + "title": self.title, + "pos_row": self.pos_row, + "pos_col": self.pos_col, + "pixel_size": self.pixel_size, + "pixel_unit": self.pixel_unit, + "k_pixel_size": self.k_pixel_size, + "k_pixel_unit": self.k_pixel_unit, + "k_calibrated": self.k_calibrated, + "center_row": self.center_row, + "center_col": self.center_col, + "bf_radius": self.bf_radius, + "roi_active": self.roi_active, + "roi_mode": self.roi_mode, + "roi_center_row": self.roi_center_row, + "roi_center_col": self.roi_center_col, + "roi_radius": self.roi_radius, + "roi_radius_inner": self.roi_radius_inner, + "roi_width": self.roi_width, + "roi_height": self.roi_height, + "vi_roi_mode": self.vi_roi_mode, + "vi_roi_center_row": self.vi_roi_center_row, + "vi_roi_center_col": self.vi_roi_center_col, + "vi_roi_radius": self.vi_roi_radius, + "vi_roi_width": self.vi_roi_width, + "vi_roi_height": self.vi_roi_height, + "vi_roi_reduce": self.vi_roi_reduce, + "dp_colormap": self.dp_colormap, + "vi_colormap": self.vi_colormap, + "fft_colormap": self.fft_colormap, + "dp_scale_mode": self.dp_scale_mode, + "vi_scale_mode": self.vi_scale_mode, + "fft_scale_mode": self.fft_scale_mode, + "dp_vmin_pct": self.dp_vmin_pct, + "dp_vmax_pct": self.dp_vmax_pct, + "vi_vmin_pct": self.vi_vmin_pct, + "vi_vmax_pct": self.vi_vmax_pct, + "fft_vmin_pct": self.fft_vmin_pct, + "fft_vmax_pct": self.fft_vmax_pct, + "dp_vmin": self.dp_vmin, + "dp_vmax": self.dp_vmax, + "vi_vmin": self.vi_vmin, + "vi_vmax": self.vi_vmax, + "fft_auto": self.fft_auto, + "show_fft": self.show_fft, + "fft_window": self.fft_window, + "show_controls": self.show_controls, + "dp_show_colorbar": self.dp_show_colorbar, + "vi_auto_contrast": self.vi_auto_contrast, + "vi_smooth": self.vi_smooth, + "path_interval_ms": self.path_interval_ms, + "path_loop": self.path_loop, + "profile_line": self.profile_line, + "profile_width": self.profile_width, + "frame_idx": self.frame_idx, + "frame_dim_label": self.frame_dim_label, + "frame_labels": list(self.frame_labels), + "frame_loop": self.frame_loop, + "frame_fps": self.frame_fps, + "frame_reverse": self.frame_reverse, + "frame_boomerang": self.frame_boomerang, + } + + def save(self, path: str): + save_state_file(path, "Show4DSTEM", self.state_dict()) + + def load_state_dict(self, state): + allowed_keys = set(self.state_dict().keys()) + pending_pos_row = state.get("pos_row", None) + pending_pos_col = state.get("pos_col", None) + pending_frame_idx = state.get("frame_idx", None) + for key, val in state.items(): + if key in {"pos_row", "pos_col", "frame_idx"}: + continue + if key in allowed_keys: + setattr(self, key, val) + if pending_frame_idx is not None: + self.frame_idx = int(max(0, min(int(pending_frame_idx), self.n_frames - 1))) + if pending_pos_row is not None or pending_pos_col is not None: + row = int(self.pos_row if pending_pos_row is None else pending_pos_row) + col = int(self.pos_col if pending_pos_col is None else pending_pos_col) + self.pos_row = int(max(0, min(row, self.shape_rows - 1))) + self.pos_col = int(max(0, min(col, self.shape_cols - 1))) + + def free(self): + """Free GPU memory held by this widget. + + Deletes the internal data tensor, runs garbage collection, and + flushes the MPS allocator cache. Call this before loading a new + dataset to avoid running out of GPU memory. + + Examples + -------- + >>> w.free() # release ~9 GB of MPS memory + >>> del result # free the source numpy array + """ + import gc + + device = str(self._device) if hasattr(self, "_device") else "" + nbytes = self._data.nbytes if hasattr(self._data, "nbytes") else 0 + self._data = None + gc.collect() + if device == "mps": + try: + torch.mps.empty_cache() + except AttributeError: + pass + elif device.startswith("cuda"): + torch.cuda.empty_cache() + if nbytes > 0: + print(f"freed {_format_memory(nbytes)} ({device})") + + def summary(self): + name = self.title if self.title else "Show4DSTEM" + lines = [name, "═" * 32] + if self.n_frames > 1: + parts = [f"{self.n_frames} ({self.frame_dim_label}), current: {self.frame_idx}"] + parts.append(f"{self.frame_fps} fps") + if self.frame_loop: + parts.append("loop") + if self.frame_reverse: + parts.append("reverse") + if self.frame_boomerang: + parts.append("bounce") + lines.append(f"Frames: {' | '.join(parts)}") + if self._frame_labels: + if len(self._frame_labels) <= 4: + lines.append(f"Labels: {self._frame_labels}") + else: + lines.append(f"Labels: {self._frame_labels[:3]} ... ({len(self._frame_labels)} total)") + lines.append(f"Scan: {self.shape_rows}×{self.shape_cols} ({self.pixel_size:.2f} {self.pixel_unit}/px)") + lines.append(f"Detector: {self.det_rows}×{self.det_cols} ({self.k_pixel_size:.4f} {self.k_pixel_unit}/px)") + lines.append(f"Position: ({self.pos_row}, {self.pos_col})") + lines.append(f"Center: ({self.center_row:.1f}, {self.center_col:.1f}) BF r={self.bf_radius:.1f} px") + if self.roi_active: + lines.append(f"ROI: {self.roi_mode} at ({self.roi_center_row:.1f}, {self.roi_center_col:.1f}) r={self.roi_radius:.1f}") + if self.vi_roi_mode != "off": + lines.append(f"VI ROI: {self.vi_roi_mode} at ({self.vi_roi_center_row:.1f}, {self.vi_roi_center_col:.1f}) r={self.vi_roi_radius:.1f}") + dp_contrast = f"{self.dp_vmin_pct:.1f}-{self.dp_vmax_pct:.1f}%" + if self.dp_vmin is not None and self.dp_vmax is not None: + dp_contrast += f", dp_vmin={self.dp_vmin:.4g}, dp_vmax={self.dp_vmax:.4g}" + lines.append( + f"DP view: {self.dp_colormap}, {self.dp_scale_mode}, {dp_contrast}" + ) + vi_contrast = f"{self.vi_vmin_pct:.1f}-{self.vi_vmax_pct:.1f}%" + if self.vi_vmin is not None and self.vi_vmax is not None: + vi_contrast += f", vi_vmin={self.vi_vmin:.4g}, vi_vmax={self.vi_vmax:.4g}" + lines.append( + f"VI view: {self.vi_colormap}, {self.vi_scale_mode}, {vi_contrast}" + ) + if self.show_fft: + fft_parts = [f"{self.fft_colormap}, {self.fft_scale_mode}, {self.fft_vmin_pct:.1f}-{self.fft_vmax_pct:.1f}%, auto={self.fft_auto}"] + if not self.fft_window: + fft_parts.append("no window") + lines.append(f"FFT view: {', '.join(fft_parts)}") + if self.profile_line and len(self.profile_line) == 2: + p0, p1 = self.profile_line[0], self.profile_line[1] + lines.append(f"Profile: ({p0['row']:.0f}, {p0['col']:.0f}) -> ({p1['row']:.0f}, {p1['col']:.0f}) width={self.profile_width}") + print("\n".join(lines)) + + # ========================================================================= + # Convenience Properties + # ========================================================================= + + @property + def position(self) -> tuple[int, int]: + """Current scan position as (row, col) tuple.""" + return (self.pos_row, self.pos_col) + + @position.setter + def position(self, value: tuple[int, int]) -> None: + """Set scan position from (row, col) tuple.""" + self.pos_row, self.pos_col = value + + @property + def scan_shape(self) -> tuple[int, int]: + """Scan dimensions as (rows, cols) tuple.""" + return (self.shape_rows, self.shape_cols) + + @property + def detector_shape(self) -> tuple[int, int]: + """Detector dimensions as (rows, cols) tuple.""" + return (self.det_rows, self.det_cols) + + @property + def _frame_data(self) -> torch.Tensor: + """Per-frame data (4D or 3D flattened), accounting for 5D time/tilt series.""" + if self.n_frames > 1: + return self._data[self.frame_idx] + return self._data + + # ========================================================================= + # Line Profile + # ========================================================================= + + def set_profile(self, start: tuple, end: tuple) -> Self: + row0, col0 = start + row1, col1 = end + self.profile_line = [ + {"row": float(row0), "col": float(col0)}, + {"row": float(row1), "col": float(col1)}, + ] + return self + + def clear_profile(self) -> Self: + self.profile_line = [] + return self + + @property + def profile(self) -> list[tuple[float, float]]: + if len(self.profile_line) == 2: + p0, p1 = self.profile_line[0], self.profile_line[1] + return [(p0["row"], p0["col"]), (p1["row"], p1["col"])] + return [] + + @property + def profile_values(self): + if len(self.profile_line) != 2: + return None + p0, p1 = self.profile_line[0], self.profile_line[1] + frame = self._get_frame(self.pos_row, self.pos_col) + return self._sample_line(frame, p0["row"], p0["col"], p1["row"], p1["col"]) + + @property + def profile_distance(self) -> float: + if len(self.profile_line) != 2: + return 0.0 + p0, p1 = self.profile_line[0], self.profile_line[1] + dist_px = np.sqrt((p1["row"] - p0["row"]) ** 2 + (p1["col"] - p0["col"]) ** 2) + if self.k_calibrated: + return float(dist_px * self.k_pixel_size) + return float(dist_px) + + def _sample_line(self, frame, row0, col0, row1, col1): + h, w = frame.shape[:2] + dc = col1 - col0 + dr = row1 - row0 + length = np.sqrt(dc * dc + dr * dr) + n = max(2, int(np.ceil(length))) + t = np.linspace(0.0, 1.0, n) + c = col0 + t * dc + r = row0 + t * dr + ci = np.floor(c).astype(np.intp) + ri = np.floor(r).astype(np.intp) + cf = c - ci + rf = r - ri + c0 = np.clip(ci, 0, w - 1) + c1 = np.clip(ci + 1, 0, w - 1) + r0 = np.clip(ri, 0, h - 1) + r1 = np.clip(ri + 1, 0, h - 1) + return ( + frame[r0, c0] * (1 - cf) * (1 - rf) + + frame[r0, c1] * cf * (1 - rf) + + frame[r1, c0] * (1 - cf) * rf + + frame[r1, c1] * cf * rf + ).astype(np.float32) + + # ========================================================================= + # Path Animation Methods + # ========================================================================= + + def set_path( + self, + points: list[tuple[int, int]], + interval_ms: int = 100, + loop: bool = True, + autoplay: bool = True, + ) -> Self: + """ + Set a custom path of scan positions to animate through. + + Parameters + ---------- + points : list[tuple[int, int]] + List of (row, col) scan positions to visit. + interval_ms : int, default 100 + Time between frames in milliseconds. + loop : bool, default True + Whether to loop when reaching end. + autoplay : bool, default True + Start playing immediately. + + Returns + ------- + Show4DSTEM + Self for method chaining. + + Examples + -------- + >>> widget.set_path([(0, 0), (10, 10), (20, 20), (30, 30)]) + >>> widget.set_path([(i, i) for i in range(48)], interval_ms=50) + """ + self._path_points = list(points) + self.path_length = len(self._path_points) + self.path_index = 0 + self.path_interval_ms = interval_ms + self.path_loop = loop + if autoplay and self.path_length > 0: + self.path_playing = True + return self + + def play(self) -> Self: + """Start playing the path animation.""" + if self.path_length > 0: + self.path_playing = True + return self + + def pause(self) -> Self: + """Pause the path animation.""" + self.path_playing = False + return self + + def stop(self) -> Self: + """Stop and reset path animation to beginning.""" + self.path_playing = False + self.path_index = 0 + return self + + def goto(self, index: int) -> Self: + """Jump to a specific index in the path.""" + if 0 <= index < self.path_length: + self.path_index = index + return self + + def _on_path_index_change(self, change): + """Called when path_index changes (from frontend timer).""" + idx = change["new"] + if 0 <= idx < len(self._path_points): + row, col = self._path_points[idx] + # Clamp to valid range + self.pos_row = max(0, min(self.shape_rows - 1, row)) + self.pos_col = max(0, min(self.shape_cols - 1, col)) + + def _on_preset_request(self, change): + """JS preset shortcut → atomic apply_preset (no per-trait race).""" + name = (change.get("new") or "").strip().lower() + if name in ("bf", "abf", "adf", "haadf"): + self.apply_preset(name) + self._preset_request = "" # consume trigger + + def _on_frame_idx_change(self, change=None): + """Called when frame_idx changes (5D time/tilt series). + + Recomputes virtual image and diffraction pattern for the new frame. + Invalidates precomputed caches since they are per-frame. + """ + if self.n_frames <= 1: + return + # Invalidate precomputed caches (they were for a different frame) + self._cached_bf_virtual = None + self._cached_abf_virtual = None + self._cached_adf_virtual = None + self._cached_haadf_virtual = None + # Recompute virtual image and displayed frame + self._compute_virtual_image_from_roi() + self._update_frame() + # Recompute reduced DP if VI ROI is active + if self.vi_roi_mode != "off": + self._compute_vi_roi_dp() + + # ========================================================================= + # Path Animation Patterns + # ========================================================================= + + def raster( + self, + step: int = 1, + bidirectional: bool = False, + interval_ms: int = 100, + loop: bool = True, + ) -> Self: + """ + Play a raster scan path (row by row, left to right). + + This mimics real STEM scanning: left→right, step down, left→right, etc. + + Parameters + ---------- + step : int, default 1 + Step size between positions. + bidirectional : bool, default False + If True, use snake/boustrophedon pattern (alternating direction). + If False (default), always scan left→right like real STEM. + interval_ms : int, default 100 + Time between frames in milliseconds. + loop : bool, default True + Whether to loop when reaching the end. + + Returns + ------- + Show4DSTEM + Self for method chaining. + """ + points = [] + for r in range(0, self.shape_rows, step): + cols = list(range(0, self.shape_cols, step)) + if bidirectional and (r // step % 2 == 1): + cols = cols[::-1] # Alternate direction for snake pattern + for c in cols: + points.append((r, c)) + return self.set_path(points=points, interval_ms=interval_ms, loop=loop) + + # ========================================================================= + # ROI Mode Methods + # ========================================================================= + + def roi_circle(self, radius: float | None = None) -> Self: + """ + Switch to circle ROI mode for virtual imaging. + + In circle mode, the virtual image integrates over a circular region + centered at the current ROI position (like a virtual bright field detector). + + Parameters + ---------- + radius : float, optional + Radius of the circle in pixels. If not provided, uses current value + or defaults to half the BF radius. + + Returns + ------- + Show4DSTEM + Self for method chaining. + + Examples + -------- + >>> widget.roi_circle(20) # 20px radius circle + >>> widget.roi_circle() # Use default radius + """ + self.roi_mode = "circle" + if radius is not None: + self.roi_radius = float(radius) + return self + + def roi_point(self) -> Self: + """ + Switch to point ROI mode (single-pixel indexing). + + In point mode, the virtual image shows intensity at the exact ROI position. + This is the default mode. + + Returns + ------- + Show4DSTEM + Self for method chaining. + """ + self.roi_mode = "point" + return self + + def roi_square(self, half_size: float | None = None) -> Self: + """ + Switch to square ROI mode for virtual imaging. + + In square mode, the virtual image integrates over a square region + centered at the current ROI position. + + Parameters + ---------- + half_size : float, optional + Half-size of the square in pixels (distance from center to edge). + A half_size of 15 creates a 30x30 pixel square. + If not provided, uses current roi_radius value. + + Returns + ------- + Show4DSTEM + Self for method chaining. + + Examples + -------- + >>> widget.roi_square(15) # 30x30 pixel square (half_size=15) + >>> widget.roi_square() # Use default size + """ + self.roi_mode = "square" + if half_size is not None: + self.roi_radius = float(half_size) + return self + + def roi_annular( + self, inner_radius: float | None = None, outer_radius: float | None = None + ) -> Self: + """ + Set ROI mode to annular (donut-shaped) for ADF/HAADF imaging. + + Parameters + ---------- + inner_radius : float, optional + Inner radius in pixels. If not provided, uses current roi_radius_inner. + outer_radius : float, optional + Outer radius in pixels. If not provided, uses current roi_radius. + + Returns + ------- + Show4DSTEM + Self for method chaining. + + Examples + -------- + >>> widget.roi_annular(20, 50) # ADF: inner=20px, outer=50px + >>> widget.roi_annular(30, 80) # HAADF: larger angles + """ + self.roi_mode = "annular" + if inner_radius is not None: + self.roi_radius_inner = float(inner_radius) + if outer_radius is not None: + self.roi_radius = float(outer_radius) + return self + + def roi_rect( + self, width: float | None = None, height: float | None = None + ) -> Self: + """ + Set ROI mode to rectangular. + + Parameters + ---------- + width : float, optional + Width in pixels. If not provided, uses current roi_width. + height : float, optional + Height in pixels. If not provided, uses current roi_height. + + Returns + ------- + Show4DSTEM + Self for method chaining. + + Examples + -------- + >>> widget.roi_rect(30, 20) # 30px wide, 20px tall + >>> widget.roi_rect(40, 40) # 40x40 rectangle + """ + self.roi_mode = "rect" + if width is not None: + self.roi_width = float(width) + if height is not None: + self.roi_height = float(height) + return self + + def auto_detect_center(self, update_roi: bool = True) -> Self: + """ + Automatically detect BF disk center and radius using centroid. + + This method analyzes the summed diffraction pattern to find the + bright field disk center and estimate its radius. The detected + values are applied to the widget's calibration (center_row, center_col, + bf_radius). + + Parameters + ---------- + update_roi : bool, default True + If True, also update ROI center and recompute cached virtual images. + Set to False during __init__ when ROI is not yet initialized. + + Returns + ------- + Show4DSTEM + Self for method chaining. + + Examples + -------- + >>> widget = Show4DSTEM(data) + >>> widget.auto_detect_center() # Auto-detect and apply + """ + # Sum diffraction patterns over scan positions to find BF disk centroid. + # Single chunked torch float path: works identically on CUDA / MPS / CPU. + # Each chunk casts uint16 → float32 transiently (~600 MB max), accumulates. + data_flat = self._data.reshape(-1, *self._det_shape) + n_pos = data_flat.shape[0] + mean_dp = torch.zeros(self._det_shape, dtype=torch.float32, device=self._device) + # Float32 cast transient = positions × det_h × det_w × 4 bytes; cap at budget. + pos_per_chunk = max(1, _CHUNK_BYTE_BUDGET // max(1, self._det_shape[0] * self._det_shape[1] * 4)) + for i in range(0, n_pos, pos_per_chunk): + mean_dp += data_flat[i:i + pos_per_chunk].sum(dim=0, dtype=torch.float32) + + threshold = mean_dp.mean() + mean_dp.std() + mask = mean_dp > threshold + + total = mask.sum() + if total == 0: + return self + + cx = float((self._det_col_coords * mask).sum() / total) + cy = float((self._det_row_coords * mask).sum() / total) + radius = float(torch.sqrt(total / torch.pi)) + + # Apply detected values + self.center_col = cx + self.center_row = cy + self.bf_radius = radius + + if update_roi: + # Also update ROI to center + self.roi_center_col = cx + self.roi_center_row = cy + # Recompute cached virtual images with new calibration + self._precompute_common_virtual_images() + + return self + + def _get_frame(self, row: int, col: int) -> np.ndarray: + """Get single diffraction frame at position (row, col) as numpy array.""" + if self._data is None: + return np.zeros((self.det_rows, self.det_cols), dtype=np.float32) + data = self._frame_data + if data.ndim == 3: + idx = row * self.shape_cols + col + return data[idx].cpu().numpy() + else: + return data[row, col].cpu().numpy() + + def _apply_scale_mode(self, data: np.ndarray, mode: str) -> np.ndarray: + arr = np.asarray(data, dtype=np.float32) + if mode == "log": + return np.log1p(np.maximum(arr, 0.0)).astype(np.float32) + return arr.astype(np.float32) + + def _slider_range( + self, + data_min: float, + data_max: float, + vmin_pct: float, + vmax_pct: float, + ) -> tuple[float, float]: + v0 = float(max(0.0, min(100.0, vmin_pct))) + v1 = float(max(0.0, min(100.0, vmax_pct))) + if v1 < v0: + v0, v1 = v1, v0 + rng = float(data_max - data_min) + return ( + float(data_min + (v0 / 100.0) * rng), + float(data_min + (v1 / 100.0) * rng), + ) + + def _render_colormap_rgb( + self, + data: np.ndarray, + cmap_name: str, + vmin: float, + vmax: float, + ) -> np.ndarray: + from matplotlib import colormaps + + arr = np.asarray(data, dtype=np.float32) + if vmax <= vmin: + normalized = np.zeros_like(arr, dtype=np.float32) + else: + normalized = np.clip((arr - vmin) / (vmax - vmin), 0.0, 1.0) + rgba = colormaps.get_cmap(cmap_name)(normalized) + return (rgba[..., :3] * 255).astype(np.uint8) + + def _get_virtual_image_array(self) -> np.ndarray: + if not self.virtual_image_bytes: + return np.zeros((self.shape_rows, self.shape_cols), dtype=np.float32) + arr = np.frombuffer(self.virtual_image_bytes, dtype=np.float32) + expected = self.shape_rows * self.shape_cols + if arr.size != expected: + return np.zeros((self.shape_rows, self.shape_cols), dtype=np.float32) + return arr.reshape(self.shape_rows, self.shape_cols).copy() + + def _get_vi_roi_dp_array(self) -> np.ndarray | None: + if self.vi_roi_mode == "off": + return None + self._compute_vi_roi_dp() + if not self.vi_roi_dp_bytes: + return None + arr = np.frombuffer(self.vi_roi_dp_bytes, dtype=np.float32) + expected = self.det_rows * self.det_cols + if arr.size != expected: + return None + return arr.reshape(self.det_rows, self.det_cols).copy() + + def _fft_enhanced_range(self, mag: np.ndarray) -> tuple[float, float]: + arr = np.asarray(mag, dtype=np.float32).copy() + if arr.size == 0: + return 0.0, 0.0 + center_row = arr.shape[0] // 2 + center_col = arr.shape[1] // 2 + neighbors = [] + if center_col - 1 >= 0: + neighbors.append(arr[center_row, center_col - 1]) + if center_col + 1 < arr.shape[1]: + neighbors.append(arr[center_row, center_col + 1]) + if center_row - 1 >= 0: + neighbors.append(arr[center_row - 1, center_col]) + if center_row + 1 < arr.shape[0]: + neighbors.append(arr[center_row + 1, center_col]) + if neighbors: + arr[center_row, center_col] = float(np.mean(neighbors)) + dmin = float(arr.min()) + dmax = float(arr.max()) + if dmax <= dmin: + return dmin, dmax + pmax = float(np.percentile(arr, 99.9)) + if pmax <= dmin: + pmax = dmax + return dmin, pmax + + def _render_dp_rgb(self) -> tuple[np.ndarray, dict]: + vi_roi_arr = self._get_vi_roi_dp_array() + if vi_roi_arr is not None: + raw = vi_roi_arr + source = "vi_roi_dp" + else: + raw = self._get_frame(self.pos_row, self.pos_col).astype(np.float32) + source = "single_frame" + + scale_mode = self.dp_scale_mode + scaled = self._apply_scale_mode(raw, scale_mode) + data_min = float(scaled.min()) if scaled.size else 0.0 + data_max = float(scaled.max()) if scaled.size else 0.0 + if self.dp_vmin is not None and self.dp_vmax is not None: + vmin = float(self._apply_scale_mode( + np.array([max(self.dp_vmin, 0)], dtype=np.float32), scale_mode + )[0]) + vmax = float(self._apply_scale_mode( + np.array([max(self.dp_vmax, 0)], dtype=np.float32), scale_mode + )[0]) + else: + vmin, vmax = self._slider_range(data_min, data_max, self.dp_vmin_pct, self.dp_vmax_pct) + rgb = self._render_colormap_rgb(scaled, self.dp_colormap, vmin, vmax) + metadata = { + "source": source, + "colormap": self.dp_colormap, + "scale_mode": scale_mode, + "vmin_pct": float(self.dp_vmin_pct), + "vmax_pct": float(self.dp_vmax_pct), + "vmin": float(vmin), + "vmax": float(vmax), + } + return rgb, metadata + + def _render_virtual_rgb(self) -> tuple[np.ndarray, dict]: + raw = self._get_virtual_image_array() + scaled = self._apply_scale_mode(raw, self.vi_scale_mode) + data_min = float(scaled.min()) if scaled.size else 0.0 + data_max = float(scaled.max()) if scaled.size else 0.0 + if self.vi_vmin is not None and self.vi_vmax is not None: + vmin = float(self._apply_scale_mode( + np.array([max(self.vi_vmin, 0)], dtype=np.float32), self.vi_scale_mode + )[0]) + vmax = float(self._apply_scale_mode( + np.array([max(self.vi_vmax, 0)], dtype=np.float32), self.vi_scale_mode + )[0]) + else: + vmin, vmax = self._slider_range(data_min, data_max, self.vi_vmin_pct, self.vi_vmax_pct) + rgb = self._render_colormap_rgb(scaled, self.vi_colormap, vmin, vmax) + metadata = { + "colormap": self.vi_colormap, + "scale_mode": self.vi_scale_mode, + "vmin_pct": float(self.vi_vmin_pct), + "vmax_pct": float(self.vi_vmax_pct), + "vmin": float(vmin), + "vmax": float(vmax), + } + return rgb, metadata + + def _render_fft_rgb(self) -> tuple[np.ndarray, dict]: + virtual_raw = self._get_virtual_image_array() + fft = np.fft.fftshift(np.fft.fft2(virtual_raw)) + mag = np.abs(fft).astype(np.float32) + scaled = self._apply_scale_mode(mag, self.fft_scale_mode) + if self.fft_auto: + display_min, display_max = self._fft_enhanced_range(scaled) + else: + display_min = float(scaled.min()) if scaled.size else 0.0 + display_max = float(scaled.max()) if scaled.size else 0.0 + vmin, vmax = self._slider_range(display_min, display_max, self.fft_vmin_pct, self.fft_vmax_pct) + rgb = self._render_colormap_rgb(scaled, self.fft_colormap, vmin, vmax) + metadata = { + "colormap": self.fft_colormap, + "scale_mode": self.fft_scale_mode, + "auto": bool(self.fft_auto), + "vmin_pct": float(self.fft_vmin_pct), + "vmax_pct": float(self.fft_vmax_pct), + "vmin": float(vmin), + "vmax": float(vmax), + } + return rgb, metadata + + _EXPORT_VIEWS = ("diffraction", "virtual", "fft", "all") + _EXPORT_FORMATS = ("png", "pdf") + + def _validate_export_view(self, view: str | None) -> str: + view_key = (view or "all").strip().lower() + if view_key not in self._EXPORT_VIEWS: + raise ValueError(f"Unsupported view '{view}'. Supported: {', '.join(self._EXPORT_VIEWS)}") + return view_key + + def _validate_frame_idx(self, frame_idx: int | None) -> int: + if frame_idx is None: + return int(self.frame_idx) + idx = int(frame_idx) + if idx < 0 or idx >= self.n_frames: + raise ValueError( + f"frame_idx={idx} is out of range [0, {self.n_frames - 1}]" + ) + return idx + + def _validate_position(self, position: tuple[int, int] | None) -> tuple[int, int]: + if position is None: + return int(self.pos_row), int(self.pos_col) + if len(position) != 2: + raise ValueError( + "position must be a (row, col) tuple with exactly two values" + ) + row = int(position[0]) + col = int(position[1]) + if row < 0 or row >= self.shape_rows or col < 0 or col >= self.shape_cols: + raise ValueError( + f"position=({row}, {col}) is out of range for " + f"scan_shape=({self.shape_rows}, {self.shape_cols})" + ) + return row, col + + def _resolve_export_format(self, path: pathlib.Path, fmt: str | None) -> str: + resolved = (fmt or path.suffix.lstrip(".") or "png").strip().lower() + if resolved not in self._EXPORT_FORMATS: + raise ValueError(f"Unsupported format '{resolved}'. Supported: {', '.join(self._EXPORT_FORMATS)}") + return resolved + + @staticmethod + def _round_to_nice_value(value: float) -> float: + if value <= 0: + return 1.0 + magnitude = 10 ** math.floor(math.log10(value)) + normalized = value / magnitude + if normalized < 1.5: + return float(magnitude) + if normalized < 3.5: + return float(2 * magnitude) + if normalized < 7.5: + return float(5 * magnitude) + return float(10 * magnitude) + + def _format_scale_label(self, value: float, unit: str) -> str: + nice = self._round_to_nice_value(value) + if unit == "Å": + if nice >= 10: + return f"{int(round(nice / 10))} nm" + if nice >= 1: + return f"{int(round(nice))} Å" + return f"{nice:.2f} Å" + if unit == "mrad": + if nice >= 1000: + return f"{int(round(nice / 1000))} rad" + if nice >= 1: + return f"{int(round(nice))} mrad" + return f"{nice:.2f} mrad" + if nice >= 1: + return f"{int(round(nice))} px" + return f"{nice:.1f} px" + + @staticmethod + def _draw_crosshair(draw, x: float, y: float, size: float, color, width: int) -> None: + draw.line([(x - size, y), (x + size, y)], fill=color, width=width) + draw.line([(x, y - size), (x, y + size)], fill=color, width=width) + + def _draw_scalebar_overlay(self, image, pixel_size: float, unit: str) -> None: + from PIL import ImageDraw, ImageFont + + if pixel_size <= 0: + return + + draw = ImageDraw.Draw(image, mode="RGBA") + font = ImageFont.load_default() + width, height = image.size + margin = max(8, int(min(width, height) * 0.04)) + thickness = max(2, int(height * 0.01)) + target_bar_px = max(36, int(width * 0.15)) + target_physical = float(target_bar_px) * float(pixel_size) + nice_physical = self._round_to_nice_value(target_physical) + bar_px = max(12, int(round(nice_physical / float(pixel_size)))) + bar_px = min(bar_px, max(12, int(width * 0.8))) + + x1 = width - margin + x0 = x1 - bar_px + y1 = height - margin + y0 = y1 - thickness + + draw.rectangle([(x0 + 1, y0 + 1), (x1 + 1, y1 + 1)], fill=(0, 0, 0, 180)) + draw.rectangle([(x0, y0), (x1, y1)], fill=(255, 255, 255, 255)) + + label = self._format_scale_label(nice_physical, unit) + label_bbox = draw.textbbox((0, 0), label, font=font) + label_w = label_bbox[2] - label_bbox[0] + label_h = label_bbox[3] - label_bbox[1] + tx = x0 + (bar_px - label_w) / 2 + ty = y0 - label_h - 4 + draw.text((tx + 1, ty + 1), label, fill=(0, 0, 0, 220), font=font) + draw.text((tx, ty), label, fill=(255, 255, 255, 255), font=font) + + zoom_label = "1.0x" + zoom_bbox = draw.textbbox((0, 0), zoom_label, font=font) + zoom_h = zoom_bbox[3] - zoom_bbox[1] + zx = margin + zy = height - margin - zoom_h + draw.text((zx + 1, zy + 1), zoom_label, fill=(0, 0, 0, 220), font=font) + draw.text((zx, zy), zoom_label, fill=(255, 255, 255, 255), font=font) + + def _draw_dp_overlays(self, image) -> None: + from PIL import ImageDraw + + draw = ImageDraw.Draw(image, mode="RGBA") + width, height = image.size + scale_x = float(width) / float(max(1, self.det_cols)) + scale_y = float(height) / float(max(1, self.det_rows)) + cx = float(self.roi_center_col) * scale_x + cy = float(self.roi_center_row) * scale_y + + if self.roi_active and self.roi_mode != "point": + stroke = (0, 220, 0, 240) + fill = (0, 220, 0, 45) + if self.roi_mode == "circle": + rx = float(self.roi_radius) * scale_x + ry = float(self.roi_radius) * scale_y + draw.ellipse([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) + elif self.roi_mode == "square": + rx = float(self.roi_radius) * scale_x + ry = float(self.roi_radius) * scale_y + draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) + elif self.roi_mode == "rect": + rx = (float(self.roi_width) / 2.0) * scale_x + ry = (float(self.roi_height) / 2.0) * scale_y + draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) + elif self.roi_mode == "annular": + outer_rx = float(self.roi_radius) * scale_x + outer_ry = float(self.roi_radius) * scale_y + inner_rx = float(self.roi_radius_inner) * scale_x + inner_ry = float(self.roi_radius_inner) * scale_y + draw.ellipse( + [(cx - outer_rx, cy - outer_ry), (cx + outer_rx, cy + outer_ry)], + outline=stroke, + fill=fill, + width=2, + ) + draw.ellipse( + [(cx - inner_rx, cy - inner_ry), (cx + inner_rx, cy + inner_ry)], + outline=stroke, + fill=(0, 0, 0, 0), + width=2, + ) + + marker_color = (0, 220, 0, 255) if self.roi_active else (255, 100, 100, 255) + self._draw_crosshair(draw, cx, cy, size=max(6, int(min(width, height) * 0.03)), color=marker_color, width=2) + + if len(self.profile_line) == 2: + p0, p1 = self.profile_line[0], self.profile_line[1] + x0 = float(p0["col"]) * scale_x + y0 = float(p0["row"]) * scale_y + x1 = float(p1["col"]) * scale_x + y1 = float(p1["row"]) * scale_y + draw.line([(x0, y0), (x1, y1)], fill=(0, 200, 255, 240), width=max(1, int(self.profile_width))) + r = 3 + draw.ellipse([(x0 - r, y0 - r), (x0 + r, y0 + r)], fill=(0, 200, 255, 255)) + draw.ellipse([(x1 - r, y1 - r), (x1 + r, y1 + r)], fill=(0, 200, 255, 255)) + + def _draw_vi_overlays(self, image) -> None: + from PIL import ImageDraw + + draw = ImageDraw.Draw(image, mode="RGBA") + width, height = image.size + scale_x = float(width) / float(max(1, self.shape_cols)) + scale_y = float(height) / float(max(1, self.shape_rows)) + + px = float(self.pos_col) * scale_x + py = float(self.pos_row) * scale_y + self._draw_crosshair( + draw, + px, + py, + size=max(6, int(min(width, height) * 0.03)), + color=(255, 100, 100, 240), + width=2, + ) + + if self.vi_roi_mode == "off": + return + + cx = float(self.vi_roi_center_col) * scale_x + cy = float(self.vi_roi_center_row) * scale_y + stroke = (180, 80, 255, 240) + fill = (180, 80, 255, 45) + if self.vi_roi_mode == "circle": + rx = float(self.vi_roi_radius) * scale_x + ry = float(self.vi_roi_radius) * scale_y + draw.ellipse([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) + elif self.vi_roi_mode == "square": + rx = float(self.vi_roi_radius) * scale_x + ry = float(self.vi_roi_radius) * scale_y + draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) + elif self.vi_roi_mode == "rect": + rx = (float(self.vi_roi_width) / 2.0) * scale_x + ry = (float(self.vi_roi_height) / 2.0) * scale_y + draw.rectangle([(cx - rx, cy - ry), (cx + rx, cy + ry)], outline=stroke, fill=fill, width=2) + + self._draw_crosshair( + draw, + cx, + cy, + size=max(6, int(min(width, height) * 0.03)), + color=(180, 80, 255, 240), + width=2, + ) + + def _decorate_panel( + self, + image, + panel_key: str, + include_overlays: bool, + include_scalebar: bool, + ): + out = image.copy() + if include_overlays: + if panel_key == "diffraction": + self._draw_dp_overlays(out) + elif panel_key == "virtual": + self._draw_vi_overlays(out) + if include_scalebar: + if panel_key == "diffraction": + unit = "mrad" if self.k_calibrated else "px" + self._draw_scalebar_overlay(out, float(self.k_pixel_size), unit) + elif panel_key == "virtual": + self._draw_scalebar_overlay(out, float(self.pixel_size), "Å") + return out + + def _render_panel_image( + self, + panel_key: str, + include_overlays: bool, + include_scalebar: bool, + ) -> tuple[Any, dict[str, Any]]: + from PIL import Image + + if panel_key == "diffraction": + rgb, render_meta = self._render_dp_rgb() + elif panel_key == "virtual": + rgb, render_meta = self._render_virtual_rgb() + elif panel_key == "fft": + rgb, render_meta = self._render_fft_rgb() + else: + raise ValueError(f"Unsupported panel {panel_key!r}. Valid options: 'diffraction', 'virtual', 'fft', 'all'.") + + panel = Image.fromarray(rgb, mode="RGB") + panel = self._decorate_panel(panel, panel_key, include_overlays, include_scalebar) + return panel, render_meta + + def _compose_horizontal(self, panels: list[Any]): + from PIL import Image + + height = max(panel.height for panel in panels) + width = sum(panel.width for panel in panels) + composite = Image.new("RGB", (width, height), color=(0, 0, 0)) + x0 = 0 + for panel in panels: + composite.paste(panel, (x0, 0)) + x0 += panel.width + return composite + + def _calibration_metadata(self) -> dict[str, Any]: + return { + "pixel_size_angstrom": float(self.pixel_size), + "pixel_size_unit": "Å/px", + "k_pixel_size": float(self.k_pixel_size), + "k_pixel_size_unit": "mrad/px" if self.k_calibrated else "px/px", + "k_calibrated": bool(self.k_calibrated), + "center_row": float(self.center_row), + "center_col": float(self.center_col), + "bf_radius": float(self.bf_radius), + } + + def _roi_metadata(self) -> dict[str, Any]: + return { + "active": bool(self.roi_active), + "mode": self.roi_mode, + "center_row": float(self.roi_center_row), + "center_col": float(self.roi_center_col), + "radius": float(self.roi_radius), + "radius_inner": float(self.roi_radius_inner), + "width": float(self.roi_width), + "height": float(self.roi_height), + } + + def _vi_roi_metadata(self) -> dict[str, Any]: + return { + "mode": self.vi_roi_mode, + "center_row": float(self.vi_roi_center_row), + "center_col": float(self.vi_roi_center_col), + "radius": float(self.vi_roi_radius), + "width": float(self.vi_roi_width), + "height": float(self.vi_roi_height), + } + + def _build_image_export_metadata( + self, + export_path: pathlib.Path, + view_key: str, + fmt: str, + render_meta: dict[str, Any], + include_overlays: bool, + include_scalebar: bool, + export_kind: str, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + metadata: dict[str, Any] = { + **build_json_header("Show4DSTEM"), + "view": view_key, + "format": fmt, + "export_kind": export_kind, + "path": str(export_path), + "position": {"row": int(self.pos_row), "col": int(self.pos_col)}, + "frame_idx": int(self.frame_idx), + "n_frames": int(self.n_frames), + "scan_shape": {"rows": int(self.shape_rows), "cols": int(self.shape_cols)}, + "detector_shape": {"rows": int(self.det_rows), "cols": int(self.det_cols)}, + "roi": self._roi_metadata(), + "vi_roi": self._vi_roi_metadata(), + "calibration": self._calibration_metadata(), + "display": render_meta, + "include_overlays": bool(include_overlays), + "include_scalebar": bool(include_scalebar), + } + if extra: + metadata.update(extra) + return metadata + + def save_image( + self, + path: str | pathlib.Path, + view: str | None = None, + position: tuple[int, int] | None = None, + frame_idx: int | None = None, + format: str | None = None, + include_metadata: bool = True, + metadata_path: str | pathlib.Path | None = None, + include_overlays: bool | None = None, + include_scalebar: bool | None = None, + restore_state: bool = True, + dpi: int | None = None, + ) -> pathlib.Path: + """ + Save the current visualization as PNG or PDF. + + Parameters + ---------- + path : str or pathlib.Path + Output image path. + view : str, optional + One of: "diffraction", "virtual", "fft", "all". + position : tuple[int, int], optional + Temporary scan position override as (row, col) for this export. + frame_idx : int, optional + Temporary frame index override for 5D data. + format : str, optional + "png" or "pdf". If omitted, inferred from file extension. + include_metadata : bool, default True + If True, writes JSON metadata next to the image. + metadata_path : str or pathlib.Path, optional + Override metadata JSON path. + include_overlays : bool, default True + Draw ROI/profile/crosshair overlays on exported panels. + include_scalebar : bool, default True + Draw panel scale bars on exported panels. + restore_state : bool, default True + If True, temporary position/frame overrides are reverted after export. + dpi : int, optional + Export DPI metadata. + + Returns + ------- + pathlib.Path + The written image path. + """ + from PIL import Image + + export_path = pathlib.Path(path) + view_key = self._validate_export_view(view) + fmt = self._resolve_export_format(export_path, format) + dpi_value = 300 if dpi is None else int(dpi) + overlays_enabled = True if include_overlays is None else bool(include_overlays) + scalebar_enabled = True if include_scalebar is None else bool(include_scalebar) + if dpi_value <= 0: + raise ValueError(f"dpi must be > 0, got {dpi_value}") + + export_path.parent.mkdir(parents=True, exist_ok=True) + + prev_row, prev_col = self.pos_row, self.pos_col + prev_frame = self.frame_idx + meta_path: pathlib.Path | None = None + + try: + if frame_idx is not None: + self.frame_idx = self._validate_frame_idx(frame_idx) + if position is not None: + row, col = self._validate_position(position) + self.pos_row = row + self.pos_col = col + + if view_key == "diffraction": + image, dp_meta = self._render_panel_image( + "diffraction", overlays_enabled, scalebar_enabled + ) + render_meta = {"diffraction": dp_meta} + elif view_key == "virtual": + image, vi_meta = self._render_panel_image( + "virtual", overlays_enabled, scalebar_enabled + ) + render_meta = {"virtual": vi_meta} + elif view_key == "fft": + image, fft_meta = self._render_panel_image( + "fft", overlays_enabled, scalebar_enabled + ) + render_meta = {"fft": fft_meta} + else: + panel_images = [] + render_meta = {} + dp_img, dp_meta = self._render_panel_image( + "diffraction", overlays_enabled, scalebar_enabled + ) + vi_img, vi_meta = self._render_panel_image( + "virtual", overlays_enabled, scalebar_enabled + ) + panel_images.extend([dp_img, vi_img]) + render_meta = {"diffraction": dp_meta, "virtual": vi_meta} + if self.show_fft: + fft_img, fft_meta = self._render_panel_image( + "fft", overlays_enabled, scalebar_enabled + ) + panel_images.append(fft_img) + render_meta["fft"] = fft_meta + image = self._compose_horizontal(panel_images) + + if fmt == "pdf": + Image.init() + image = image.convert("RGB") + image.save(export_path, format="PDF", resolution=dpi_value) + else: + image.save(export_path, format="PNG", dpi=(dpi_value, dpi_value)) + + if include_metadata: + meta_path = ( + pathlib.Path(metadata_path) + if metadata_path is not None + else export_path.with_suffix(".json") + ) + metadata = self._build_image_export_metadata( + export_path=export_path, + view_key=view_key, + fmt=fmt, + render_meta=render_meta, + include_overlays=overlays_enabled, + include_scalebar=scalebar_enabled, + export_kind="single_view_image", + extra={"dpi": int(dpi_value)}, + ) + meta_path.write_text(json.dumps(metadata, indent=2)) + finally: + if restore_state: + self.frame_idx = prev_frame + self.pos_row = prev_row + self.pos_col = prev_col + + return export_path + + def apply_preset(self, name: str) -> Self: + preset_name = str(name).strip().lower() + # Batch all trait writes atomically. Without this, each individual + # trait change fires _on_roi_change, and intermediate states (e.g. mode + # just switched to "annular" but radius_inner still stale from the + # previous preset) compute a wrong mask -> black VI flashes before the + # final correct frame. hold_trait_notifications defers observers until + # all 5 traits have committed. + bf = self.bf_radius + center_row = float(self.center_row) + center_col = float(self.center_col) + self._suppress_roi_recompute = True + try: + if preset_name == "bf": + with self.hold_trait_notifications(): + self.roi_active = True + self.roi_mode = "circle" + self.roi_center_row = center_row + self.roi_center_col = center_col + self.roi_radius = float(max(1.0, bf)) + elif preset_name == "abf": + with self.hold_trait_notifications(): + self.roi_active = True + self.roi_mode = "annular" + self.roi_center_row = center_row + self.roi_center_col = center_col + self.roi_radius_inner = float(max(0.5, bf * 0.5)) + self.roi_radius = float(max(1.0, bf)) + elif preset_name == "adf": + with self.hold_trait_notifications(): + self.roi_active = True + self.roi_mode = "annular" + self.roi_center_row = center_row + self.roi_center_col = center_col + self.roi_radius_inner = float(max(1.0, bf)) + self.roi_radius = float(max(bf + 1.0, bf * 2.0)) + elif preset_name == "haadf": + with self.hold_trait_notifications(): + self.roi_active = True + self.roi_mode = "annular" + self.roi_center_row = center_row + self.roi_center_col = center_col + self.roi_radius_inner = float(max(1.0, bf * 2.0)) + self.roi_radius = float(max(bf * 2.0 + 1.0, bf * 4.0)) + else: + raise ValueError( + f"Unknown preset {name!r}. Choices: 'bf', 'abf', 'adf', 'haadf'." + ) + finally: + self._suppress_roi_recompute = False + # Single recompute with final, consistent state. + self._compute_virtual_image_from_roi() + return self + + + def _normalize_frame(self, frame: np.ndarray) -> np.ndarray: + mode = self.dp_scale_mode + scaled = self._apply_scale_mode(frame, mode) + if self.dp_vmin is not None and self.dp_vmax is not None: + fmin = float(self._apply_scale_mode( + np.array([max(self.dp_vmin, 0)], dtype=np.float32), mode + )[0]) + fmax = float(self._apply_scale_mode( + np.array([max(self.dp_vmax, 0)], dtype=np.float32), mode + )[0]) + else: + fmin = float(scaled.min()) + fmax = float(scaled.max()) + fmin, fmax = self._slider_range(fmin, fmax, self.dp_vmin_pct, self.dp_vmax_pct) + if fmax > fmin: + return np.clip((scaled - fmin) / (fmax - fmin) * 255, 0, 255).astype(np.uint8) + return np.zeros(frame.shape, dtype=np.uint8) + + def _on_gif_export(self, change=None): + if not self._gif_export_requested: + return + self._gif_export_requested = False + self._generate_gif() + + def _generate_gif(self): + import io + + from matplotlib import colormaps + from PIL import Image + + if not self._path_points: + with self.hold_sync(): + self._gif_data = b"" + self._gif_metadata_json = "" + return + + cmap_fn = colormaps.get_cmap(self.dp_colormap) + duration_ms = max(10, self.path_interval_ms) + + pil_frames = [] + for row, col in self._path_points: + row = max(0, min(self.shape_rows - 1, row)) + col = max(0, min(self.shape_cols - 1, col)) + frame = self._get_frame(row, col).astype(np.float32) + normalized = self._normalize_frame(frame) + rgba = cmap_fn(normalized / 255.0) + rgb = (rgba[:, :, :3] * 255).astype(np.uint8) + pil_frames.append(Image.fromarray(rgb)) + + if not pil_frames: + return + + buf = io.BytesIO() + pil_frames[0].save( + buf, + format="GIF", + save_all=True, + append_images=pil_frames[1:], + duration=duration_ms, + loop=0, + ) + metadata = { + **build_json_header("Show4DSTEM"), + "view": "diffraction", + "format": "gif", + "export_kind": "path_animation", + "n_frames": int(len(pil_frames)), + "duration_ms": int(duration_ms), + "path_loop": bool(self.path_loop), + "path_points": [{"row": int(row), "col": int(col)} for row, col in self._path_points], + "frame_idx": int(self.frame_idx), + "n_frames_total": int(self.n_frames), + "scan_shape": {"rows": int(self.shape_rows), "cols": int(self.shape_cols)}, + "detector_shape": {"rows": int(self.det_rows), "cols": int(self.det_cols)}, + "calibration": self._calibration_metadata(), + "display": { + "diffraction": { + "colormap": self.dp_colormap, + "scale_mode": self.dp_scale_mode, + "vmin_pct": float(self.dp_vmin_pct), + "vmax_pct": float(self.dp_vmax_pct), + } + }, + } + with self.hold_sync(): + self._gif_metadata_json = json.dumps(metadata, indent=2) + self._gif_data = buf.getvalue() + + def _update_frame(self, change=None): + """Send raw float32 frame to frontend (JS handles scale/colormap).""" + if self._data is None: + return + # Get frame as tensor (stays on device) + data = self._frame_data + if data.ndim == 3: + idx = self.pos_row * self.shape_cols + self.pos_col + frame = data[idx] + else: + frame = data[self.pos_row, self.pos_col] + + # Cast small frame to float32 for stats and JS transfer. Bulk data + # stays in native dtype; only this single 192×192 (~144 KB) frame + # gets promoted. + if frame.dtype != torch.float32: + frame = frame.float() + # Stats compute moved to JS (frontend has frame_bytes; computeStats() in + # js/stats.ts does mean/min/max/std on the Float32Array directly, + # avoiding 4 sync trait round-trips per scan-position click). + self.frame_bytes = frame.cpu().numpy().tobytes() + + def _on_roi_change(self, change=None): + """Recompute virtual image when individual ROI params change. + + High-frequency drag updates use the compound roi_center trait instead. + """ + if not self.roi_active: + return + if getattr(self, "_suppress_roi_recompute", False): + return + self._compute_virtual_image_from_roi() + + def _on_roi_center_change(self, change=None): + """Handle batched roi_center updates from JS (single observer for row+col). + + This is the fast path for drag operations. JS sends [row, col] as a single + compound trait, so only one observer fires per mouse move. + """ + if not self.roi_active: + return + if getattr(self, "_suppress_roi_recompute", False): + return + if change and "new" in change: + row, col = change["new"] + # Sync to individual traits (without triggering _on_roi_change observers) + self.unobserve(self._on_roi_change, names=["roi_center_col", "roi_center_row"]) + self.roi_center_row = row + self.roi_center_col = col + self.observe(self._on_roi_change, names=["roi_center_col", "roi_center_row"]) + self._compute_virtual_image_from_roi() + + def _on_vi_roi_center_change(self, change=None): + """Apply compound (row, col) update atomically (avoids split-trait race).""" + if change and "new" in change: + row, col = change["new"] + self.unobserve(self._on_vi_roi_change, names=["vi_roi_center_row", "vi_roi_center_col"]) + self.vi_roi_center_row = float(row) + self.vi_roi_center_col = float(col) + self.observe(self._on_vi_roi_change, names=["vi_roi_center_row", "vi_roi_center_col"]) + if self.vi_roi_mode == "off": + self.vi_roi_dp_bytes = b"" + return + self._compute_vi_roi_dp() + + def _on_vi_roi_change(self, change=None): + """Recompute reduced DP when VI ROI or reduction changes.""" + if self.vi_roi_mode == "off": + self.vi_roi_dp_bytes = b"" + return + self._compute_vi_roi_dp() + + def _compute_vi_roi_dp(self): + """Reduce diffraction patterns over scan positions inside VI ROI. + + Reduction selected by `vi_roi_reduce`: + - "mean": average DP (size-invariant, default for region-of-interest analysis) + - "sum": total counts (scales with ROI area; use for quantitative integration) + - "max": brightest pixel per detector position across the region + """ + if self._data is None: + return + if self.vi_roi_mode == "circle": + mask = (self._scan_row_coords - self.vi_roi_center_row) ** 2 + (self._scan_col_coords - self.vi_roi_center_col) ** 2 <= self.vi_roi_radius ** 2 + elif self.vi_roi_mode == "square": + half_size = self.vi_roi_radius + mask = (torch.abs(self._scan_row_coords - self.vi_roi_center_row) <= half_size) & (torch.abs(self._scan_col_coords - self.vi_roi_center_col) <= half_size) + elif self.vi_roi_mode == "rect": + half_w = self.vi_roi_width / 2 + half_h = self.vi_roi_height / 2 + mask = (torch.abs(self._scan_row_coords - self.vi_roi_center_row) <= half_h) & (torch.abs(self._scan_col_coords - self.vi_roi_center_col) <= half_w) + else: + return + + n_positions = int(mask.sum()) + if n_positions == 0: + self.vi_roi_dp_bytes = b"" + return + + reduce = self.vi_roi_reduce + data = self._frame_data + # Single chunked torch path. For each scan-row chunk: cast to float32 and + # broadcast-multiply by the mask (no `chunk[row_mask]` slab, which would + # roughly duplicate the chunk in memory when the mask is dense). Sum/mean + # use einsum over scan dims; max masks zero rows then takes amax. + data_4d = data if data.ndim == 4 else data.reshape(self._scan_shape[0], self._scan_shape[1], *self._det_shape) + rows_per_chunk = self._chunk_rows() + if reduce == "sum" or reduce == "mean": + dp = torch.zeros(self._det_shape, dtype=torch.float32, device=self._device) + else: # max + dp = torch.full(self._det_shape, -float("inf"), dtype=torch.float32, device=self._device) + for i in range(0, self._scan_shape[0], rows_per_chunk): + row_mask = mask[i:i + rows_per_chunk] + if not bool(row_mask.any()): + continue + chunk = data_4d[i:i + rows_per_chunk] + if not torch.is_floating_point(chunk): + chunk = chunk.float() + row_mask_f = row_mask.float() + if reduce == "max": + # Outside-mask positions become 0; doesn't affect amax provided + # the data has any non-negative pixels (true for detector counts). + dp = torch.maximum(dp, (chunk * row_mask_f[..., None, None]).amax(dim=(0, 1))) + else: + dp += torch.einsum("rcij,rc->ij", chunk, row_mask_f) + if reduce == "mean": + dp /= float(n_positions) + + self.vi_roi_dp_bytes = dp.cpu().numpy().tobytes() + + def _create_circular_mask(self, cx: float, cy: float, radius: float): + """Create circular mask (boolean tensor on device).""" + mask = (self._det_col_coords - cx) ** 2 + (self._det_row_coords - cy) ** 2 <= radius ** 2 + return mask + + def _create_square_mask(self, cx: float, cy: float, half_size: float): + """Create square mask (boolean tensor on device).""" + mask = (torch.abs(self._det_col_coords - cx) <= half_size) & (torch.abs(self._det_row_coords - cy) <= half_size) + return mask + + def _create_annular_mask( + self, cx: float, cy: float, inner: float, outer: float + ): + """Create annular (donut) mask (boolean tensor on device).""" + dist_sq = (self._det_col_coords - cx) ** 2 + (self._det_row_coords - cy) ** 2 + mask = (dist_sq >= inner ** 2) & (dist_sq <= outer ** 2) + return mask + + def _create_rect_mask(self, cx: float, cy: float, half_width: float, half_height: float): + """Create rectangular mask (boolean tensor on device).""" + mask = (torch.abs(self._det_col_coords - cx) <= half_width) & (torch.abs(self._det_row_coords - cy) <= half_height) + return mask + + def _on_calibration_change(self, change=None): + self._cached_bf_virtual = None + self._cached_abf_virtual = None + self._cached_adf_virtual = None + self._cached_haadf_virtual = None + + def _precompute_common_virtual_images(self): + """Pre-compute BF/ABF/ADF/HAADF virtual image bytes. Annular ranges match + apply_preset() so the cache always hits on preset clicks.""" + cx, cy, bf = self.center_col, self.center_row, self.bf_radius + self._cached_bf_virtual = self._to_float32_bytes( + self._fast_masked_sum(self._create_circular_mask(cx, cy, bf)) + ) + self._cached_abf_virtual = self._to_float32_bytes( + self._fast_masked_sum(self._create_annular_mask(cx, cy, bf * 0.5, bf)) + ) + self._cached_adf_virtual = self._to_float32_bytes( + self._fast_masked_sum(self._create_annular_mask(cx, cy, bf, bf * 2.0)) + ) + self._cached_haadf_virtual = self._to_float32_bytes( + self._fast_masked_sum(self._create_annular_mask(cx, cy, bf * 2.0, bf * 4.0)) + ) + + def _get_cached_preset(self) -> bytes | None: + """Return cached preset bytes if current ROI matches BF/ABF/ADF preset shape.""" + # Must be centered on detector center + if abs(self.roi_center_col - self.center_col) >= 1 or abs(self.roi_center_row - self.center_row) >= 1: + return None + + bf = self.bf_radius + + # BF: circle at bf_radius + if (self.roi_mode == "circle" and abs(self.roi_radius - bf) < 1): + return self._cached_bf_virtual + + # ABF: annular at 0.5*bf to bf + if (self.roi_mode == "annular" and + abs(self.roi_radius_inner - bf * 0.5) < 1 and + abs(self.roi_radius - bf) < 1): + return self._cached_abf_virtual + + # ADF: annular at bf to 2*bf + if (self.roi_mode == "annular" and + abs(self.roi_radius_inner - bf) < 1 and + abs(self.roi_radius - bf * 2.0) < 1): + return self._cached_adf_virtual + + # HAADF: annular at 2*bf to 4*bf + if (self.roi_mode == "annular" and + abs(self.roi_radius_inner - bf * 2.0) < 1 and + abs(self.roi_radius - bf * 4.0) < 1): + return self._cached_haadf_virtual + + return None + + def _virtual_image_for_frame(self, frame_idx: int) -> np.ndarray: + """Compute virtual image for a specific 5D frame without mutating traits. + + Single chunked-torch path matching _fast_masked_sum. + """ + data = self._data[frame_idx] if self.n_frames > 1 else self._data + cx, cy = self.roi_center_col, self.roi_center_row + if self.roi_mode == "circle" and self.roi_radius > 0: + mask = self._create_circular_mask(cx, cy, self.roi_radius) + elif self.roi_mode == "square" and self.roi_radius > 0: + mask = self._create_square_mask(cx, cy, self.roi_radius) + elif self.roi_mode == "annular" and self.roi_radius > 0: + mask = self._create_annular_mask(cx, cy, self.roi_radius_inner, self.roi_radius) + elif self.roi_mode == "rect" and self.roi_width > 0 and self.roi_height > 0: + mask = self._create_rect_mask(cx, cy, self.roi_width / 2, self.roi_height / 2) + else: + row = int(max(0, min(round(cy), self._det_shape[0] - 1))) + col = int(max(0, min(round(cx), self._det_shape[1] - 1))) + if data.ndim == 4: + vi = data[:, :, row, col] + else: + vi = data[:, row, col].reshape(self._scan_shape) + return vi.cpu().numpy().astype(np.float32, copy=False) + data_4d = data if data.ndim == 4 else data.reshape(self._scan_shape[0], self._scan_shape[1], *self._det_shape) + mask_f = mask.float() + rows_per_chunk = self._chunk_rows() + out = torch.zeros(self._scan_shape, dtype=torch.float32, device=self._device) + for i in range(0, data_4d.shape[0], rows_per_chunk): + chunk = data_4d[i:i + rows_per_chunk] + if not torch.is_floating_point(chunk): + chunk = chunk.float() + out[i:i + rows_per_chunk] = torch.tensordot(chunk, mask_f, dims=([2, 3], [0, 1])) + return out.cpu().numpy().astype(np.float32, copy=False) + + def _chunk_rows(self) -> int: + """Pick rows-per-chunk so float32 transient stays under _CHUNK_BYTE_BUDGET. + + Float32 cast of one chunk = rows × scan_cols × det_h × det_w × 4 bytes. + Selected slabs (e.g. vi_roi reduce) inherit the same per-row budget. + """ + per_row = self._scan_shape[1] * self._det_shape[0] * self._det_shape[1] * 4 + return max(1, _CHUNK_BYTE_BUDGET // max(1, per_row)) + + def _fast_masked_sum(self, mask: torch.Tensor) -> torch.Tensor: + """Sum data over scan positions weighted by detector mask. + + Chunked tensordot. Per-chunk float32 cast bounded by _CHUNK_BYTE_BUDGET. + Identical math on CUDA / MPS / CPU. + """ + data = self._frame_data + if data.ndim == 3: + data_4d = data.reshape(self._scan_shape[0], self._scan_shape[1], *self._det_shape) + else: + data_4d = data + # Single chunked torch path. Per scan-row chunk: cast to float32, contract + # with mask via tensordot. Transient memory bounded by chunk size. Same + # code on CUDA / MPS / CPU. Identical results regardless of device. + mask_f = mask.float() + n_rows = data_4d.shape[0] + out = torch.zeros(self._scan_shape, dtype=torch.float32, device=self._device) + # Convert positions chunk size to row chunks based on scan width. + rows_per_chunk = self._chunk_rows() + for i in range(0, n_rows, rows_per_chunk): + chunk = data_4d[i:i + rows_per_chunk] + if not torch.is_floating_point(chunk): + chunk = chunk.float() + out[i:i + rows_per_chunk] = torch.tensordot(chunk, mask_f, dims=([2, 3], [0, 1])) + return out + + def _to_float32_bytes(self, arr: torch.Tensor) -> bytes: + """Convert tensor (any numeric dtype) to float32 bytes for JS transfer. + + Cast to float32 only at the small output. Integer reductions (uint16 sums, + int64 accumulators) get promoted here so the multi-GB raw data never gets + copied to float. Stats (min/max/mean/std) are computed JS-side from the + same Float32Array — keeping them out of separate traits avoids a + comm-message ordering race where bytes from click N arrive with stats + from click N-1, producing a wrong colormap normalization (uniform white + flash on rapid preset switching). + """ + if arr.dtype != torch.float32: + arr = arr.float() + return arr.cpu().numpy().tobytes() + + def _compute_virtual_image_from_roi(self): + """Compute virtual image based on ROI mode.""" + if self._data is None: + return + cached = self._get_cached_preset() + if cached is not None: + self.virtual_image_bytes = cached + return + + cx, cy = self.roi_center_col, self.roi_center_row + + if self.roi_mode == "circle" and self.roi_radius > 0: + mask = self._create_circular_mask(cx, cy, self.roi_radius) + elif self.roi_mode == "square" and self.roi_radius > 0: + mask = self._create_square_mask(cx, cy, self.roi_radius) + elif self.roi_mode == "annular" and self.roi_radius > 0: + mask = self._create_annular_mask(cx, cy, self.roi_radius_inner, self.roi_radius) + elif self.roi_mode == "rect" and self.roi_width > 0 and self.roi_height > 0: + mask = self._create_rect_mask(cx, cy, self.roi_width / 2, self.roi_height / 2) + else: + # Point mode: single-pixel indexing + row = int(max(0, min(round(cy), self._det_shape[0] - 1))) + col = int(max(0, min(round(cx), self._det_shape[1] - 1))) + data = self._frame_data + if data.ndim == 4: + virtual_image = data[:, :, row, col] + else: + virtual_image = data[:, row, col].reshape(self._scan_shape) + self.virtual_image_bytes = self._to_float32_bytes(virtual_image) + return + + self.virtual_image_bytes = self._to_float32_bytes(self._fast_masked_sum(mask)) + diff --git a/widget/src/quantem/widget/state.py b/widget/src/quantem/widget/state.py new file mode 100644 index 00000000..c1750dc2 --- /dev/null +++ b/widget/src/quantem/widget/state.py @@ -0,0 +1,46 @@ +import importlib.metadata +import json +import pathlib +from typing import Any + +JSON_METADATA_VERSION = "1.0" + + +def resolve_widget_version() -> str: + try: + return importlib.metadata.version("quantem-widget") + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +def build_json_header(widget_name: str) -> dict[str, Any]: + return { + "metadata_version": JSON_METADATA_VERSION, + "widget_name": widget_name, + "widget_version": resolve_widget_version(), + } + + +def wrap_state_dict(widget_name: str, state: dict[str, Any]) -> dict[str, Any]: + envelope = build_json_header(widget_name) + envelope["state"] = state + return envelope + + +def unwrap_state_payload(payload: dict[str, Any], *, require_envelope: bool = False) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("State payload must be a dict.") + if "state" in payload: + state = payload["state"] + if not isinstance(state, dict): + raise ValueError("State envelope field 'state' must be a dict.") + return state + if require_envelope: + raise ValueError("State JSON file must be a versioned envelope with top-level 'state'.") + return payload + + +def save_state_file(path: str | pathlib.Path, widget_name: str, state: dict[str, Any]) -> None: + p = pathlib.Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(wrap_state_dict(widget_name, state), indent=2)) diff --git a/widget/tests/test_fft_parity.py b/widget/tests/test_fft_parity.py new file mode 100644 index 00000000..4e38b85f --- /dev/null +++ b/widget/tests/test_fft_parity.py @@ -0,0 +1,200 @@ +"""FFT parity: JS fft1d/fft2d/fftshift line-ported to Python, validated against numpy. + +Why ports instead of running the JS directly: pytest can't drive a TypeScript +module without a Node bridge or browser harness, both of which add fragility +and slow CI. Instead we mirror js/fft.ts:14-82 line-for-line in Python below +and assert against numpy.fft. If the JS algorithm has a bug, the line-port +inherits it and this test fails — surfacing the bug at unit-test speed. + +When js/fft.ts changes, update the ports here in the same commit. The +side-by-side structure makes drift visually obvious during review. +""" +import numpy as np + + +def _next_pow2(n: int) -> int: + p = 1 + while p < n: + p <<= 1 + return p + + +def _js_fft1d(real: np.ndarray, imag: np.ndarray, inverse: bool = False) -> None: + """Line-port of js/fft.ts fft1d. In-place. Iterative radix-2 Cooley-Tukey.""" + n = real.size + if n <= 1: + return + # Bit-reversal permutation. + j = 0 + for i in range(n - 1): + if i < j: + real[i], real[j] = real[j], real[i] + imag[i], imag[j] = imag[j], imag[i] + k = n >> 1 + while k <= j: + j -= k + k >>= 1 + j += k + sign = 1 if inverse else -1 + length = 2 + while length <= n: + half = length >> 1 + angle = (sign * 2 * np.pi) / length + w_real = np.cos(angle) + w_imag = np.sin(angle) + for i in range(0, n, length): + cur_real = 1.0 + cur_imag = 0.0 + for k in range(half): + even = i + k + odd = i + k + half + t_real = cur_real * real[odd] - cur_imag * imag[odd] + t_imag = cur_real * imag[odd] + cur_imag * real[odd] + real[odd] = real[even] - t_real + imag[odd] = imag[even] - t_imag + real[even] += t_real + imag[even] += t_imag + new_real = cur_real * w_real - cur_imag * w_imag + cur_imag = cur_real * w_imag + cur_imag * w_real + cur_real = new_real + length <<= 1 + if inverse: + real /= n + imag /= n + + +def _js_fft2d(real: np.ndarray, imag: np.ndarray, width: int, height: int, inverse: bool = False) -> None: + """Line-port of js/fft.ts fft2d. In-place on (height*width) flattened arrays.""" + padded_w = _next_pow2(width) + padded_h = _next_pow2(height) + needs_padding = padded_w != width or padded_h != height + if needs_padding: + work_real = np.zeros(padded_w * padded_h, dtype=np.float64) + work_imag = np.zeros(padded_w * padded_h, dtype=np.float64) + for y in range(height): + for x in range(width): + work_real[y * padded_w + x] = real[y * width + x] + work_imag[y * padded_w + x] = imag[y * width + x] + else: + work_real = real + work_imag = imag + row_real = np.empty(padded_w, dtype=np.float64) + row_imag = np.empty(padded_w, dtype=np.float64) + for y in range(padded_h): + offset = y * padded_w + row_real[:] = work_real[offset:offset + padded_w] + row_imag[:] = work_imag[offset:offset + padded_w] + _js_fft1d(row_real, row_imag, inverse) + work_real[offset:offset + padded_w] = row_real + work_imag[offset:offset + padded_w] = row_imag + col_real = np.empty(padded_h, dtype=np.float64) + col_imag = np.empty(padded_h, dtype=np.float64) + for x in range(padded_w): + for y in range(padded_h): + col_real[y] = work_real[y * padded_w + x] + col_imag[y] = work_imag[y * padded_w + x] + _js_fft1d(col_real, col_imag, inverse) + for y in range(padded_h): + work_real[y * padded_w + x] = col_real[y] + work_imag[y * padded_w + x] = col_imag[y] + if needs_padding: + for y in range(height): + for x in range(width): + real[y * width + x] = work_real[y * padded_w + x] + imag[y * width + x] = work_imag[y * padded_w + x] + + +def _js_fftshift(data: np.ndarray, width: int, height: int) -> None: + """Line-port of js/fft.ts fftshift. In-place.""" + half_w = width >> 1 + half_h = height >> 1 + temp = np.empty(width * height, dtype=data.dtype) + for y in range(height): + for x in range(width): + temp[((y + half_h) % height) * width + ((x + half_w) % width)] = data[y * width + x] + data[:] = temp + + +# --------------------------------------------------------------------------- + +def test_fft1d_matches_numpy_pow2(): + """1D FFT on power-of-2 input matches numpy.fft.fft.""" + rng = np.random.default_rng(0) + n = 64 + x = rng.standard_normal(n) + real = x.astype(np.float64).copy() + imag = np.zeros(n, dtype=np.float64) + _js_fft1d(real, imag, inverse=False) + js = real + 1j * imag + expected = np.fft.fft(x) + np.testing.assert_allclose(js, expected, atol=1e-9) + + +def test_fft1d_inverse_roundtrip(): + """fft1d(fft1d(x), inverse=True) ≈ x.""" + rng = np.random.default_rng(1) + n = 128 + x = rng.standard_normal(n) + real = x.astype(np.float64).copy() + imag = np.zeros(n, dtype=np.float64) + _js_fft1d(real, imag, inverse=False) + _js_fft1d(real, imag, inverse=True) + np.testing.assert_allclose(real, x, atol=1e-9) + np.testing.assert_allclose(imag, np.zeros(n), atol=1e-9) + + +def test_fft2d_matches_numpy_pow2(): + """2D FFT on power-of-2 dims matches numpy.fft.fft2.""" + rng = np.random.default_rng(2) + h, w = 32, 64 + img = rng.standard_normal((h, w)) + real = img.astype(np.float64).flatten() + imag = np.zeros(h * w, dtype=np.float64) + _js_fft2d(real, imag, w, h, inverse=False) + js = (real + 1j * imag).reshape(h, w) + expected = np.fft.fft2(img) + np.testing.assert_allclose(js, expected, atol=1e-9) + + +def test_fft2d_non_pow2_zero_pads(): + """Non-power-of-2 input gets zero-padded; FFT of padded matches numpy of padded.""" + rng = np.random.default_rng(3) + h, w = 30, 50 + img = rng.standard_normal((h, w)) + real = img.astype(np.float64).flatten() + imag = np.zeros(h * w, dtype=np.float64) + _js_fft2d(real, imag, w, h, inverse=False) + # JS contract: only the (h, w) region of the result is written back to the input arrays. + js = (real + 1j * imag).reshape(h, w) + pw, ph = _next_pow2(w), _next_pow2(h) + padded = np.zeros((ph, pw)) + padded[:h, :w] = img + expected = np.fft.fft2(padded)[:h, :w] + np.testing.assert_allclose(js, expected, atol=1e-9) + + +def test_fftshift_matches_numpy(): + """fftshift matches numpy.fft.fftshift on 2D data.""" + rng = np.random.default_rng(4) + h, w = 16, 16 + img = rng.standard_normal((h, w)) + flat = img.flatten().copy() + _js_fftshift(flat, w, h) + js_shifted = flat.reshape(h, w) + expected = np.fft.fftshift(img) + np.testing.assert_array_equal(js_shifted, expected) + + +def test_fft2d_then_fftshift_matches_numpy(): + """Combined FFT + fftshift matches numpy reference.""" + rng = np.random.default_rng(5) + h, w = 32, 32 + img = rng.standard_normal((h, w)) + real = img.astype(np.float64).flatten() + imag = np.zeros(h * w, dtype=np.float64) + _js_fft2d(real, imag, w, h, inverse=False) + _js_fftshift(real, w, h) + _js_fftshift(imag, w, h) + js = (real + 1j * imag).reshape(h, w) + expected = np.fft.fftshift(np.fft.fft2(img)) + np.testing.assert_allclose(js, expected, atol=1e-9) diff --git a/widget/tests/test_state_dict.py b/widget/tests/test_state_dict.py new file mode 100644 index 00000000..60614941 --- /dev/null +++ b/widget/tests/test_state_dict.py @@ -0,0 +1,166 @@ +"""state_dict roundtrip tests for Show2D and Show4DSTEM. + +For each widget: +1. Construct with default data. +2. Mutate every trait in state_dict() to a non-default value. +3. Get state_dict. +4. Construct a fresh widget and load_state_dict. +5. Assert every trait on the restored widget equals what we set. + +Catches silent regressions when traits are added, renamed, or dropped without +updating the state_dict roundtrip path. +""" +import json + +import numpy as np +import pytest +from quantem.widget import Show2D, Show4DSTEM + + +def _flip_value(default): + """Return a value distinct from `default` for the same type.""" + if isinstance(default, bool): + return not default + if isinstance(default, int): + return int(default) + 7 + if isinstance(default, float): + return float(default) + 0.123 + if isinstance(default, str): + return default + "_x" if default else "x" + if isinstance(default, list): + return [_flip_value(default[0])] if default else [0] + return default + + +def _mutate_state(state: dict) -> dict: + """Build a new state dict with every key changed to a non-default value.""" + out = {} + for k, v in state.items(): + # Skip values our flipper can't safely tweak (None defaults, nested dicts/lists-of-dicts, bytes). + if v is None or isinstance(v, (dict, bytes)): + out[k] = v + continue + # Lists hold structured items (dicts, tuples) for ROI / profile / labels; + # mutating them generically is fragile. The roundtrip-defaults test already + # covers list trait persistence — here we only mutate scalars. + if isinstance(v, list): + out[k] = v + continue + out[k] = _flip_value(v) + return out + + +# --------------------------------------------------------------------------- +# Show4DSTEM +# --------------------------------------------------------------------------- + +@pytest.fixture +def show4dstem_widget(): + data = np.random.default_rng(0).poisson(5, (8, 8, 16, 16)).astype(np.uint16) + data[:, :, 6:10, 6:10] += 500 # synthetic BF disk + return Show4DSTEM(data, verbose=False) + + +def test_show4dstem_state_dict_keys(show4dstem_widget): + """state_dict returns a non-empty dict of public traits.""" + s = show4dstem_widget.state_dict() + assert isinstance(s, dict) + assert len(s) > 10 + # Required keys for the widget's user-facing display state + for required in ("title", "dp_colormap", "vi_colormap", "roi_mode", "vi_roi_reduce"): + assert required in s, f"state_dict missing key {required!r}" + + +def test_show4dstem_state_dict_roundtrip_defaults(show4dstem_widget): + """save → load on default widget preserves state.""" + original = show4dstem_widget.state_dict() + data = np.random.default_rng(0).poisson(5, (8, 8, 16, 16)).astype(np.uint16) + data[:, :, 6:10, 6:10] += 500 + fresh = Show4DSTEM(data, state=original, verbose=False) + restored = fresh.state_dict() + for k in original: + assert restored[k] == original[k], f"{k}: {original[k]!r} -> {restored[k]!r}" + + +def test_show4dstem_state_dict_roundtrip_mutated(show4dstem_widget): + """Mutating every trait then roundtripping preserves the mutations.""" + # Position / frame indices are clamped to valid range by trait validators + # against the data dimensions; mutating them generically is meaningless here. + skip = {"pos_row", "pos_col", "frame_idx", "path_index", "path_length", + "vi_roi_center_row", "vi_roi_center_col"} + mutated = _mutate_state(show4dstem_widget.state_dict()) + show4dstem_widget.load_state_dict(mutated) + out = show4dstem_widget.state_dict() + for k, v in mutated.items(): + if k in skip: + continue + if isinstance(v, float): + assert abs(out[k] - v) < 1e-3, f"{k}: expected {v}, got {out[k]}" + else: + assert out[k] == v, f"{k}: expected {v!r}, got {out[k]!r}" + + +def test_show4dstem_save_and_load(tmp_path, show4dstem_widget): + """save() writes a versioned envelope JSON, state= kwarg loads it.""" + show4dstem_widget.dp_colormap = "viridis" + show4dstem_widget.vi_colormap = "magma" + show4dstem_widget.show_fft = True + path = tmp_path / "show4dstem_state.json" + show4dstem_widget.save(str(path)) + + payload = json.loads(path.read_text()) + assert payload["widget_name"] == "Show4DSTEM" + assert "metadata_version" in payload + assert "state" in payload + + data = np.random.default_rng(0).poisson(5, (8, 8, 16, 16)).astype(np.uint16) + data[:, :, 6:10, 6:10] += 500 + fresh = Show4DSTEM(data, state=str(path), verbose=False) + assert fresh.dp_colormap == "viridis" + assert fresh.vi_colormap == "magma" + assert fresh.show_fft is True + + +# --------------------------------------------------------------------------- +# Show2D +# --------------------------------------------------------------------------- + +@pytest.fixture +def show2d_widget(): + return Show2D(np.random.default_rng(0).standard_normal((32, 32)).astype(np.float32), verbose=False) + + +def test_show2d_state_dict_keys(show2d_widget): + s = show2d_widget.state_dict() + assert isinstance(s, dict) + assert len(s) > 5 + for required in ("cmap", "log_scale"): + assert required in s, f"state_dict missing key {required!r}" + + +def test_show2d_state_dict_roundtrip_defaults(show2d_widget): + original = show2d_widget.state_dict() + fresh = Show2D(np.random.default_rng(0).standard_normal((32, 32)).astype(np.float32), + state=original, verbose=False) + restored = fresh.state_dict() + for k in original: + if isinstance(original[k], float): + assert abs(restored[k] - original[k]) < 1e-3, f"{k}: {original[k]} -> {restored[k]}" + else: + assert restored[k] == original[k], f"{k}: {original[k]!r} -> {restored[k]!r}" + + +def test_show2d_save_and_load(tmp_path, show2d_widget): + show2d_widget.cmap = "viridis" + show2d_widget.log_scale = True + path = tmp_path / "show2d_state.json" + show2d_widget.save(str(path)) + + payload = json.loads(path.read_text()) + assert payload["widget_name"] == "Show2D" + assert "state" in payload + + fresh = Show2D(np.random.default_rng(0).standard_normal((32, 32)).astype(np.float32), + state=str(path), verbose=False) + assert fresh.cmap == "viridis" + assert fresh.log_scale is True diff --git a/widget/tsconfig.json b/widget/tsconfig.json new file mode 100644 index 00000000..8b4afe79 --- /dev/null +++ b/widget/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": [ + "js" + ], + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} \ No newline at end of file diff --git a/widget/vite.config.js b/widget/vite.config.js deleted file mode 100644 index 8f303083..00000000 --- a/widget/vite.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from "vite"; -import anywidget from "@anywidget/vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [anywidget(), react()], - define: { - "process.env.NODE_ENV": JSON.stringify("production"), - }, - build: { - outDir: "src/quantem/widget/static", - lib: { - entry: "js/index.jsx", - formats: ["es"], - fileName: "index", - }, - rollupOptions: { - output: { - inlineDynamicImports: true, - }, - }, - }, -});