diff --git a/.github/workflows/konfai_apps_ci.yml b/.github/workflows/konfai_apps_ci.yml index 3a82cb35..9443f9b4 100644 --- a/.github/workflows/konfai_apps_ci.yml +++ b/.github/workflows/konfai_apps_ci.yml @@ -41,7 +41,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 @@ -70,9 +70,6 @@ jobs: run: | pip install -e ./konfai-apps - - name: Pin gloo to the loopback interface on macOS - if: runner.os == 'macOS' - run: echo "GLOO_SOCKET_IFNAME=lo0" >> "$GITHUB_ENV" - name: Run apps tests run: | python -m pytest -q konfai-apps/tests diff --git a/.github/workflows/konfai_ci.yml b/.github/workflows/konfai_ci.yml index 2e9c6300..4f9fc6c7 100644 --- a/.github/workflows/konfai_ci.yml +++ b/.github/workflows/konfai_ci.yml @@ -32,7 +32,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 diff --git a/.github/workflows/konfai_mcp_ci.yml b/.github/workflows/konfai_mcp_ci.yml index c5ae51a4..cc0961c9 100644 --- a/.github/workflows/konfai_mcp_ci.yml +++ b/.github/workflows/konfai_mcp_ci.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 diff --git a/.github/workflows/konfai_studio_ci.yml b/.github/workflows/konfai_studio_ci.yml index 12250213..356c2dd4 100644 --- a/.github/workflows/konfai_studio_ci.yml +++ b/.github/workflows/konfai_studio_ci.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd0f945a..10c26738 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -114,7 +114,7 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: "3.10" + python-version: "3.11" - name: Set up Node if: matrix.name == 'konfai-studio' diff --git a/README.md b/README.md index 7a546272..8bb3a6e4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

From images on disk to reproducible experiments, production inference, and reusable clinical applications.

PyPI version - Python 3.10+ + Python 3.11+ CI Documentation Apache-2.0 diff --git a/apps/impact_reg/README.md b/apps/impact_reg/README.md index 9f8d0a6a..e53b6eb6 100644 --- a/apps/impact_reg/README.md +++ b/apps/impact_reg/README.md @@ -1,6 +1,6 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![PyPI version](https://img.shields.io/pypi/v/impact_reg_konfai.svg?color=blue)](https://pypi.org/project/impact_reg_konfai/) -[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [![CI](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml/badge.svg)](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml) [![Paper](https://img.shields.io/badge/๐Ÿ“Œ%20Paper-KonfAI-blue)](https://www.arxiv.org/abs/2508.09823) diff --git a/apps/impact_reg/pyproject.toml b/apps/impact_reg/pyproject.toml index c8edeae9..f985b5ba 100644 --- a/apps/impact_reg/pyproject.toml +++ b/apps/impact_reg/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Fast and lightweight CLI for pairwise registration workflows with IMPACT-Reg presets through the KonfAI runtime." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/apps/impact_reg/tests/unit/test_displacement_field_io.py b/apps/impact_reg/tests/unit/test_displacement_field_io.py index 93843273..44001078 100644 --- a/apps/impact_reg/tests/unit/test_displacement_field_io.py +++ b/apps/impact_reg/tests/unit/test_displacement_field_io.py @@ -38,7 +38,7 @@ ) from konfai.utils.errors import TransformError # noqa: E402 from konfai.utils.ITK import read_displacement_field # noqa: E402 -from konfai.utils.ome_zarr import _zarr_v3_available, is_displacement_field, write_ome_zarr # noqa: E402 +from konfai.utils.ome_zarr import is_displacement_field, write_ome_zarr # noqa: E402 def _write_displacement_field(field: "sitk.Image", dest: Path) -> None: @@ -59,13 +59,6 @@ def _write_displacement_field(field: "sitk.Image", dest: Path) -> None: sitk.WriteImage(field, str(dest)) -# The OME-Zarr side of these tests writes an RFC-5 field, a zarr v3 store that zarr 2.x -# (Python 3.10) cannot write. -pytestmark = pytest.mark.skipif( - not _zarr_v3_available(), - reason="NGFF RFC-5 displacement fields need a zarr v3 store (zarr>=3, Python>=3.11)", -) - SPACING = (1.5, 1.5, 2.0) ORIGIN = (7.0, -3.0, 10.0) # A 90 deg in-plane rotation: non-identity, so a Direction dropped on the store round-trip changes diff --git a/apps/impact_reg/tests/unit/test_orchestration.py b/apps/impact_reg/tests/unit/test_orchestration.py index bbdc9e28..24615b99 100644 --- a/apps/impact_reg/tests/unit/test_orchestration.py +++ b/apps/impact_reg/tests/unit/test_orchestration.py @@ -219,9 +219,6 @@ def test_register_reads_a_store_moving_against_an_itk_field(tmp_path: Path) -> N per group, so a caller's OME-Zarr moving registers against the ``.mha`` field every published preset declares.""" ome_zarr = pytest.importorskip("konfai.utils.ome_zarr") - if not ome_zarr._zarr_v3_available(): - pytest.skip("writing an OME-Zarr moving needs zarr 3") - volume = np.arange(8**3, dtype=np.float32).reshape(8, 8, 8) moving = tmp_path / "moving.ome.zarr" ome_zarr.write_ome_zarr(moving, volume[None], spacing=(1.0, 1.0, 1.0), origin=(0.0, 0.0, 0.0)) diff --git a/apps/impact_seg/README.md b/apps/impact_seg/README.md index 71ce9258..87703409 100644 --- a/apps/impact_seg/README.md +++ b/apps/impact_seg/README.md @@ -1,6 +1,6 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![PyPI version](https://img.shields.io/pypi/v/impact_seg_konfai.svg?color=blue)](https://pypi.org/project/impact_seg_konfai/) -[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [![CI](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml/badge.svg)](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml) [![Paper](https://img.shields.io/badge/๐Ÿ“Œ%20Paper-KonfAI-blue)](https://www.arxiv.org/abs/2508.09823) diff --git a/apps/impact_seg/pyproject.toml b/apps/impact_seg/pyproject.toml index be1a1eeb..74b4da35 100644 --- a/apps/impact_seg/pyproject.toml +++ b/apps/impact_seg/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Fast and lightweight CLI for anatomical segmentation with 2.5D U-Net models using a residual encoder within the KonfAI framework." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/apps/impact_synth/README.md b/apps/impact_synth/README.md index f9402413..fd2b8249 100644 --- a/apps/impact_synth/README.md +++ b/apps/impact_synth/README.md @@ -1,6 +1,6 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![PyPI version](https://img.shields.io/pypi/v/impact_synth_konfai.svg?color=blue)](https://pypi.org/project/impact_synth_konfai/) -[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [![CI](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml/badge.svg)](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml) [![Paper](https://img.shields.io/badge/๐Ÿ“Œ%20Paper-KonfAI-blue)](https://arxiv.org/abs/2510.21358) diff --git a/apps/impact_synth/pyproject.toml b/apps/impact_synth/pyproject.toml index bf43a77a..dd5a5b8a 100644 --- a/apps/impact_synth/pyproject.toml +++ b/apps/impact_synth/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Fast and lightweight CLI for synthetic CT generation using IMPACT-Synth models within the KonfAI framework." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/apps/mrsegmentator/README.md b/apps/mrsegmentator/README.md index 3c88b075..dfd63263 100644 --- a/apps/mrsegmentator/README.md +++ b/apps/mrsegmentator/README.md @@ -1,6 +1,6 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![PyPI version](https://img.shields.io/pypi/v/mrsegmentator-konfai.svg?color=blue)](https://pypi.org/project/mrsegmentator-konfai/) -[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [![CI](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml/badge.svg)](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml) [![Paper](https://img.shields.io/badge/๐Ÿ“Œ%20Paper-KonfAI-blue)](https://www.arxiv.org/abs/2508.09823) diff --git a/apps/mrsegmentator/pyproject.toml b/apps/mrsegmentator/pyproject.toml index 188fee51..7fc221c5 100644 --- a/apps/mrsegmentator/pyproject.toml +++ b/apps/mrsegmentator/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Fast and lightweight MRSegmentator CLI powered by the KonfAI framework." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/apps/totalsegmentator/README.md b/apps/totalsegmentator/README.md index cdc03d36..fcca9e22 100644 --- a/apps/totalsegmentator/README.md +++ b/apps/totalsegmentator/README.md @@ -1,6 +1,6 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![PyPI version](https://img.shields.io/pypi/v/totalsegmentator-konfai.svg?color=blue)](https://pypi.org/project/totalsegmentator-konfai/) -[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [![CI](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml/badge.svg)](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_ci.yml) [![CI](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_apps_ci.yml/badge.svg)](https://github.com/fideus-labs/KonfAI/actions/workflows/konfai_apps_ci.yml) [![Paper](https://img.shields.io/badge/๐Ÿ“Œ%20Paper-KonfAI-blue)](https://www.arxiv.org/abs/2508.09823) diff --git a/apps/totalsegmentator/pyproject.toml b/apps/totalsegmentator/pyproject.toml index cd13085d..098328dc 100644 --- a/apps/totalsegmentator/pyproject.toml +++ b/apps/totalsegmentator/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Fast and lightweight TotalSegmentator CLI powered by the KonfAI framework." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/docs/source/development.md b/docs/source/development.md index a6609b5f..2ac453ab 100644 --- a/docs/source/development.md +++ b/docs/source/development.md @@ -12,7 +12,7 @@ cloned KonfAI checkout.** ## Prerequisites -- **Python 3.10 or later**: the minimum version declared in `pyproject.toml` +- **Python 3.11 or later**: the minimum version declared in `pyproject.toml` - **Pixi**: install once with: ```bash @@ -140,7 +140,7 @@ pixi run test -- tests/unit/test_config.py -v ### What CI runs The GitHub Actions workflow in `.github/workflows/konfai_ci.yml` runs `pytest` -across Python `3.10` to `3.13` on Linux, macOS, and Windows. +across Python `3.11` to `3.13` on Linux, macOS, and Windows. ### The konfai-apps test suite diff --git a/docs/source/getting-started/installation.md b/docs/source/getting-started/installation.md index 9f784fb7..ca331ba7 100644 --- a/docs/source/getting-started/installation.md +++ b/docs/source/getting-started/installation.md @@ -1,6 +1,6 @@ # Installation -KonfAI needs **Python 3.10 or newer**. This is the line most people want: +KonfAI needs **Python 3.11 or newer**. This is the line most people want: ```bash python -m pip install "konfai[imaging]" diff --git a/docs/source/index.rst b/docs/source/index.rst index bb6ef713..a9122acf 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -24,7 +24,7 @@ KonfAI

pip install "konfai[imaging]" Apache-2.0 - Python 3.10+ + Python 3.11+
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 17a0fe5b..745d1a6f 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -17,7 +17,7 @@ first, so it is the shortest path to seeing KonfAI work. Install ------- -You need Python 3.10 or newer. A GPU makes it faster, and every command below +You need Python 3.11 or newer. A GPU makes it faster, and every command below works with ``--cpu 1`` instead of ``--gpu 0``. .. code-block:: bash diff --git a/konfai-apps/konfai_apps/app_server.py b/konfai-apps/konfai_apps/app_server.py index 361aeb59..15ead45a 100644 --- a/konfai-apps/konfai_apps/app_server.py +++ b/konfai-apps/konfai_apps/app_server.py @@ -570,7 +570,7 @@ async def sse_log_stream(job: Job): while True: try: line = await asyncio.wait_for(job.log_q.get(), timeout=SSE_HEARTBEAT_S) - except asyncio.TimeoutError: + except TimeoutError: # Keep the connection alive during quiet periods without signalling # completion. Stop only once the job itself has terminated (guards # against a completion marker dropped from a saturated queue). diff --git a/konfai-apps/pyproject.toml b/konfai-apps/pyproject.toml index e7f24073..549f7035 100644 --- a/konfai-apps/pyproject.toml +++ b/konfai-apps/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version", "dependencies"] description = "Standalone KonfAI Apps package with local and remote app execution" readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/konfai-mcp/konfai_mcp/catalog.py b/konfai-mcp/konfai_mcp/catalog.py index e1f1985a..f5a5670d 100644 --- a/konfai-mcp/konfai_mcp/catalog.py +++ b/konfai-mcp/konfai_mcp/catalog.py @@ -29,7 +29,6 @@ import importlib import inspect import os -import types from typing import Any from konfai_mcp.classpaths import public_module @@ -115,10 +114,6 @@ def _list_subclasses(module_path: str, base_name: str) -> list[dict[str, Any]]: base = getattr(module, base_name) components: list[dict[str, Any]] = [] for name, obj in inspect.getmembers(module, inspect.isclass): - # A subscripted builtin generic (konfai.data.geometry.SpatialStages) passes isclass on - # Python 3.10 but is not a class there, and issubclass refuses it. - if isinstance(obj, types.GenericAlias): - continue if obj is base or not issubclass(obj, base): continue if inspect.isabstract(obj) or name.startswith("_"): diff --git a/konfai-mcp/konfai_mcp/server_experiments.py b/konfai-mcp/konfai_mcp/server_experiments.py index 0eb7aa8c..c3bf16f6 100644 --- a/konfai-mcp/konfai_mcp/server_experiments.py +++ b/konfai-mcp/konfai_mcp/server_experiments.py @@ -18,7 +18,7 @@ import tempfile from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal, cast @@ -62,7 +62,7 @@ class SessionService(DatasetInspectionMixin, MetricsServiceMixin): def _isoformat(self, timestamp: float | None) -> str | None: if timestamp is None: return None - return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() + return datetime.fromtimestamp(timestamp, tz=UTC).isoformat() def session_name(self) -> str: return self.workspace_layout.current_session or "default" diff --git a/konfai-mcp/pyproject.toml b/konfai-mcp/pyproject.toml index 3772e0c3..479a5c58 100644 --- a/konfai-mcp/pyproject.toml +++ b/konfai-mcp/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "Standalone MCP server package for KonfAI experimentation workflows" readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } diff --git a/konfai-mcp/tests/test_mcp_server_apps.py b/konfai-mcp/tests/test_mcp_server_apps.py index 18fe4371..841f43c8 100644 --- a/konfai-mcp/tests/test_mcp_server_apps.py +++ b/konfai-mcp/tests/test_mcp_server_apps.py @@ -859,8 +859,8 @@ async def scenario() -> dict[str, dict]: properties = schemas[name].get("properties", {}) missing = required_params - set(properties) assert not missing, f"{name} lost parameters on the wire: {missing}" - # pydantic places a union param's description inside anyOf on some versions (the 3.10 leg): - # documented means present anywhere in the property's subtree. + # pydantic places a union param's description inside anyOf on some versions: documented + # means present anywhere in the property's subtree. undocumented = [p for p in required_params if '"description"' not in json.dumps(properties[p])] assert not undocumented, f"{name} has undocumented parameters: {undocumented}" diff --git a/konfai/data/case_reduction.py b/konfai/data/case_reduction.py index b25bae63..c20305fc 100644 --- a/konfai/data/case_reduction.py +++ b/konfai/data/case_reduction.py @@ -113,6 +113,12 @@ class ReductionPlan: #: whatever the cohort's size, because ``_fold`` accumulates the members one after another, so #: only one chain is ever replaying. chain_multiple: float = 0.0 + #: The source window ONE member's region pulls (:attr:`~konfai.data.patching.BlockReads` + #: ``widest_pull``). For a chain that resamples, a region's source is not the region -- a + #: rotated or scaled map reaches a box around it -- and the chain holds that box while it + #: produces the region. Charged ONCE, like the reads below: the members are folded in turn, so + #: one chain is pulling at a time. Zero for a chain whose region is its own source. + pull_bytes: int = 0 #: What ONE member's region makes the store decode ABOVE the window it asked for #: (:meth:`~konfai.data.patching.DatasetManager.region_reads`). A chunked backend decodes whole #: blocks, so below one stored block this is the SAME figure at every height: it is charged flat @@ -180,15 +186,15 @@ def peak_bytes(self) -> int: # one region holds, so the peak is the same whether there are one or two passes. member_bytes = self._region_bytes(self.source_channels or self.channels) members = self.buffered_regions * member_bytes - # The chain replaying a member holds its own buffers beside the region it lands, and it is - # the members' own width it holds them at. Once: the members are accumulated in turn. - # Plus what the store decodes to serve ONE member's region: the chain's buffers are built - # over the window it asked for, the decode materialises the blocks that window falls in, and - # the two are resident together. One read is in flight at a time (the members accumulate in - # turn), so it is charged once. + # Beside the buffered regions, the one replaying chain holds three things at once: the + # source window it pulled, its own working buffers, and what the store decoded above that + # window. The buffers are built over the larger of the window and the region it lands, the + # sweep prices its blocks the same way, and all three are charged once because the members + # are folded in turn -- one chain pulls, allocates and decodes at a time. return int( members * (1 + self.working_multiple) - + self.chain_multiple * member_bytes + + self.chain_multiple * max(member_bytes, self.pull_bytes) + + self.pull_bytes + self.region_bytes + self.read_bytes ) @@ -359,14 +365,6 @@ def fit_budget(self, budget_bytes: float | None, cap: int | None = None) -> None if not budget_bytes or budget_bytes <= 0: return plan = self.plan() - # What the store decodes does NOT fall with the height: below one stored block the same - # blocks are decoded whatever the region asks for, and the fold simply asks more often. It - # is taken off the allowance whole, never divided by the rows -- dividing it is what made a - # small budget cut the regions, decode the same bytes twice as often, and hold MORE. - flat = plan.read_bytes - row_bytes = (plan.peak_bytes - flat) / max(1, plan.slab_rows) - if row_bytes <= 0: - return # Past the plateau a taller region reads no less and holds more, so that is the ceiling # when the caller names none; a chain that cannot price one falls back to the output height. ceiling = int(cap) if cap is not None else self._plateau_rows(plan) @@ -377,8 +375,41 @@ def fit_budget(self, budget_bytes: float | None, cap: int | None = None) -> None allowance = budget_share("regions", budget_bytes) or 0.0 if self.keeps_folds(plan): allowance -= self._folded_output_bytes(plan) - allowance -= flat - self.slab_rows = max(1, min(ceiling, int(max(row_bytes, allowance) / row_bytes))) + self.slab_rows = self._tallest_affordable(ceiling, allowance) + + def _tallest_affordable(self, ceiling: int, allowance: float) -> int: + """The tallest region up to ``ceiling`` whose PRICED plan fits ``allowance``. + + Bisected on the price itself rather than extrapolated from one height, because none of what + a region costs scales with its rows: a chain's source window is not its region (a halo is a + constant, a rotated map's box grows with the diagonal), and what a chunked store decodes + does not fall with the height at all -- below one stored block the same blocks are decoded + whatever the region asks for. A straight line through one sample sized a fold at 2.6x the + budget it printed. + + One row when nothing fits: the plan then reports a peak above the budget and the workflow + refuses, which is the only honest answer, there being no whole-volume path to fall back to. + """ + ceiling = max(1, int(ceiling)) + if self._priced_peak(ceiling) <= allowance: + return ceiling + low, high = 1, ceiling + while low < high: + middle = (low + high + 1) // 2 + if self._priced_peak(middle) <= allowance: + low = middle + else: + high = middle - 1 + return low + + def _priced_peak(self, rows: int) -> int: + """What the plan prices at ``rows``, leaving the height the sizing is working from alone.""" + held = self.slab_rows + try: + self.slab_rows = rows + return self.plan().peak_bytes + finally: + self.slab_rows = held def keeps_folds(self, plan: ReductionPlan) -> bool: """Whether the stat pass hands its folds to the write pass instead of re-folding them. @@ -528,6 +559,7 @@ def _needs_stat_pass(self) -> bool: def plan(self) -> ReductionPlan: reference = self.reference + pull_bytes, read_bytes = self._member_read_bytes(int(reference.base_shape[0])) return ReductionPlan( output=self.reduce.output, cases=[manager.name for manager in self.managers], @@ -542,24 +574,30 @@ def plan(self) -> ReductionPlan: working_multiple=float(self.operator.working_multiple_for(len(self.managers))), # The worst member's, because the fold is paced by whichever chain holds the most. chain_multiple=max((float(manager.working_multiple()) for manager in self.managers), default=0.0), - read_bytes=self._member_read_bytes(int(reference.base_shape[0])), + pull_bytes=pull_bytes, + read_bytes=read_bytes, stat_pass=self._needs_stat_pass(), unbounded=self._unbounded_members(), refusal=self._first_refusal(), ) - def _member_read_bytes(self, channels: int) -> int: - """What the widest member's region makes its store decode, at the current height. + def _member_read_bytes(self, channels: int) -> tuple[int, int]: + """What one member's region costs its store at the current height, in bytes: the source + window it pulls, and what the store decodes above that window. - The fold is paced by whichever member decodes the most, and one read is in flight at a - time. ``None`` from a manager (a chain that cannot answer) contributes nothing: the peak - then says what it did before, which is what the run-time probe is there to correct. + The fold is paced by whichever member costs the most, and one read is in flight at a time, + so both are the widest member's and both are charged once. ``None`` from a manager (a chain + that cannot answer) contributes nothing: the peak then says what it did before, which is + what the run-time probe is there to correct. """ from konfai.data.patching import _SWEEP_ELEMENT_BYTES reads = [manager.region_reads(self.slab_rows) for manager in self.managers] - widest = max((excess for read in reads if read is not None for excess, _total in [read]), default=0) - return int(widest * max(1, channels) * _SWEEP_ELEMENT_BYTES) + present = [read for read in reads if read is not None] + element = max(1, channels) * _SWEEP_ELEMENT_BYTES + pull = max((read.widest_pull for read in present), default=0) + excess = max((read.widest_excess for read in present), default=0) + return int(pull * element), int(excess * element) # --------------------------------------------------------------- execution diff --git a/konfai/data/materialize.py b/konfai/data/materialize.py index 3e293b37..f487d4f7 100644 --- a/konfai/data/materialize.py +++ b/konfai/data/materialize.py @@ -28,22 +28,10 @@ """ import contextlib -import sys import warnings from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass - -if sys.version_info >= (3, 11): - from enum import StrEnum -else: # Python 3.10 has no StrEnum: the same contract, a str whose text is its value - from enum import Enum - - class StrEnum(str, Enum): - def __str__(self) -> str: - return str(self.value) - - __format__ = str.__format__ - +from enum import StrEnum import numpy as np import torch diff --git a/konfai/data/patching/manager.py b/konfai/data/patching/manager.py index f34aad01..c2768a72 100644 --- a/konfai/data/patching/manager.py +++ b/konfai/data/patching/manager.py @@ -1352,16 +1352,16 @@ def _source_extents(spatial: Sequence[int], plans: Sequence["_ReadStagePlan"]) - window the landing has no extent for.""" return [int(extent) for extent in plans[0].in_shape] if plans else [int(extent) for extent in spatial] - def region_reads(self, rows: int, a: int = 0) -> tuple[int, int] | None: - """What a decomposition into ``rows``-row regions costs this chain in source voxels: - ``(excess, total)``. ``None`` when the chain cannot stream. + def region_reads(self, rows: int, a: int = 0) -> "BlockReads | None": + """What a decomposition into ``rows``-row regions costs this chain in source voxels. + ``None`` when the chain cannot stream. - ``excess`` is what the widest region's read materialises ABOVE the window it asked for -- - a chunked store decodes whole blocks, so a window is served by the block-aligned hull that - covers it. Zero on a store that serves exactly what it is asked for, which is what makes - this safe to charge on top of a price already counting the window. - - ``total`` is what all the regions read together, the figure a caller compares heights on. + The same aggregates the sweep sizes against (:class:`BlockReads`): ``widest_pull``, the + source window one region materialises -- which for a chain that resamples is not the region + and is what a fold must hold while it produces one; ``widest_excess``, what the store + decodes above that window, a chunked one serving a window by the block-aligned hull that + covers it; and ``total``, what all the regions read together, the figure a caller compares + heights on. Closed form, from the chain's own pull maps and the store's metadata: no voxel is read. """ @@ -1371,8 +1371,7 @@ def region_reads(self, rows: int, a: int = 0) -> tuple[int, int] | None: segment = segments[-1] spatial = [int(extent) for extent in segment.landing] tile = [max(1, min(int(rows), spatial[0])), *spatial[1:]] - reads = self.block_reads(spatial, tile, segment.plans) - return (reads.widest_excess, reads.total) + return self.block_reads(spatial, tile, segment.plans) def _grid_rows(self, cap: int) -> list[int]: """The heights that land on the store's block grid, up to ``cap``. diff --git a/konfai/data/transform/io.py b/konfai/data/transform/io.py index 0760a098..06edc064 100644 --- a/konfai/data/transform/io.py +++ b/konfai/data/transform/io.py @@ -35,7 +35,7 @@ class Save(Transform): where the levels are derived once the last region has landed. ``downsample_method`` names how the coarse levels are derived, and its default is - ``ITKWASM_BIN_SHRINK`` (block averaging), NOT ngff-zarr's own ``ITKWASM_GAUSSIAN``. Measured on a + ``DASK_BIN_SHRINK`` (block averaging), NOT ngff-zarr's own ``ITKWASM_GAUSSIAN``. Measured on a real volume, the Gaussian holds a 0.9998 correlation while crushing peak intensity by 20 %. """ diff --git a/konfai/network/network/base.py b/konfai/network/network/base.py index 05dfa450..290010d8 100644 --- a/konfai/network/network/base.py +++ b/konfai/network/network/base.py @@ -46,6 +46,40 @@ def is_full(self) -> bool: return len(self.patch.get_patch_slices(0)) == self.index +#: A patched forward yields every layer twice over: once per patch, and once assembled. The two are +#: told apart by a marker inside the layer's NAME, because the name is what a checkpoint key and an +#: ``outputs_criterions`` key are written against -- it cannot move out of the string without moving +#: both. The marker stays in band; the reading of it does not, and lives in the four helpers below. +_ACCUMULATED = ";accu;" + + +def mark_accumulated(name: str) -> str: + """``name`` as a patched forward yields it, for a layer an accumulator is assembling.""" + return f"{_ACCUMULATED}{name}" + + +def is_accumulated(name: str) -> bool: + """Whether ``name`` names a patch of a layer rather than the assembled layer.""" + return _ACCUMULATED in name + + +def strip_accumulated(name: str) -> str: + """``name`` as the model declares it, with any marker taken out.""" + return name.replace(_ACCUMULATED, "") + + +def accumulator_owner(name: str) -> str: + """The module whose accumulator assembles ``name``: the last segment of the path before the + INNERMOST marker, empty for the network itself. + + Innermost because a nested network marks the names it yields and its parent prefixes its own + path onto them, so a layer of a sub-network carries a marker per level and the accumulator is + the one closest to the layer. + """ + head = name.rsplit(_ACCUMULATED, 2)[-2] if is_accumulated(name) else "" + return head.rstrip(".").rsplit(".", 1)[-1] + + def batched_step( optimizer_class: type[torch.optim.Optimizer], parameters: list[torch.nn.parameter.Parameter] ) -> Callable[..., torch.optim.Optimizer]: diff --git a/konfai/network/network/measure.py b/konfai/network/network/measure.py index e53e85bd..0b5d652a 100644 --- a/konfai/network/network/measure.py +++ b/konfai/network/network/measure.py @@ -26,6 +26,7 @@ import torch from konfai.metric.schedulers import Scheduler +from konfai.network.network.base import strip_accumulated from konfai.network.network.loaders import CriterionsAttr, TargetCriterionsLoader from konfai.utils.dataset import Attribute from konfai.utils.errors import ConfigError, MeasureError @@ -194,7 +195,7 @@ def init(self, model: torch.nn.Module, group_dest: list[str]) -> None: modules.append(i) for output_group in self.outputs_criterions.keys(): - if output_group.replace(";accu;", "") not in modules: + if strip_accumulated(output_group) not in modules: raise MeasureError( f"The output group '{output_group}' defined in 'outputs_criterions' " "does not correspond to any module in the model.", diff --git a/konfai/network/network/network.py b/konfai/network/network/network.py index cdf0c061..3ec1309f 100644 --- a/konfai/network/network/network.py +++ b/konfai/network/network/network.py @@ -24,14 +24,8 @@ from collections.abc import Callable, Iterable, Iterator, Sequence from contextlib import nullcontext from functools import partial -from typing import Any +from typing import Any, Self -from konfai.utils.dataset import Attribute - -try: - from typing import Self # type: ignore[attr-defined] # Python โ‰ฅ 3.11 -except ImportError: - from typing_extensions import Self # Python โ‰ค 3.10 import torch from torch._jit_internal import _copy_to_script_wrapper from torch.utils.checkpoint import checkpoint @@ -39,10 +33,18 @@ from konfai import konfai_root from konfai.data.data_manager import BatchSample from konfai.data.patching import Accumulator, ModelPatch -from konfai.network.network.base import NetState, PatchIndexed +from konfai.network.network.base import ( + NetState, + PatchIndexed, + accumulator_owner, + is_accumulated, + mark_accumulated, + strip_accumulated, +) from konfai.network.network.loaders import LRSchedulersLoader, OptimizerLoader, TargetCriterionsLoader from konfai.network.network.measure import Measure from konfai.utils.clock import SweepClock +from konfai.utils.dataset import Attribute from konfai.utils.errors import ConfigError from konfai.utils.runtime import State, get_device, get_gpu_memory @@ -366,7 +368,7 @@ def named_forward( ), ): for ob in self._modulesArgs[name].out_branch: - if ob in module._modulesArgs[k.split(".")[0].replace(";accu;", "")].out_branch: + if ob in module._modulesArgs[strip_accumulated(k.split(".")[0])].out_branch: tmp.append(ob) branchs[ob] = out yield name + "." + k, out @@ -880,7 +882,7 @@ def named_forward( buffer = [] for i, patch_input in enumerate(patch_iterator): for name, output_layer in super().named_forward(*patch_input, attributes=attributes): - yield f";accu;{name}", output_layer + yield mark_accumulated(name), output_layer buffer.append((name.split(".")[0], output_layer)) if len(buffer) == 2: if buffer[0][0] != buffer[1][0]: @@ -923,7 +925,7 @@ def get_layers( it = 0 debug = "KONFAI_DEBUG" in os.environ for name_tmp, output_layer in self.named_forward(*inputs, attributes=attributes): - name = name_tmp.replace(";accu;", "") + name = strip_accumulated(name_tmp) if debug: if "KONFAI_DEBUG_LAST_LAYER" in os.environ: os.environ["KONFAI_DEBUG_LAST_LAYER"] = ( @@ -937,13 +939,9 @@ def get_layers( ) it += 1 if name in layers_name or name_tmp in layers_name: - if ";accu;" in name_tmp: + if is_accumulated(name_tmp): if name not in output_layer_patch_indexed: - network_name = ( - name_tmp.split(".;accu;")[-2].split(".")[-1] - if ".;accu;" in name_tmp - else name_tmp.split(";accu;")[-2].split(".")[-1] - ) + network_name = accumulator_owner(name_tmp) module = self network = None if network_name == "": @@ -976,7 +974,7 @@ def get_layers( yield name_tmp, output_layer, None if name in layers_name: - if ";accu;" in name_tmp: + if is_accumulated(name_tmp): yield name, output_layer, output_layer_patch_indexed[name] output_layer_patch_indexed[name].index += 1 if output_layer_patch_indexed[name].is_full(): diff --git a/konfai/utils/ome_zarr.py b/konfai/utils/ome_zarr.py index 6c4c419d..226a9e53 100644 --- a/konfai/utils/ome_zarr.py +++ b/konfai/utils/ome_zarr.py @@ -23,8 +23,8 @@ 1. map between KonfAI's channel-first ``C[Z]YX`` arrays / ``(x, y, z)`` geometry and ngff-zarr's ``NgffImage`` (axis-named ``scale``/``translation``), and 2. round-trip KonfAI's full ``Attribute`` sidecar (including the ``Direction`` - matrix, which OME-NGFF cannot express) through a single ``konfai`` group - attribute, read/written with ``zarr``. + matrix, which OME-NGFF cannot express) through a single ``konfai`` root + attribute, carried by ngff-zarr beside the OME metadata. Reads are lazy: ``ngff-zarr`` exposes the array as a chunked store, so slicing only materialises the requested patch. @@ -38,7 +38,6 @@ import dataclasses import itertools import operator -import shutil import tempfile import threading from collections import OrderedDict @@ -99,9 +98,24 @@ def _native_byteorder(array: np.ndarray) -> np.ndarray: # NGFF RFC-5 types the component axis of a vector field, so a displacement field says what it is on # disk. Those types only exist from NGFF 0.6 (zarr v3); 0.4 (zarr v2 layout) stays the default -# everywhere else, being the version portable across the whole CI matrix. +# everywhere else, being the version external OME-Zarr readers most widely accept. _DISPLACEMENT_AXIS_TYPE = "displacement" +# EVERY field store holds its components in the spec's order -- the OUTPUT axes' (dz, dy, dx for a +# zyx store) -- there is no second layout. KonfAI's own convention stays ITK's (dx, dy, dz) +# everywhere in memory; the two orders meet only at this backend's read/write boundary, where the +# components are flipped. An axis-aligned grid is additionally declared a ``displacements`` +# transformation mapping the physical coordinate system onto itself through the level-0 array, +# which is what makes it APPLICABLE by a spec reader rather than merely labelled; a grid carrying +# a rotation cannot be declared (RFC-5 maps a field's array to space by scale and translation +# alone) and keeps only the typed axis, its Direction in the sidecar, the marker below saying how +# its components are ordered. A typed store with NEITHER the entry NOR the marker is a pre-1.9 +# layout whose components are ITK-ordered: reading it under one convention or the other would be a +# guess with a plausible registration either way, so it is refused by name. +_PHYSICAL_CS = "physical" +_FIELD_COMPONENTS_KEY = "field_components" +_FIELD_COMPONENTS = "output-axes" + _RFC5_VERSION = "0.6" _DEFAULT_VERSION = "0.4" @@ -112,21 +126,11 @@ def _native_byteorder(array: np.ndarray) -> np.ndarray: CHUNK_SPATIAL_TILE = 128 CHUNK_TARGET_BYTES = 32 << 20 - -def _zarr_v3_available() -> bool: - """Whether the installed zarr can write a v3 store, which NGFF >= 0.5 (RFC-5) requires. - - RFC-5 axis types live only in NGFF 0.6, a zarr v3 layout, and zarr-python 3 needs Python >= 3.11 -- - so on Python 3.10 (zarr 2.x) a displacement field cannot be written. This is the capability the - RFC-5 write actually depends on: ``coordinateSystems`` (checked in ``_type_component_axis``) tracks - the ngff-zarr version, not the zarr one, so it alone lets a 2.x store through to an opaque failure. - """ - if not _ZARR_AVAILABLE: - return False - try: - return int(zarr.__version__.split(".")[0]) >= 3 - except (AttributeError, ValueError): - return False +#: zarr v2 stores keep byte-shuffled blosc-lz4 (what every 1.8.2 store carries) rather than the +#: zarrista writer's zstd-0 default: measured on a CT-like uint16 volume, zstd-0 costs +19 % disk +#: and ~+11 % on the streamed read sweep, because without the shuffle a uint16's high bytes break +#: every run the compressor could fold. +_V2_COMPRESSOR = {"id": "blosc", "cname": "lz4", "clevel": 5, "shuffle": 1, "blocksize": 0} def _require_zarr() -> None: @@ -146,37 +150,16 @@ def _require_ngff_zarr() -> None: ) -def _require_zarr_v3_for_rfc5() -> None: - """Both write paths type a component axis, so both need the same capability check. - - Raised rather than silently downgraded to an untyped 0.4 store: a caller asking for a displacement - field is asking for the one property that makes it readable as a transform, and a store that - quietly is not one gets found out much later, by a reader that took its three channels for an - image. - """ - if not _zarr_v3_available(): - raise DatasetManagerError( - "Writing an NGFF RFC-5 displacement field needs a zarr v3 store, i.e. " - "zarr-python >= 3 (Python >= 3.11); this environment has zarr 2.", - "Install it with: pip install 'zarr>=3' on Python >= 3.11.", - ) - - def _read_konfai_attributes(store_path: str | Path) -> dict[str, Any]: - """KonfAI's proprietary ``Attribute`` sidecar from the store, if present. - - A copy of a memoised read: it is metadata, and a streamed run asks for it once per region. + """KonfAI's proprietary ``Attribute`` sidecar: the ``konfai`` key ngff-zarr carries back beside + the OME metadata. A copy of a memoised parse: it is metadata, and a streamed run asks for it + once per region. """ - return dict(_konfai_attributes(str(store_path))) - - -@lru_cache(maxsize=8) -def _konfai_attributes(store_path: str) -> dict[str, Any]: try: - group = zarr.open_group(store_path, mode="r") - return dict(dict(group.attrs).get(_KONFAI_ATTR_KEY, {}).get("attributes", {})) - except (KeyError, OSError, ValueError, TypeError): + root = _multiscales(str(store_path)).root_attributes or {} + except Exception: return {} + return dict(root.get(_KONFAI_ATTR_KEY, {}).get("attributes", {})) def _from_ngff_zarr(store_path: str | Path) -> Any: @@ -196,13 +179,21 @@ def _from_ngff_zarr(store_path: str | Path) -> Any: @lru_cache(maxsize=8) -def _load_image(store_path: str, level: int) -> Any: - """Return the ``NgffImage`` for ``level`` of an OME-Zarr store, memoised per (store, level). +def _multiscales(store_path: str) -> Any: + """ngff-zarr's multiscales for a store, memoised per path: the images, their metadata, and the + root attributes beside them, all from one parse. A streamed run reads one patch per call, and re-parsing the NGFF metadata and rebuilding the - lazy array graph per patch is pure per-read overhead: the image object is lazy (no voxel data), - so a handful of them is cheap to keep. The key is the path alone, so anything that puts a - different store at a path already read must call ``clear_ome_zarr_cache()``: see there. + lazy array graph per patch is pure per-read overhead: the object is lazy (no voxel data), so a + handful of them is cheap to keep. The key is the path alone, so anything that puts a different + store at a path already read must call ``clear_ome_zarr_cache()``: see there. + """ + _require_ngff_zarr() + return _from_ngff_zarr(store_path) + + +def _load_image(store_path: str, level: int) -> Any: + """Return the ``NgffImage`` for ``level`` of an OME-Zarr store, off the memoised parse. ``@N`` selects among the levels a store offers, so a single-level store has nothing to select: its one level is read whatever ``N`` says (as every other backend does: ``SitkFile`` ignores @@ -210,9 +201,8 @@ def _load_image(store_path: str, level: int) -> Any: three-level mask beside a four-level image is a real mismatch (it silently pairs 160 ยตm against 320 ยตm), and quietly falling back to level 0 would hide it. """ - _require_ngff_zarr() try: - multiscales = _from_ngff_zarr(store_path) + multiscales = _multiscales(store_path) except (KeyError, IndexError, OSError, TypeError, ValueError) as exc: raise DatasetManagerError( f"Cannot open OME-Zarr store '{store_path}' (level {level}).", @@ -255,10 +245,8 @@ def clear_ome_zarr_cache(store_path: str | Path | None = None) -> None: a hit serves the previous store's axes and geometry against the new store's voxels. That reads as a shape mismatch when the two differ, and as nothing at all when they do not. """ - _load_image.cache_clear() - _level_path.cache_clear() + _multiscales.cache_clear() _level_array.cache_clear() - _konfai_attributes.cache_clear() if _CHUNK_CACHE is not None: _CHUNK_CACHE.forget(None if store_path is None else store_identity(store_path)) @@ -270,22 +258,52 @@ def is_displacement_field(store_path: str | Path) -> bool: read from the store itself: the producer does not have to be trusted, and no sidecar convention (a filename, an attribute) has to be agreed on separately. - A store that predates RFC-5, or an ngff-zarr too old to model it, simply answers False: an - unreadable or absent store is not a displacement field either, so this never raises. + A store that predates RFC-5 simply answers False: an unreadable or absent store is not a + displacement field either, so this never raises. """ if not _NGFF_ZARR_AVAILABLE: return False try: - metadata = _from_ngff_zarr(store_path).metadata + image = _multiscales(str(store_path)).images[0] except Exception: # "Not a displacement field" is the only answer this owes: it is asked purely to decide HOW to # read an entry, and an absent or unreadable store is not one either. return False - return any( - axis.name == "c" and axis.type == _DISPLACEMENT_AXIS_TYPE - for system in getattr(metadata, "coordinateSystems", None) or [] - for axis in system.axes - ) + return _has_displacement_axis(image) + + +def _has_displacement_axis(image: Any) -> bool: + """Whether the image's component axis is typed as an RFC-5 displacement.""" + return (image.axes_types or {}).get("c") == _DISPLACEMENT_AXIS_TYPE + + +def _component_flip(store_path: str) -> bool: + """Whether the store holds its components in the spec's order, to flip back to ITK's on read. + + Every field store this backend writes does -- marked by the ``displacements`` entry when the + grid could be declared, by the sidecar's ``field_components`` when it could not. A store that + types its axis and carries neither is a pre-1.9 layout whose components are ITK-ordered, and it + is refused rather than read: under either convention the guess yields a plausible field with + dx and dz possibly exchanged, which is the silent kind of wrong. + """ + if not _NGFF_ZARR_AVAILABLE: + return False + try: + multiscales = _multiscales(store_path) + except Exception: + return False + if any(entry.type == "displacements" for entry in multiscales.metadata.coordinateTransformations or []): + return True + root = (multiscales.root_attributes or {}).get(_KONFAI_ATTR_KEY) or {} + if root.get(_FIELD_COMPONENTS_KEY) == _FIELD_COMPONENTS: + return True + if _has_displacement_axis(multiscales.images[0]): + raise DatasetManagerError( + f"'{store_path}' types its component axis but declares no component order: a" + " displacement field written by KonfAI < 1.9, whose components are ITK-ordered.", + "Rewrite it from its source transform with KonfAI >= 1.9, or read it with the release that wrote it.", + ) + return False def _canonical_shape(dims: Sequence[str], shape: Sequence[int]) -> list[int]: @@ -639,11 +657,10 @@ def place(coords: tuple) -> None: map_over_rank_pool(place, wanted) -@lru_cache(maxsize=8) def _level_path(store_path: str, level: int) -> str | None: - """The zarr path of one level, from the store's multiscales metadata, memoised beside the image.""" + """The zarr path of one level, from the store's memoised multiscales metadata.""" try: - datasets = _from_ngff_zarr(store_path).metadata.datasets + datasets = _multiscales(store_path).metadata.datasets return str(datasets[level if len(datasets) > 1 else 0].path) except Exception: return None @@ -761,10 +778,21 @@ def read_ome_zarr_data_slice( level: int = 0, timepoint: int = 0, ) -> tuple[np.ndarray, dict[str, Any]]: - """Read a KonfAI channel-first ``C[Z]YX`` patch from an OME-Zarr store (lazy).""" + """Read a KonfAI channel-first ``C[Z]YX`` patch from an OME-Zarr store (lazy). + + A conformant displacement store holds its components in RFC-5 order; the channel selection is + remapped and the patch flipped back, so every caller keeps receiving ITK's (dx, dy, dz) + whatever layout the store holds. + """ image = _load_image(str(store_path), level) dims = [str(axis).lower() for axis in image.dims] canonical_shape = _canonical_shape(dims, image.data.shape) + flipped = _component_flip(str(store_path)) + if flipped: + start, stop, step = slices[0].indices(canonical_shape[0]) + if step != 1: + raise DatasetManagerError("A displacement store's component axis takes unit-step selections.") + slices = (slice(canonical_shape[0] - stop, canonical_shape[0] - start), *slices[1:]) index = _store_index(dims, canonical_shape, slices, timepoint) patch = _read_level_window(str(store_path), level, image, index) remaining = [axis for axis, selection in zip(dims, index, strict=True) if not isinstance(selection, int)] @@ -772,6 +800,10 @@ def read_ome_zarr_data_slice( patch = np.transpose(patch, [remaining.index(axis) for axis in wanted]) if "c" not in remaining: patch = patch[np.newaxis] + elif flipped: + # Contiguous, not a reversed view: a negative stride is refused by torch.from_numpy, and + # every patch this returns is about to become a tensor. + patch = np.ascontiguousarray(patch[::-1]) metadata = { "axes": dims, @@ -812,18 +844,20 @@ def _spatial_geometry( def _downsample_method(downsample_method: str | None) -> Any: - """Resolve a downsampling method name to ngff-zarr's enum, defaulting to BIN_SHRINK. + """Resolve a downsampling method name to ngff-zarr's enum, defaulting to DASK_BIN_SHRINK. NOT ngff-zarr's own default, which is ``ITKWASM_GAUSSIAN``: a pyramid is indexed by position and read as "the same image, coarser", so a level that has been smoothed is a change of pixels that no reader can see. Measured on a real volume, the gaussian keeps a 0.9998 correlation while crushing the peak intensity by 20 %: the shape of difference that passes a sanity check and - resurfaces months later. ``ITKWASM_BIN_SHRINK`` is a plain block mean, so a caller that already - downsamples by averaging blocks gets the same voxels from this writer. + resurfaces months later. ``DASK_BIN_SHRINK`` is a plain block mean with ITK's own BinShrink + semantics (aligned windows, remainder trimmed, integers rounded half up), computed lazily with + a bounded peak: it takes any extent and chunk layout the streamed writer leaves, where the wasm + variant traps on blocks past 2.5 GiB and on tails no chunking can avoid. """ _require_ngff_zarr() if downsample_method is None: - return ngff_zarr.Methods.ITKWASM_BIN_SHRINK + return ngff_zarr.Methods.DASK_BIN_SHRINK try: return ngff_zarr.Methods[downsample_method] except KeyError: @@ -898,35 +932,15 @@ def write_ome_zarr( append_ome_zarr_levels(store_path, scale_factors, downsample_method=downsample_method) -def _type_component_axis(multiscales: Any, axis_type: str) -> None: - """Type the ``c`` axis of every coordinate system, in place. - - The axis type is set on the RFC-5 coordinate systems rather than on the ``NgffImage``, because - ``to_multiscales`` derives the axes itself and hardcodes ``type="channel"`` for a ``c`` dim -- - tagging the image is a dead assignment on a non-frozen dataclass, and the store comes out an - ordinary 3-channel image with no error raised anywhere. - - Coordinate systems are also the capability check: an ngff-zarr too old to model RFC-5 has no - ``coordinateSystems`` on its metadata, and would otherwise write a silently untyped store. - """ - systems = getattr(multiscales.metadata, "coordinateSystems", None) - if not systems: - raise DatasetManagerError( - f"Writing a '{axis_type}' field needs NGFF RFC-5 coordinate systems, which this ngff-zarr cannot model.", - "Upgrade it with: pip install 'ngff-zarr>=0.38'", - ) - for system in systems: - for axis in system.axes: - if axis.name == "c": - axis.type = axis_type - - -def _write_skeleton(store_path: str | Path, multiscales: Any, version: str) -> None: - """ngff-zarr's metadata for the store, written in place. ngff-zarr (>= 0.44) writes local +def _write_skeleton(store_path: str | Path, multiscales: Any, version: str, **kwargs: Any) -> None: + """The store's metadata and empty arrays, written in place (``to_ngff_zarr(metadata_only=True)`` + describes every level and creates its array without computing a voxel). ngff-zarr writes local directories only, so a remote root gets the skeleton written locally and uploaded through the - root's own filesystem: a few bytes of metadata, before the array is created underneath it.""" + root's own filesystem: metadata documents only, before a chunk lands.""" if not uri.is_uri(store_path): - ngff_zarr.to_ngff_zarr(str(store_path), multiscales, overwrite=True, version=version) + ngff_zarr.to_ngff_zarr( + str(store_path), multiscales, overwrite=True, version=version, metadata_only=True, **kwargs + ) return filesystem = uri.filesystem(store_path) _, target = uri.split_scheme(str(store_path)) @@ -940,11 +954,107 @@ def _write_skeleton(store_path: str | Path, multiscales: Any, version: str) -> N filesystem.makedirs(target, exist_ok=True) with tempfile.TemporaryDirectory() as scratch: local = Path(scratch) / "skeleton" - ngff_zarr.to_ngff_zarr(str(local), multiscales, overwrite=True, version=version) + ngff_zarr.to_ngff_zarr(str(local), multiscales, overwrite=True, version=version, metadata_only=True, **kwargs) for file in sorted(path for path in local.rglob("*") if path.is_file()): filesystem.put_file(str(file), uri.join(target, file.relative_to(local).as_posix())) +def _grid_is_axis_aligned(attributes: dict[str, Any] | None) -> bool: + """Whether the field's grid carries no rotation. + + RFC-5 maps a field's array to space by scale and translation alone, so only an axis-aligned + grid can be declared a ``displacements`` transformation; an oriented one keeps the label-only + layout, its Direction in the sidecar. + + ``Attribute`` versions its keys (``Direction_0``, ``Direction_1``, ...), and the sidecar dict + arrives here verbatim: the LATEST version is the grid the store describes. + """ + versions = { + key: value + for key, value in (attributes or {}).items() + if key == "Direction" or (key.startswith("Direction_") and key.removeprefix("Direction_").isdigit()) + } + if not versions: + return True + value = versions[max(versions, key=lambda key: int(key.rsplit("_", 1)[-1]) if "_" in key else -1)] + flat = np.asarray( + str(value).replace("[", " ").replace("]", " ").split() if isinstance(value, str) else value, dtype=np.float64 + ).ravel() + side = round(len(flat) ** 0.5) + return side * side == len(flat) and bool(np.allclose(flat.reshape(side, side), np.eye(side))) + + +def _declare_displacements_transform(multiscales: Any) -> None: + """Mark the store as an RFC-5 ``displacements`` transformation, in place. + + What makes the field APPLICABLE by a spec reader rather than merely labelled: a spatial + ``physical`` coordinate system, and a ``displacements`` entry mapping it onto itself through + the level-0 array. The components must then follow the output axes' order, which is + ``_ComponentFlippedWriter``'s half of the contract. + """ + from ngff_zarr.v06.zarr_metadata import Axis, CoordinateSystem, CoordinateSystemIdentifier, Displacements + + spatial = [str(dim) for dim in multiscales.images[0].dims if dim in _SPATIAL] + physical = CoordinateSystem(name=_PHYSICAL_CS, axes=[Axis(name=name, type="space", unit=None) for name in spatial]) + reference = CoordinateSystemIdentifier(name=_PHYSICAL_CS) + entry = Displacements( + input=reference, output=reference, path=multiscales.metadata.datasets[0].path, interpolation="linear" + ) + multiscales.metadata = dataclasses.replace( + multiscales.metadata, + coordinateSystems=[*multiscales.metadata.coordinateSystems, physical], + coordinateTransformations=[entry], + ) + + +class _ComponentFlippedWriter: + """The level-0 array of a conformant displacement store, taking ITK-ordered components. + + Every producer in KonfAI hands fields in ITK's component order (dx, dy, dz); the store holds + the spec's (dz, dy, dx). Flipping at this boundary keeps the two conventions from ever + meeting: no producer knows about the spec, no store holds a private order. A value of lower + rank than the array (a scalar fill) has no component identity and broadcasts as it stands. + """ + + def __init__(self, array: Any) -> None: + self._array = array + self._channels = int(array.shape[0]) + + @property + def shape(self) -> tuple[int, ...]: + return tuple(self._array.shape) + + @property + def chunks(self) -> tuple[int, ...]: + return tuple(self._array.chunks) + + @property + def dtype(self) -> Any: + return self._array.dtype + + def _remap(self, key: Any) -> tuple[Any, bool]: + """The store-side selection for a caller's ITK-side one, and whether values need flipping.""" + if key is Ellipsis: + return key, True + first, *rest = key if isinstance(key, tuple) else (key,) + if isinstance(first, int): + return (self._channels - 1 - first, *rest), False + start, stop, step = first.indices(self._channels) + if step != 1: + raise DatasetManagerError("A displacement store's component axis takes unit-step selections.") + return (slice(self._channels - stop, self._channels - start), *rest), True + + def __setitem__(self, key: Any, value: Any) -> None: + target, flip = self._remap(key) + data = np.asarray(value) + self._array[target] = np.flip(data, axis=0) if flip and data.ndim == self._array.ndim else data + + def __getitem__(self, key: Any) -> np.ndarray: + source, flip = self._remap(key) + data = np.asarray(self._array[source]) + return np.ascontiguousarray(np.flip(data, axis=0)) if flip else data + + def create_ome_zarr_store( store_path: str | Path, shape: Sequence[int], @@ -965,17 +1075,10 @@ def create_ome_zarr_store( ngff-zarr writes that metadata, exactly as it does for the whole-array path, so both paths describe a store the same way: ``displacement_field`` included, which is the point of routing it through ngff-zarr at all. A field too large to assemble in memory is written region by region, so this is - the ONLY path a real one takes, and until it could type its component axis a DVF came out - self-describing exactly when it was small enough not to need to be. - - It describes the store from a one-voxel stand-in rather than from an array of the target shape. - With a single resolution level the metadata does not depend on the extent at all (axes and - coordinate transformations come from ``dims``, ``scale`` and ``translation``), and the two come - out byte-identical, verified for 0.4 and 0.6. Handing ngff-zarr the real shape instead costs a - pass over every chunk of an array that is entirely zeros (~44 ms per chunk, ~33 s for a 13.6 GiB - field) to write no bytes at all. The real array is then created underneath that metadata, which is - also what makes its chunking exactly the caller's: the region grid is the one thing ngff-zarr - cannot infer, and a store whose chunks straddle it turns every region write into a + the ONLY path a real one takes. The KonfAI sidecar rides along as a root attribute beside the OME + keys, and the array is described from a LAZY zeros of the real shape: ``metadata_only`` creates it + without computing a voxel, chunked exactly as the caller says: the region grid is the one thing + ngff-zarr cannot infer, and a store whose chunks straddle it turns every region write into a read-modify-write. """ clear_ome_zarr_cache(store_path) @@ -994,135 +1097,34 @@ def create_ome_zarr_store( chunks = [min(shape[0], max(1, CHUNK_TARGET_BYTES // max(1, tile_bytes))), *spatial_chunks] chunks = tuple(chunks) - stand_in = dask.array.zeros((shape[0], *(1,) * len(spatial_axes)), dtype=np.dtype(dtype)) - image = ngff_zarr.to_ngff_image(stand_in, dims=dims, scale=scale, translation=translation) - multiscales = ngff_zarr.to_multiscales(image, scale_factors=[]) + data = dask.array.zeros(tuple(shape), dtype=np.dtype(dtype), chunks=chunks) + image = ngff_zarr.to_ngff_image(data, dims=dims, scale=scale, translation=translation) version = _DEFAULT_VERSION if displacement_field: - _require_zarr_v3_for_rfc5() - _type_component_axis(multiscales, _DISPLACEMENT_AXIS_TYPE) + # Typing the component axis (NGFF RFC-5, so version 0.6) is what lets the store say on disk + # that its channels are a displacement rather than an ordinary 3-channel image. + image.axes_types = {"c": _DISPLACEMENT_AXIS_TYPE} version = _RFC5_VERSION - # version is explicit because to_ngff_zarr defaults to 0.5, which zarr-python 2 cannot write. - _write_skeleton(store_path, multiscales, version) + multiscales = ngff_zarr.to_multiscales(image, scale_factors=[], chunks=chunks, cache=False) + if displacement_field: + # One layout for every field: components in the spec's order. The entry when the grid can + # be declared; the sidecar marker either way, so the reader never has to guess. + if _grid_is_axis_aligned(attributes): + _declare_displacements_transform(multiscales) + multiscales.root_attributes = { + _KONFAI_ATTR_KEY: {"attributes": dict(attributes or {}), _FIELD_COMPONENTS_KEY: _FIELD_COMPONENTS} + } + elif attributes: + multiscales.root_attributes = {_KONFAI_ATTR_KEY: {"attributes": dict(attributes)}} + # version is explicit because to_ngff_zarr defaults to 0.5; 0.4 stays the portable default. A + # v3 (RFC-5) store takes the writer's own codec chain, which is what 1.8.2 wrote there too. + compression = {} if displacement_field else {"compressor": _V2_COMPRESSOR} + _write_skeleton(store_path, multiscales, version, **compression) # The level-0 key comes from the metadata rather than a literal: ngff-zarr builds it from the # image name, so "scale0/image" is its convention to change, not ours to hardcode. - group = zarr.open_group(str(store_path), mode="r+") - create_array = getattr(group, "create_array", None) or group.create_dataset - array = create_array( - multiscales.metadata.datasets[0].path, - shape=tuple(shape), - chunks=chunks, - dtype=np.dtype(dtype), - fill_value=0, - overwrite=True, - ) - # Sidecar last: to_ngff_zarr(overwrite=True) reopens the root with mode="w" and drops every - # attribute it finds, so writing this first loses Direction, and losing Direction is silent, - # the reader falls back to identity and returns a plausibly-oriented volume. - if attributes: - group.attrs[_KONFAI_ATTR_KEY] = {"attributes": dict(attributes)} - # ngff-zarr leaves a consolidated index behind, and readers trust it over the arrays themselves -- - # so until it is rebuilt the store still advertises the one-voxel stand-in, whatever is on disk. - # Last, so that the sidecar written just above is part of what gets indexed. - zarr.consolidate_metadata(str(store_path)) - return array - - -def _bin_shrink_multiscales(image: Any, scale_factors: Sequence[int], out_chunks: Any) -> Any: - """The BIN_SHRINK pyramid computed by ``dask.array.coarsen``, in ngff-zarr's own clothes. - - NOT ngff-zarr's ``ITKWASM_BIN_SHRINK``, for a reason found on real rounds: that method hands - the 32-bit wasm sandbox one BLOCK at a time, the sandbox traps near 2.5 GiB -- a whole ExaSPIM - volume as one block -- and it also traps on any multi-block layout whose extent does not divide - the factor (514 rows @4: every chunking leaves an offending tail, so NO layout is safe). The - engine's own streamed writes hit both. - - ``coarsen(np.mean, trim_excess)`` over factor-aligned blocks is the same statistic -- the mean - of each aligned ``factor**rank`` window, the global remainder dropped -- computed lazily with a - bounded peak, no sandbox, no layout constraint. The cast back to the payload dtype truncates, - which is the ``static_cast`` ITK's own BinShrink performs. - - The METADATA stays ngff-zarr's: each level's dataset entry is taken from a single-level - ``to_multiscales`` call on that level's image and repathed, so the axes, transform spelling and - version handling remain theirs, not a private replica that drifts when they move. - """ - dims = list(image.dims) - spatial = [dim for dim in dims if dim in ("z", "y", "x")] - images = [image] - previous, previous_absolute = image, 1 - for absolute in scale_factors: - factor = int(absolute) // previous_absolute - if factor * previous_absolute != int(absolute) or factor < 1: - raise DatasetManagerError( - f"scale_factors {list(scale_factors)} (relative to level 0) do not form a ladder: each" - " factor must be an integer multiple of the previous one.", - "Each declared factor shrinks the level above it and must be 2 or more: [2, 2] or [4, 4].", - ) - data = previous.data - trim = tuple( - slice(0, (int(data.shape[axis]) // factor) * factor) if dims[axis] in spatial else slice(None) - for axis in range(len(dims)) - ) - for axis in range(len(dims)): - if dims[axis] in spatial and int(data.shape[axis]) < factor: - raise DatasetManagerError( - f"scale factor {int(absolute)} shrinks axis '{dims[axis]}' (extent" - f" {int(data.shape[axis])}) to nothing at this level.", - "Stop the ladder before the factor outgrows the smallest axis.", - ) - # coarsen folds within blocks, so every spatial chunk must divide the factor; on the - # trimmed extent a factor-multiple chunk size guarantees the tail does too. - chunk = max(factor, (256 // factor) * factor) - working = data[trim].rechunk({axis: chunk if dims[axis] in spatial else -1 for axis in range(len(dims))}) - coarsened = dask.array.coarsen( - np.mean, working, {axis: factor for axis in range(len(dims)) if dims[axis] in spatial} - ) - if not np.issubdtype(np.dtype(data.dtype), np.floating): - # Round to nearest, half up, BEFORE the cast: astype truncates toward zero, and ITK's - # BinShrink (the reference for these levels) rounds -- a 0.5 window mean writes 1. - # Truncation would shift every integer level ~half an LSB down, silently and uniformly. - coarsened = dask.array.floor(coarsened + 0.5) - coarsened = coarsened.astype(data.dtype) - # The centre-of-voxel convention ngff-zarr itself applies: the coarse voxel's centre sits - # half the spacing delta past the fine one's. Getting this wrong composes every level a - # fraction of a voxel apart and still looks like an image. - scale = {dim: previous.scale[dim] * (factor if dim in spatial else 1) for dim in previous.scale} - translation = { - dim: previous.translation[dim] + (0.5 * (factor - 1) * previous.scale[dim] if dim in spatial else 0.0) - for dim in previous.translation - } - previous = dataclasses.replace(previous, data=coarsened, scale=scale, translation=translation) - previous_absolute = int(absolute) - images.append(previous) - - if out_chunks is not None: - images = [ - dataclasses.replace(level, data=level.data.rechunk(tuple(out_chunks))) - if hasattr(level.data, "rechunk") - else level - for level in images - ] - assembled = ngff_zarr.to_multiscales(image, scale_factors=[], chunks=out_chunks, cache=False) - datasets = [] - for index, level in enumerate(images): - level_multiscales = ngff_zarr.to_multiscales(level, scale_factors=[], chunks=out_chunks, cache=False) - dataset = level_multiscales.metadata.datasets[0] - path = f"scale{index}/{image.name}" - for transform_sequence in dataset.coordinateTransformations or []: - if getattr(transform_sequence, "input", None) is not None and hasattr(transform_sequence.input, "path"): - transform_sequence.input.path = path - dataset.path = path - datasets.append(dataset) - assembled.images = images - assembled.metadata = dataclasses.replace(assembled.metadata, datasets=datasets) - # Both OFF, deliberately: to_ngff_zarr RE-DERIVES every level whose index it can, whenever - # scale_factors, method and chunks are all set -- to_multiscales records its default method - # (the gaussian) even when asked for no levels. The levels' data never go through it (see - # append_ome_zarr_levels), but its metadata write must not try to derive them either. - assembled.scale_factors = [] - assembled.method = None - return assembled + array = zarr.open_group(str(store_path), mode="r+")[multiscales.metadata.datasets[0].path] + return _ComponentFlippedWriter(array) if displacement_field else array def append_ome_zarr_levels( @@ -1135,16 +1137,13 @@ def append_ome_zarr_levels( The companion of :func:`create_ome_zarr_store`: a store written region by region cannot be given ``scale_factors`` up front, because no level exists until the last region lands. This derives the - pyramid afterwards, from what is on disk, and grafts it BESIDE level 0. - - Level 0 is not rewritten, not moved, not read back whole: each coarser level is computed lazily - from it and stored straight into a new array of the same group, so the cost is one pass over - level 0 into a level 16x smaller (measured 55 s -> ~10 s on a 4.9 GB store), with a chunk-sized - peak. The multiscales metadata that names every level is still ngff-zarr's: it is described from - one-voxel stand-ins (the metadata does not depend on the extent) into a scratch store and copied - onto the group's attributes, so the axes, transforms and version spelling remain theirs. The - KonfAI attribute sidecar is untouched, being a key beside theirs; a displacement field keeps its - typed component axis through the same call that types it at creation. + pyramid afterwards, from what is on disk, and grafts it BESIDE level 0 + (``to_ngff_zarr(start_level=1)``): level 0 is not rewritten, not moved, not read back whole; each + coarser level is computed lazily from the one before it with a chunk-sized peak, and the + multiscales metadata that names every level lands last, so an interrupted call leaves a store + that still reads exactly as its level 0. The KonfAI attribute sidecar rides along as the root + attributes ngff-zarr read back beside the OME keys; a displacement field keeps its typed + component axis the same way. """ if uri.is_uri(store_path): raise DatasetManagerError( @@ -1156,60 +1155,59 @@ def append_ome_zarr_levels( return store = Path(store_path) clear_ome_zarr_cache(store) - field = is_displacement_field(store) - base = _from_ngff_zarr(store).images[0] - stored_chunks = tuple(int(size) for size in base.data.chunksize) - if downsample_method in (None, "ITKWASM_BIN_SHRINK"): - multiscales = _bin_shrink_multiscales(base, _level_zero_scale_factors(scale_factors), stored_chunks) - else: - multiscales = ngff_zarr.to_multiscales( - base, - scale_factors=_level_zero_scale_factors(scale_factors), - method=_downsample_method(downsample_method), - chunks=stored_chunks, - cache=False, - ) - version = _DEFAULT_VERSION - if field: - _require_zarr_v3_for_rfc5() - _type_component_axis(multiscales, _DISPLACEMENT_AXIS_TYPE) - version = _RFC5_VERSION - - group = zarr.open_group(str(store), mode="r+") - create_array = getattr(group, "create_array", None) or group.create_dataset - for level, dataset in zip(multiscales.images[1:], multiscales.metadata.datasets[1:], strict=True): - data = level.data.rechunk(stored_chunks) - array = create_array( - dataset.path, shape=data.shape, chunks=stored_chunks, dtype=data.dtype, fill_value=0, overwrite=True + multiscales = _from_ngff_zarr(store) + base = multiscales.images[0] + factors = _level_zero_scale_factors(scale_factors) + _refuse_factors_outgrowing_an_axis(base, factors) + derived = ngff_zarr.to_multiscales( + base, + scale_factors=factors, + method=_downsample_method(downsample_method), + chunks=tuple(int(size) for size in base.data.chunksize), + cache=False, + ) + derived.root_attributes = multiscales.root_attributes + if multiscales.metadata.coordinateTransformations: + # A conformant field keeps its ``displacements`` entry (and the coordinate system it names) + # through the append: the entry references level 0, which this never rewrites. + declared = {system.name for system in derived.metadata.coordinateSystems} + derived.metadata = dataclasses.replace( + derived.metadata, + coordinateSystems=[ + *derived.metadata.coordinateSystems, + *(s for s in multiscales.metadata.coordinateSystems if s.name not in declared), + ], + coordinateTransformations=multiscales.metadata.coordinateTransformations, ) - # Aligned chunks: each zarr chunk is written by exactly one task, so no lock is needed. - dask.array.store(data, array, lock=False) - - # The metadata LAST, so an interrupted call leaves a store that still reads as its level 0 - # (unreferenced arrays beside it are overwritten by the next call). - rank = base.data.ndim - 1 - described = dataclasses.replace( - multiscales, - images=[ - dataclasses.replace( - level, data=dask.array.zeros((level.data.shape[0], *(1,) * rank), dtype=level.data.dtype) - ) - for level in multiscales.images - ], - scale_factors=[], - method=None, + field = _has_displacement_axis(base) + # The coarse levels take level 0's own compressor, so the store stays uniform whatever wrote + # it; a v3 store carries a codec chain instead and keeps the writer's default. + level_zero = zarr.open_group(str(store), mode="r")[multiscales.metadata.datasets[0].path] + compressor = level_zero.metadata.to_dict().get("compressor") + ngff_zarr.to_ngff_zarr( + str(store), + derived, + overwrite=False, + version=_RFC5_VERSION if field else _DEFAULT_VERSION, + start_level=1, + **({"compressor": compressor} if compressor else {}), ) - scratch = store.with_name(f"{store.name}.describing") - shutil.rmtree(scratch, ignore_errors=True) - try: - ngff_zarr.to_ngff_zarr(str(scratch), described, overwrite=True, version=version) - group.attrs.update(dict(zarr.open_group(str(scratch), mode="r").attrs)) - finally: - shutil.rmtree(scratch, ignore_errors=True) - zarr.consolidate_metadata(str(store)) clear_ome_zarr_cache(store) +def _refuse_factors_outgrowing_an_axis(base: Any, factors: Sequence[int]) -> None: + """A factor that shrinks an axis to nothing is refused by name: ngff-zarr would fall back to + deriving that level from level 0 and write an empty array where a consumer's ``@N`` resolves.""" + for absolute in factors: + for axis, dim in enumerate(base.dims): + if dim in _SPATIAL and int(base.data.shape[axis]) // int(absolute) == 0: + raise DatasetManagerError( + f"scale factor {int(absolute)} shrinks axis '{dim}' (extent" + f" {int(base.data.shape[axis])}) to nothing at this level.", + "Stop the ladder before the factor outgrows the smallest axis.", + ) + + def get_ome_zarr_info(store_path: str | Path, level: int = 0) -> dict[str, Any]: """OME-Zarr metadata, without reading pixel data. @@ -1226,9 +1224,11 @@ def get_ome_zarr_info(store_path: str | Path, level: int = 0) -> dict[str, Any]: image = _load_image(str(store_path), level) dims = [str(axis).lower() for axis in image.dims] try: - n_levels = len(_from_ngff_zarr(store_path).images) + n_levels = len(_multiscales(str(store_path)).images) except (OSError, TypeError, ValueError): n_levels = 1 + scale = _ordered(dict(image.scale), dims) + translation = _ordered(dict(image.translation), dims) return { "axes": dims, "shape": list(image.data.shape), @@ -1239,17 +1239,12 @@ def get_ome_zarr_info(store_path: str | Path, level: int = 0) -> dict[str, Any]: "canonical_shape": _canonical_shape(dims, image.data.shape), "chunks": list(getattr(image.data, "chunks", []) or []), "dtype": str(image.data.dtype), - "scale": _ordered(dict(image.scale), dims), - "translation": _ordered(dict(image.translation), dims), + "scale": scale, + "translation": translation, # Keyed by axis name, so no caller has to know which of the two orders it is holding. "geometry": { - axis: {"scale": float(scale), "translation": float(translation)} - for axis, scale, translation in zip( - dims, - _ordered(dict(image.scale), dims), - _ordered(dict(image.translation), dims), - strict=True, - ) + axis: {"scale": float(value), "translation": float(offset)} + for axis, value, offset in zip(dims, scale, translation, strict=True) }, "n_levels": n_levels, "attributes": _read_konfai_attributes(store_path), diff --git a/konfai/utils/runtime/distributed.py b/konfai/utils/runtime/distributed.py index 7b53dc1f..c95d0303 100644 --- a/konfai/utils/runtime/distributed.py +++ b/konfai/utils/runtime/distributed.py @@ -516,8 +516,6 @@ def apply_cpu_thread_budget(world_size: int | None = None) -> None: try: import zarr - from konfai.utils.ome_zarr import _zarr_v3_available - # A THIRD of the share: a pipelined sweep runs three of these at once, the decode of the # region being read, the assembly of the one before it, the encode of the one being written. # Measured with the chain off the reading thread: 24 cores, ExaSPIM 513x1331x1776 through a stored affine, two runs per point, @@ -531,7 +529,7 @@ def apply_cpu_thread_budget(world_size: int | None = None) -> None: # costs the reader its own throughput (read busy 4.4-4.6 s at 8, 5.1-5.2 s at 24). A share # of a few cores keeps them all, up to four: a third of it is one chunk in flight, and on a # remote root that is the whole of the read's parallelism. - if _zarr_v3_available(): # 2.x has no config object, and no async reader to share the cores with + if hasattr(zarr, "config"): # 2.x has no config object, and no async reader to share the cores with zarr.config.set({"async.concurrency": max(min(cores, 4), cores // 3)}) except ImportError: pass diff --git a/pixi.lock b/pixi.lock index aa186cb6..bd493e15 100644 --- a/pixi.lock +++ b/pixi.lock @@ -52,7 +52,6 @@ environments: - pypi: 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 - pypi: https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl @@ -66,6 +65,7 @@ environments: - pypi: 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 - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl @@ -124,13 +124,13 @@ environments: - pypi: ./ - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl @@ -181,13 +181,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl @@ -265,9 +265,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/c6/906a1c94a9e0d12b6cbc8a36d6fe284d06298d3d3eda14934d21609845ab/itkwasm_downsample-1.8.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl @@ -307,6 +305,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/43/894c2cbbcbdf53b57d1257a249811abe2ee9ab7ef76af301b40f1c054533/mistune-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/e0/60466c6d712dad2cf807df315e39863e91609ffd1064ecb835994460bbda/pydicom-3.0.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl @@ -317,7 +316,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/5c/1f/1957d07725e6abdaef43b05c30a797fa2a2e7093ced9dbd750d796921a5d/ngff_zarr-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl @@ -387,10 +385,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/1a/f5e892909992c91e18b05f45237391ce0af54732a7cb393a79607fea0806/zarr_metadata-0.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/f6/b16f524cd61d4347b87882350a00869b99d52c1830618a323bd06eb77d0b/itkwasm_downsample-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/2b/2cca4261bab978eb3d9f1aa32fe7dcd9021e193bdc88b3dcf0bb366e9f91/itkwasm_downsample_wasi-1.8.1-py3-none-any.whl - pypi: 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 - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl @@ -436,12 +436,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/48/3d07340e3256b2cdf441b883da4fd2f7ab89d4894da94a4c627ba8b2f9eb/zarrista-0.1.0-cp311-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - pypi: 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 + - pypi: https://files.pythonhosted.org/packages/f7/90/011498cb18e1e3343207efc66e78c819323f3971e47bdb72b800bbd29f0d/ngff_zarr-0.45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl - pypi: 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 - pypi: 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 @@ -483,9 +485,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl - - pypi: https://files.pythonhosted.org/packages/12/c6/906a1c94a9e0d12b6cbc8a36d6fe284d06298d3d3eda14934d21609845ab/itkwasm_downsample-1.8.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl @@ -516,10 +516,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/41/d8/f37480ebc669af91ebab0b01ab649413446bd5cce365f3f50b71d05be2af/zarrista-0.1.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/43/894c2cbbcbdf53b57d1257a249811abe2ee9ab7ef76af301b40f1c054533/mistune-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/e0/60466c6d712dad2cf807df315e39863e91609ffd1064ecb835994460bbda/pydicom-3.0.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl @@ -528,7 +530,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5b/0b/a81b5daf5adea482ecb68d9615f6a348486ab4d8e980a915d4420e57ee4d/wasmtime-45.0.0-py3-none-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/5c/1f/1957d07725e6abdaef43b05c30a797fa2a2e7093ced9dbd750d796921a5d/ngff_zarr-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl @@ -598,8 +599,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/1a/f5e892909992c91e18b05f45237391ce0af54732a7cb393a79607fea0806/zarr_metadata-0.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/f6/b16f524cd61d4347b87882350a00869b99d52c1830618a323bd06eb77d0b/itkwasm_downsample-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/2b/2cca4261bab978eb3d9f1aa32fe7dcd9021e193bdc88b3dcf0bb366e9f91/itkwasm_downsample_wasi-1.8.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl @@ -644,6 +647,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl - pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/90/011498cb18e1e3343207efc66e78c819323f3971e47bdb72b800bbd29f0d/ngff_zarr-0.45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl @@ -683,10 +687,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/12/c6/906a1c94a9e0d12b6cbc8a36d6fe284d06298d3d3eda14934d21609845ab/itkwasm_downsample-1.8.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/a8/734725bb703c5fabb687f79c79e51249475212b3eb37771ac4a4ac9b487f/onnx-1.22.0-cp312-abi3-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl @@ -721,6 +723,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/43/894c2cbbcbdf53b57d1257a249811abe2ee9ab7ef76af301b40f1c054533/mistune-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/46/e0/60466c6d712dad2cf807df315e39863e91609ffd1064ecb835994460bbda/pydicom-3.0.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl @@ -728,7 +731,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/5c/1f/1957d07725e6abdaef43b05c30a797fa2a2e7093ced9dbd750d796921a5d/ngff_zarr-0.40.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl @@ -788,11 +790,14 @@ environments: - pypi: https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/af/2e/59f3ab896f5a8c76a3d009c40015a32660b1b05b0ff055efcee3aa977caa/zarrista-0.1.0-cp311-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b2/1a/f5e892909992c91e18b05f45237391ce0af54732a7cb393a79607fea0806/zarr_metadata-0.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/f6/b16f524cd61d4347b87882350a00869b99d52c1830618a323bd06eb77d0b/itkwasm_downsample-2.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/b5/2b/2cca4261bab978eb3d9f1aa32fe7dcd9021e193bdc88b3dcf0bb366e9f91/itkwasm_downsample_wasi-1.8.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl @@ -839,6 +844,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/90/011498cb18e1e3343207efc66e78c819323f3971e47bdb72b800bbd29f0d/ngff_zarr-0.45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl @@ -884,7 +890,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl @@ -902,6 +907,7 @@ environments: - pypi: 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 - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl @@ -979,7 +985,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl @@ -990,6 +995,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/28/fa0f2ff73b8ca7987ebaa107d369c95459741ac73567e5079b4a881a981b/sphinx_autodoc_typehints-3.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl @@ -1059,7 +1065,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl @@ -1070,6 +1075,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/28/fa0f2ff73b8ca7987ebaa107d369c95459741ac73567e5079b4a881a981b/sphinx_autodoc_typehints-3.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl @@ -1995,8 +2001,8 @@ packages: - simpleitk>=2.0 ; extra == 'imaging' - h5py ; extra == 'imaging' - pydicom ; extra == 'imaging' - - zarr ; extra == 'imaging' - - ngff-zarr>=0.38 ; extra == 'imaging' + - zarr>=3 ; extra == 'imaging' + - ngff-zarr>=0.45 ; extra == 'imaging' - dask ; extra == 'imaging' - vtk ; extra == 'vtk' - lpips ; extra == 'lpips' @@ -2006,8 +2012,8 @@ packages: - torchvision ; extra == 'fid' - submitit ; extra == 'cluster' - pydicom ; extra == 'dicom' - - zarr ; extra == 'omezarr' - - ngff-zarr>=0.38 ; extra == 'omezarr' + - zarr>=3 ; extra == 'omezarr' + - ngff-zarr>=0.45 ; extra == 'omezarr' - dask ; extra == 'omezarr' - s3fs ; extra == 's3' - aiohttp ; extra == 's3' @@ -2031,8 +2037,8 @@ packages: - simpleitk>=2.0 ; extra == 'dev' - h5py ; extra == 'dev' - pydicom ; extra == 'dev' - - zarr ; extra == 'dev' - - ngff-zarr>=0.38 ; extra == 'dev' + - zarr>=3 ; extra == 'dev' + - ngff-zarr>=0.45 ; extra == 'dev' - dask ; extra == 'dev' - scikit-image ; extra == 'dev' - onnx ; extra == 'dev' @@ -2058,7 +2064,7 @@ packages: - sphinx-copybutton ; extra == 'dev' - sphinx-togglebutton ; extra == 'dev' - sphinx-tabs ; extra == 'dev' - requires_python: '>=3.10' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl name: distlib version: 0.4.3 @@ -2338,15 +2344,6 @@ packages: version: 1.5.5 sha256: 0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/12/c6/906a1c94a9e0d12b6cbc8a36d6fe284d06298d3d3eda14934d21609845ab/itkwasm_downsample-1.8.1-py3-none-any.whl - name: itkwasm-downsample - version: 1.8.1 - sha256: f1315d32a1d80a610c682c3a32ab41aa46b584f91f17a839a7c0b6e3796e60c7 - requires_dist: - - itkwasm-downsample-emscripten ; sys_platform == 'emscripten' - - itkwasm-downsample-wasi ; sys_platform != 'emscripten' - - itkwasm>=1.0b145 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl name: filelock version: 3.29.4 @@ -2363,11 +2360,6 @@ packages: - ml-dtypes>=0.5.4 - pillow ; extra == 'reference' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - name: typing-extensions - version: 4.15.0 - sha256: f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl name: absl-py version: 2.4.0 @@ -2921,6 +2913,13 @@ packages: requires_dist: - referencing>=0.31.0 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/41/d8/f37480ebc669af91ebab0b01ab649413446bd5cce365f3f50b71d05be2af/zarrista-0.1.0-cp311-abi3-macosx_11_0_arm64.whl + name: zarrista + version: 0.1.0 + sha256: 28ea1359ec791e84e1438e38e3047a964a3e7d59f56f122255a44ec2b368f33f + requires_dist: + - zarr-metadata>=0.4 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl name: id version: 1.6.1 @@ -2997,6 +2996,11 @@ packages: - pylibjpeg[rle] ; extra == 'pixeldata' - python-gdcm ; extra == 'pixeldata' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl + name: typing-extensions + version: 4.16.0 + sha256: 481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl name: imageio version: 2.37.3 @@ -3316,89 +3320,6 @@ packages: version: 0.15.2 sha256: d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/5c/1f/1957d07725e6abdaef43b05c30a797fa2a2e7093ced9dbd750d796921a5d/ngff_zarr-0.40.0-py3-none-any.whl - name: ngff-zarr - version: 0.40.0 - sha256: d8b07b4bc27a198190cfa98b292b5f1a5b7fb88ff3a72e401308347abb852866 - requires_dist: - - dask[array]>=2026.1.2 - - importlib-resources - - itkwasm-downsample>=1.8.0 - - itkwasm>=1.0b183 - - numpy - - platformdirs - - psutil ; sys_platform != 'emscripten' - - rich - - rich-argparse - - typing-extensions - - zarr - - adlfs ; extra == 'all' - - aiohttp ; extra == 'all' - - dask-image ; extra == 'all' - - dask[distributed] ; extra == 'all' - - deepdiff ; extra == 'all' - - fsspec ; extra == 'all' - - furo>=2024.7.18,<2025 ; extra == 'all' - - gcsfs ; extra == 'all' - - imagecodecs>=2025.3.30,<2026.5.0 ; extra == 'all' - - imageio ; extra == 'all' - - itk-filtering>=5.3.0 ; extra == 'all' - - itk-io>=5.3.0 ; extra == 'all' - - itkwasm-image-io ; extra == 'all' - - jsonschema ; extra == 'all' - - liffile ; extra == 'all' - - myst-parser>=3.0.1,<4 ; extra == 'all' - - nibabel ; extra == 'all' - - pooch ; extra == 'all' - - pytest>=6 ; extra == 'all' - - requests ; extra == 'all' - - s3fs ; extra == 'all' - - sphinx-autobuild>=2024.4.16,<2025 ; extra == 'all' - - sphinx-autodoc2>=0.5.0,<0.6 ; extra == 'all' - - sphinx-copybutton>=0.5.2,<0.6 ; extra == 'all' - - sphinx-design>=0.6.0,<0.7 ; extra == 'all' - - sphinx>=7.4.7,<8 ; extra == 'all' - - sphinxext-opengraph>=0.9.1,<0.10 ; extra == 'all' - - tensorstore ; extra == 'all' - - tifffile>=2024.7.24 ; extra == 'all' - - adlfs ; extra == 'cli' - - aiohttp ; extra == 'cli' - - dask-image ; extra == 'cli' - - dask[distributed] ; extra == 'cli' - - fsspec ; extra == 'cli' - - gcsfs ; extra == 'cli' - - imagecodecs>=2025.3.30,<2026.5.0 ; extra == 'cli' - - imageio ; extra == 'cli' - - itk-filtering>=5.3.0 ; extra == 'cli' - - itk-io>=5.3.0 ; extra == 'cli' - - itkwasm-image-io ; extra == 'cli' - - liffile ; extra == 'cli' - - nibabel ; extra == 'cli' - - requests ; extra == 'cli' - - s3fs ; extra == 'cli' - - tifffile>=2024.7.24 ; extra == 'cli' - - dask-image ; extra == 'dask-image' - - furo>=2024.7.18,<2025 ; extra == 'docs' - - myst-parser>=3.0.1,<4 ; extra == 'docs' - - sphinx-autobuild>=2024.4.16,<2025 ; extra == 'docs' - - sphinx-autodoc2>=0.5.0,<0.6 ; extra == 'docs' - - sphinx-copybutton>=0.5.2,<0.6 ; extra == 'docs' - - sphinx-design>=0.6.0,<0.7 ; extra == 'docs' - - sphinx>=7.4.7,<8 ; extra == 'docs' - - sphinxext-opengraph>=0.9.1,<0.10 ; extra == 'docs' - - itk-filtering>=5.3.0 ; extra == 'itk' - - adlfs ; extra == 'remote' - - aiohttp ; extra == 'remote' - - fsspec ; extra == 'remote' - - gcsfs ; extra == 'remote' - - requests ; extra == 'remote' - - s3fs ; extra == 'remote' - - tensorstore ; extra == 'tensorstore' - - deepdiff ; extra == 'test' - - pooch ; extra == 'test' - - pytest>=6 ; extra == 'test' - - jsonschema ; extra == 'validate' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl name: charset-normalizer version: 3.4.7 @@ -4642,6 +4563,13 @@ packages: - pytest ; extra == 'testing' - tox ; extra == 'testing' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/af/2e/59f3ab896f5a8c76a3d009c40015a32660b1b05b0ff055efcee3aa977caa/zarrista-0.1.0-cp311-abi3-win_amd64.whl + name: zarrista + version: 0.1.0 + sha256: 5723fc1a4960392ba220ae17dcf8830805c653f86e9f1933396175062af0cb3d + requires_dist: + - zarr-metadata>=0.4 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl name: h5py version: 3.16.0 @@ -4669,6 +4597,13 @@ packages: version: 0.3.0 sha256: 841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780 requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/b2/1a/f5e892909992c91e18b05f45237391ce0af54732a7cb393a79607fea0806/zarr_metadata-0.5.0-py3-none-any.whl + name: zarr-metadata + version: 0.5.0 + sha256: 9d0128cee0356def01b0c23cbd9006874452ce1b34489c06f30b3883129a3336 + requires_dist: + - typing-extensions>=4.16 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: ast-serialize version: 0.5.0 @@ -4731,6 +4666,15 @@ packages: - pytest-timeout ; extra == 'testing' - requests ; extra == 'testing' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b3/f6/b16f524cd61d4347b87882350a00869b99d52c1830618a323bd06eb77d0b/itkwasm_downsample-2.0.2-py3-none-any.whl + name: itkwasm-downsample + version: 2.0.2 + sha256: e4aad62f586d05ac51fdfe644be21174ceb67a5f9dc10332361bb6f89afdfdfb + requires_dist: + - itkwasm-downsample-emscripten ; sys_platform == 'emscripten' + - itkwasm-downsample-wasi ; sys_platform != 'emscripten' + - itkwasm>=1.0b185 + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl name: psutil version: 7.2.2 @@ -5581,6 +5525,13 @@ packages: - pyreadline ; python_full_version < '3.8' and sys_platform == 'win32' - pyreadline3 ; python_full_version >= '3.8' and sys_platform == 'win32' requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' +- pypi: https://files.pythonhosted.org/packages/f0/48/3d07340e3256b2cdf441b883da4fd2f7ab89d4894da94a4c627ba8b2f9eb/zarrista-0.1.0-cp311-abi3-manylinux_2_28_x86_64.whl + name: zarrista + version: 0.1.0 + sha256: a2840000a75fc8367bf44e751c0ef2200138c31c6d859ad03a1dc27059293153 + requires_dist: + - zarr-metadata>=0.4 + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl name: stack-data version: 0.6.3 @@ -5700,6 +5651,76 @@ packages: - ruff>=0.12.0 ; extra == 'dev' - cython-lint>=0.12.2 ; extra == 'dev' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/f7/90/011498cb18e1e3343207efc66e78c819323f3971e47bdb72b800bbd29f0d/ngff_zarr-0.45.0-py3-none-any.whl + name: ngff-zarr + version: 0.45.0 + sha256: 9860a09986a938f52881d0def6ed96aee2837ca808c358fd1db011866d6bacc0 + requires_dist: + - dask[array]>=2026.1.2 + - importlib-resources + - itkwasm-downsample>=2.0.2 + - itkwasm>=1.0b183 + - numcodecs + - numpy + - packaging + - platformdirs + - psutil ; sys_platform != 'emscripten' + - rich + - rich-argparse + - typing-extensions + - zarrista + - dask-image ; extra == 'all' + - dask[distributed] ; extra == 'all' + - deepdiff ; extra == 'all' + - furo>=2024.7.18,<2025 ; extra == 'all' + - imagecodecs>=2025.3.30,<2026.5.0 ; extra == 'all' + - imageio ; extra == 'all' + - itk-filtering>=5.3.0 ; extra == 'all' + - itk-io>=5.3.0 ; extra == 'all' + - itkwasm-image-io ; extra == 'all' + - jsonschema ; extra == 'all' + - liffile ; extra == 'all' + - myst-parser>=3.0.1,<4 ; extra == 'all' + - nibabel ; extra == 'all' + - obstore ; extra == 'all' + - pooch ; extra == 'all' + - pytest>=6 ; extra == 'all' + - sphinx-autobuild>=2024.4.16,<2025 ; extra == 'all' + - sphinx-autodoc2>=0.5.0,<0.6 ; extra == 'all' + - sphinx-copybutton>=0.5.2,<0.6 ; extra == 'all' + - sphinx-design>=0.6.0,<0.7 ; extra == 'all' + - sphinx>=7.4.7,<8 ; extra == 'all' + - sphinxext-opengraph>=0.9.1,<0.10 ; extra == 'all' + - tifffile>=2024.7.24 ; extra == 'all' + - zarr ; extra == 'all' + - dask-image ; extra == 'cli' + - dask[distributed] ; extra == 'cli' + - imagecodecs>=2025.3.30,<2026.5.0 ; extra == 'cli' + - imageio ; extra == 'cli' + - itk-filtering>=5.3.0 ; extra == 'cli' + - itk-io>=5.3.0 ; extra == 'cli' + - itkwasm-image-io ; extra == 'cli' + - liffile ; extra == 'cli' + - nibabel ; extra == 'cli' + - obstore ; extra == 'cli' + - tifffile>=2024.7.24 ; extra == 'cli' + - dask-image ; extra == 'dask-image' + - furo>=2024.7.18,<2025 ; extra == 'docs' + - myst-parser>=3.0.1,<4 ; extra == 'docs' + - sphinx-autobuild>=2024.4.16,<2025 ; extra == 'docs' + - sphinx-autodoc2>=0.5.0,<0.6 ; extra == 'docs' + - sphinx-copybutton>=0.5.2,<0.6 ; extra == 'docs' + - sphinx-design>=0.6.0,<0.7 ; extra == 'docs' + - sphinx>=7.4.7,<8 ; extra == 'docs' + - sphinxext-opengraph>=0.9.1,<0.10 ; extra == 'docs' + - itk-filtering>=5.3.0 ; extra == 'itk' + - obstore ; extra == 'remote' + - deepdiff ; extra == 'test' + - pooch ; extra == 'test' + - pytest>=6 ; extra == 'test' + - zarr ; extra == 'test' + - jsonschema ; extra == 'validate' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl name: debugpy version: 1.8.21 diff --git a/pyproject.toml b/pyproject.toml index 718f24cc..a83971e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "A declarative execution engine for reproducible medical-imaging workflows" readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Valentin Boussot", email = "boussot.v@gmail.com" } @@ -22,7 +22,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -58,7 +57,7 @@ itk = ["SimpleITK>=2.0"] hdf5 = ["h5py"] monitoring = ["nvidia-ml-py"] tensorboard = ["tensorboard"] -imaging = ["SimpleITK>=2.0", "h5py", "pydicom", "zarr", "ngff-zarr>=0.38", "dask"] +imaging = ["SimpleITK>=2.0", "h5py", "pydicom", "zarr>=3", "ngff-zarr>=0.45", "dask"] vtk = ["vtk"] lpips = ["lpips"] smp = ["segmentation-models-pytorch"] @@ -66,7 +65,7 @@ ssim = ["scikit-image"] fid = ["scipy", "torchvision"] cluster = ["submitit"] dicom = ["pydicom"] -omezarr = ["zarr", "ngff-zarr>=0.38", "dask"] +omezarr = ["zarr>=3", "ngff-zarr>=0.45", "dask"] # Reading a dataset root from S3: s3fs is the fsspec implementation, aiohttp the transport # aiobotocore drives it over. fsspec itself already comes with the OME-Zarr extra, through dask. s3 = ["s3fs", "aiohttp"] @@ -91,8 +90,8 @@ dev = [ "SimpleITK>=2.0", "h5py", "pydicom", - "zarr", - "ngff-zarr>=0.38", + "zarr>=3", + "ngff-zarr>=0.45", "dask", "scikit-image", "onnx", @@ -143,7 +142,7 @@ platforms = ["linux-64", "osx-arm64", "win-64"] konfai = { path = ".", editable = true } [tool.pixi.dependencies] -python = ">=3.10,<3.14" +python = ">=3.11,<3.14" pip = "*" [tool.pixi.environments] @@ -170,8 +169,8 @@ h5py = "*" tensorboard = "*" nvidia-ml-py = "*" pydicom = "*" -zarr = "*" -ngff-zarr = ">=0.38" +zarr = ">=3" +ngff-zarr = ">=0.45" dask = "*" scikit-image = "*" @@ -191,7 +190,7 @@ check = { depends-on = ["lint", "format-check", "test", "test-apps"], descriptio # Feature: lint # --------------------------------------------------------------------------- [tool.pixi.feature.lint.dependencies] -python = ">=3.10,<3.14" +python = ">=3.11,<3.14" [tool.pixi.feature.lint.pypi-dependencies] ruff = "==0.15.2" @@ -205,7 +204,7 @@ pre-commit-run = { cmd = "pre-commit run --all-files", description = "Run pre-co # Feature: docs # --------------------------------------------------------------------------- [tool.pixi.feature.docs.dependencies] -python = ">=3.10,<3.14" +python = ">=3.11,<3.14" [tool.pixi.feature.docs.pypi-dependencies] sphinx = ">=7.0" @@ -255,7 +254,7 @@ check_untyped_defs = true # No `python_version`: mypy analyses as the interpreter it runs on, which is the only version whose # stubs are installed. Pinning it to the floor of `requires-python` makes mypy refuse to parse # NumPy's stubs (they use `type` statements) and a parse error inside a dependency stops the whole -# analysis. Syntax newer than 3.10 is caught by ruff instead, which infers its target from +# analysis. Syntax newer than 3.11 is caught by ruff instead, which infers its target from # `requires-python` (rule set `UP`). # --------------------------------------------------------------------------- diff --git a/studio/pyproject.toml b/studio/pyproject.toml index 3436db0c..09d215af 100644 --- a/studio/pyproject.toml +++ b/studio/pyproject.toml @@ -9,7 +9,7 @@ name = "konfai-studio" dynamic = ["version", "dependencies"] description = "KonfAI Studio - a chatbot web UI driving the konfai-mcp server (BFF + front)" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = { text = "Apache-2.0" } [project.optional-dependencies] diff --git a/studio/tests/test_live_feed.py b/studio/tests/test_live_feed.py index 40206078..6bc9f18c 100644 --- a/studio/tests/test_live_feed.py +++ b/studio/tests/test_live_feed.py @@ -63,9 +63,8 @@ async def drain() -> None: break try: - # wait_for, not asyncio.timeout: the package supports 3.10 and the latter is 3.11+. await asyncio.wait_for(drain(), timeout=2) - except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError): + except (TimeoutError, asyncio.CancelledError): pass finally: await stream.aclose() @@ -657,7 +656,7 @@ async def drain() -> None: try: await asyncio.wait_for(drain(), timeout=3) - except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError): + except (TimeoutError, asyncio.CancelledError): pass finally: await stream.aclose() diff --git a/tests/unit/oracle_support.py b/tests/unit/oracle_support.py index acdee0c3..42c4b4d8 100644 --- a/tests/unit/oracle_support.py +++ b/tests/unit/oracle_support.py @@ -29,7 +29,6 @@ """ import inspect -import types from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -613,11 +612,8 @@ def builtin_transforms() -> list[type[Transform]]: """Every concrete transform class KonfAI ships.""" return [ cls - # A subscripted builtin generic (SpatialStages) passes isclass on Python 3.10 but is not a - # class there, and issubclass refuses it. for _, cls in inspect.getmembers(transform_module, inspect.isclass) - if not isinstance(cls, types.GenericAlias) - and issubclass(cls, Transform) + if issubclass(cls, Transform) and cls.__module__.startswith(transform_module.__name__) and not inspect.isabstract(cls) ] @@ -748,8 +744,7 @@ def builtin_augmentations() -> list[type[DataAugmentation]]: return [ cls for _, cls in inspect.getmembers(augmentation_module, inspect.isclass) - if not isinstance(cls, types.GenericAlias) - and issubclass(cls, DataAugmentation) + if issubclass(cls, DataAugmentation) and cls.__module__ == augmentation_module.__name__ and not inspect.isabstract(cls) ] diff --git a/tests/unit/test_case_reduction.py b/tests/unit/test_case_reduction.py index 6ea349eb..8dda475d 100644 --- a/tests/unit/test_case_reduction.py +++ b/tests/unit/test_case_reduction.py @@ -20,6 +20,7 @@ never assembled, that a cases which does not agree on its grid is refused before anything is read, and that a chain continues after the reduction.""" +import dataclasses import itertools import time import weakref @@ -847,6 +848,40 @@ def test_the_plan_prices_what_a_member_chain_holds_beside_its_region(tmp_path: P ) +def test_the_plan_prices_the_source_window_a_member_pulls(tmp_path: Path) -> None: + """A member's region is not its source: a chain that resamples reaches a box around it, and the + fold holds that box while it produces the region. + + The fold priced the landed regions, the operator and the output, and charged nothing for the + pull -- so the sizing rested on a figure the run walked straight past. Measured on the bench's + own ``Mean +resample x5`` at a 256 MiB budget: the plan printed 127.63 MiB and the run peaked at + 1332 MiB; charged, the same row peaks at 754 MiB for a plan of 115 MiB, and the run that cannot + fit is refused instead of overshooting. + """ + resampled = _run( + tmp_path / "resampled", [Resample(spacing=[1.0, 1.0, 2.0])], Reduce(operator="Mean", output="t"), [] + )[0] + plan = resampled.plan() + assert plan.pull_bytes > 0, "a resampling member pulls a source window, and one is resident" + assert plan.peak_bytes - dataclasses.replace(plan, pull_bytes=0).peak_bytes >= plan.pull_bytes, ( + "the peak the sizing is derived from must carry the window, or it is not a bound" + ) + + +def test_a_budget_no_region_fits_is_refused_rather_than_cut_to_one_row(tmp_path: Path) -> None: + """Below one row there is nothing to cut, and no whole-volume path to fall back to: the sizing + stops at one row and the plan reports a peak above the budget, which is what the workflow + refuses on. Sizing it to something that does not fit and running anyway is what a linear + extrapolation through one height used to do.""" + resampled = _run(tmp_path / "tight", [Resample(spacing=[1.0, 1.0, 2.0])], Reduce(operator="Mean", output="t"), [])[ + 0 + ] + resampled.fit_budget(4096) + + assert resampled.slab_rows == 1, "one row is the floor, and the plan then says it does not fit" + assert resampled.plan().peak_bytes > 4096, "the peak must exceed the budget for the refusal to fire" + + def test_a_generous_budget_stops_at_the_plateau_and_never_below_the_slab_floor(tmp_path: Path) -> None: """The budget is a CEILING, not a target: past the height where a chain reads no fewer source voxels, a taller region only holds more, so the sizing stops there however much memory it is diff --git a/tests/unit/test_imaging_formats.py b/tests/unit/test_imaging_formats.py index dea90fb2..1456f151 100644 --- a/tests/unit/test_imaging_formats.py +++ b/tests/unit/test_imaging_formats.py @@ -312,22 +312,6 @@ def test_raises_without_zarr(self) -> None: with pytest.raises(DatasetManagerError, match="zarr is required"): ome_zarr._require_zarr() - def test_displacement_field_raises_without_zarr_v3(self, tmp_path: Path) -> None: - """An RFC-5 field is an NGFF >= 0.5 store, which only zarr v3 can write. The check must fire - before ngff-zarr is handed the version, so what surfaces on zarr 2.x names the requirement - instead of the ValueError ngff-zarr raises from inside its writer.""" - pytest.importorskip("zarr") - from konfai.utils import ome_zarr - - field = np.zeros((3, 2, 3, 4), dtype=np.float32) - with patch.object(ome_zarr, "_zarr_v3_available", lambda: False): - with pytest.raises(DatasetManagerError, match="zarr v3"): - ome_zarr.write_ome_zarr(tmp_path / "DVF.ome.zarr", field, displacement_field=True) - - # The guard is specific to RFC-5: an ordinary image is a 0.4 store and stays writable. - ome_zarr.write_ome_zarr(tmp_path / "Image.ome.zarr", field) - assert (tmp_path / "Image.ome.zarr").is_dir() - class TestDatasetImagingBackends: def test_ome_zarr_dataset_round_trip_and_patch_read(self, tmp_path: Path) -> None: diff --git a/tests/unit/test_ome_zarr_data_surface.py b/tests/unit/test_ome_zarr_data_surface.py index ed8edb4a..05ed2336 100644 --- a/tests/unit/test_ome_zarr_data_surface.py +++ b/tests/unit/test_ome_zarr_data_surface.py @@ -113,13 +113,13 @@ def test_a_stepped_selection_falls_back_instead_of_returning_a_wrong_window(tmp_ def test_the_konfai_sidecar_is_read_once_and_not_once_per_region(tmp_path: Path) -> None: """It is metadata, and a streamed run asks for it once per region: re-opening the store each time is a read it never needed.""" - from konfai.utils.ome_zarr import _konfai_attributes, clear_ome_zarr_cache + from konfai.utils.ome_zarr import _multiscales, clear_ome_zarr_cache store = _big_endian_store(tmp_path / "big_endian.ome.zarr", _volume()) clear_ome_zarr_cache() for row in range(4): read_ome_zarr_data_slice(store, (slice(None), slice(row, row + 1), slice(None), slice(None))) - assert _konfai_attributes.cache_info() == (3, 1, 8, 1), "one read of the sidecar, three regions" + assert _multiscales.cache_info().misses == 1, "one parse of the store, however many regions" def test_info_publishes_the_shape_the_reader_indexes(tmp_path: Path) -> None: diff --git a/tests/unit/test_omezarr_displacement_field.py b/tests/unit/test_omezarr_displacement_field.py index 62578275..a7ae4a15 100644 --- a/tests/unit/test_omezarr_displacement_field.py +++ b/tests/unit/test_omezarr_displacement_field.py @@ -34,19 +34,12 @@ from konfai.utils.dataset import DISPLACEMENT_FIELD_ATTRIBUTE, Attribute, Dataset # noqa: E402 from konfai.utils.ome_zarr import ( # noqa: E402 - _zarr_v3_available, + append_ome_zarr_levels, clear_ome_zarr_cache, is_displacement_field, write_ome_zarr, ) -# RFC-5 fields are a zarr v3 store (NGFF >= 0.5), which zarr 2.x (Python 3.10) cannot write: the -# feature does not exist there. -pytestmark = pytest.mark.skipif( - not _zarr_v3_available(), - reason="NGFF RFC-5 displacement fields need a zarr v3 store (zarr>=3, Python>=3.11)", -) - SPACING = (1.5, 1.5, 2.0) ORIGIN = (7.0, -3.0, 10.0) # A 90 deg in-plane rotation: an identity matrix would let a dropped Direction pass unnoticed. @@ -167,3 +160,144 @@ def test_a_field_streamed_region_by_region_is_still_a_field(tmp_path: Path) -> N clear_ome_zarr_cache() assert is_displacement_field(tmp_path / "dataset" / "case" / "DVF.ome.zarr") assert isinstance(dataset.read_transform("DVF", "case"), sitk.DisplacementFieldTransform) + + +def _axis_aligned_field_transform() -> "sitk.DisplacementFieldTransform": + """The conformant case: a field whose grid carries no rotation.""" + values = np.arange(4 * 5 * 6 * 3, dtype=np.float64).reshape(4, 5, 6, 3) + field = sitk.GetImageFromArray(values, isVector=True) + field.SetSpacing(SPACING) + field.SetOrigin(ORIGIN) + return sitk.DisplacementFieldTransform(sitk.Cast(field, sitk.sitkVectorFloat64)) + + +def test_an_axis_aligned_field_is_an_applicable_rfc5_transformation(tmp_path: Path) -> None: + """The store is not merely labelled, a spec reader can APPLY it. + + ngff-zarr reads the ``displacements`` entry the writer declares, rebuilds a native ITK + transform from the store alone -- no KonfAI in the loop -- and that transform maps points + exactly as the SimpleITK original. This is the whole meaning of conformance: the mistakes it + rules out (component order, value frame) are silent and produce plausible registrations. + """ + itk = pytest.importorskip("itk") + import dataclasses + + import ngff_zarr + + original = _axis_aligned_field_transform() + _store(tmp_path).write("case", "DVF", original) + store = next(tmp_path.rglob("*.ome.zarr")) + + multiscales = ngff_zarr.from_ngff_zarr(str(store)) + entries = multiscales.metadata.coordinateTransformations or [] + assert [entry.type for entry in entries] == ["displacements"] + + wasm = ngff_zarr.ngff_displacement_field_to_itk_transform(entries[0], multiscales, ["z", "y", "x"]) + native = itk.transform_from_dict(dataclasses.asdict(wasm[0])) + native = native[0] if isinstance(native, (list, tuple)) else native + for point in ((9.0, -1.0, 12.0), (7.5, -2.5, 11.0), (10.0, 0.0, 14.0)): + assert tuple(native.TransformPoint(point)) == pytest.approx(original.TransformPoint(point)) + + +def test_a_conformant_store_holds_spec_ordered_components(tmp_path: Path) -> None: + """On disk the components follow the output axes (dz, dy, dx); through KonfAI's reader they + come back in ITK's (dx, dy, dz). Both at once, or one side is silently wrong: the store for + every spec reader, the flip for every KonfAI consumer.""" + import zarr + from konfai.data import read_ome_zarr_data_slice + + values = np.arange(3 * 4 * 5 * 6, dtype=np.float32).reshape(3, 4, 5, 6) / 100.0 + attributes = Attribute() + attributes["Spacing"] = np.asarray(SPACING) + attributes["Origin"] = np.asarray(ORIGIN) + attributes[DISPLACEMENT_FIELD_ATTRIBUTE] = "true" + + dataset = _store(tmp_path) + stream = dataset.open_data_stream("DVF", "case", list(values.shape), values.dtype, attributes) + assert stream is not None + with stream: + for z in range(0, values.shape[1], 2): + stream.write_slice( + (slice(0, 3), slice(z, z + 2), slice(0, values.shape[2]), slice(0, values.shape[3])), + values[:, z : z + 2], + ) + clear_ome_zarr_cache() + store = next(tmp_path.rglob("*.ome.zarr")) + + stored = zarr.open_group(str(store), mode="r")["scale0/image"][...] + np.testing.assert_array_equal(stored, values[::-1], err_msg="the store must hold (dz, dy, dx)") + + back, _ = read_ome_zarr_data_slice(store, tuple(slice(None) for _ in values.shape)) + np.testing.assert_array_equal(back, values, err_msg="the reader must hand back (dx, dy, dz)") + one, _ = read_ome_zarr_data_slice(store, (slice(0, 1), *[slice(None)] * 3)) + np.testing.assert_array_equal(one[0], values[0], err_msg="a partial component read must remap too") + + +def test_an_oriented_field_keeps_the_label_only_layout(tmp_path: Path) -> None: + """A rotated grid has no RFC-5 spelling (scale and translation cannot carry a direction), so + no ``displacements`` entry that would promise a reader an application it cannot make -- but the + LAYOUT is the same one every field shares: components in the spec's order, marked as such, the + Direction in the sidecar. One convention on disk, whatever the grid.""" + import ngff_zarr + import zarr + + original = _displacement_field_transform() # carries the 90-degree DIRECTION + _store(tmp_path).write("case", "DVF", original) + store = next(tmp_path.rglob("*.ome.zarr")) + + multiscales = ngff_zarr.from_ngff_zarr(str(store)) + assert not (multiscales.metadata.coordinateTransformations or []) + assert is_displacement_field(store) + stored = zarr.open_group(str(store), mode="r")["scale0/image"][...] + itk_order = np.moveaxis(sitk.GetArrayFromImage(original.GetDisplacementField()), -1, 0) + np.testing.assert_array_equal(stored, itk_order[::-1], err_msg="oriented fields share the spec order") + + +def test_a_pre_19_store_is_refused_by_name(tmp_path: Path) -> None: + """A store with the typed axis but neither the ``displacements`` entry nor the component-order + marker is every store this backend wrote before 1.9, and its components are ITK-ordered. Read + under either convention it is a guess -- a plausible field with dx and dz possibly exchanged -- + so it is refused with the layout named, not read with one.""" + import dask.array + import ngff_zarr + from konfai.data import read_ome_zarr_data_slice + + values = np.arange(3 * 4 * 5 * 6, dtype=np.float32).reshape(3, 4, 5, 6) + image = ngff_zarr.to_ngff_image( + dask.array.from_array(values, chunks=values.shape), + dims=["c", "z", "y", "x"], + scale={"c": 1.0, "z": SPACING[2], "y": SPACING[1], "x": SPACING[0]}, + translation={"c": 0.0, "z": ORIGIN[2], "y": ORIGIN[1], "x": ORIGIN[0]}, + ) + image.axes_types = {"c": "displacement"} + multiscales = ngff_zarr.to_multiscales(image, scale_factors=[], cache=False) + store = tmp_path / "legacy.ome.zarr" + ngff_zarr.to_ngff_zarr(str(store), multiscales, overwrite=True, version="0.6") + clear_ome_zarr_cache() + + with pytest.raises(DatasetManagerError, match=r"KonfAI < 1\.9"): + read_ome_zarr_data_slice(store, tuple(slice(None) for _ in values.shape)) + + +@pytest.mark.parametrize("oriented", [False, True], ids=["axis-aligned", "oriented"]) +def test_appending_levels_keeps_the_field_a_field(tmp_path: Path, oriented: bool) -> None: + """A pyramid grafted onto a field store rewrites the multiscales document, which is where the + typed component axis and the ``displacements`` entry live. Both must survive, or the store + comes back an ordinary 3-channel image and nothing says so.""" + import ngff_zarr + + transform = _displacement_field_transform() if oriented else _axis_aligned_field_transform() + _store(tmp_path).write("case", "DVF", transform) + store = next(tmp_path.rglob("*.ome.zarr")) + before = ngff_zarr.from_ngff_zarr(str(store)).metadata + + append_ome_zarr_levels(store, [2]) + clear_ome_zarr_cache() + + after = ngff_zarr.from_ngff_zarr(str(store)).metadata + assert [dataset.path for dataset in after.datasets] == ["scale0/image", "scale1/image"] + assert is_displacement_field(store) + # The entry an axis-aligned field declares keeps naming level 0, which the append never + # rewrites; an oriented one has none to keep. + assert (after.coordinateTransformations or []) == (before.coordinateTransformations or []) + assert {system.name for system in after.coordinateSystems} == {system.name for system in before.coordinateSystems} diff --git a/tests/unit/test_omezarr_store_creation.py b/tests/unit/test_omezarr_store_creation.py index 570fc595..f2968a45 100644 --- a/tests/unit/test_omezarr_store_creation.py +++ b/tests/unit/test_omezarr_store_creation.py @@ -35,7 +35,6 @@ import zarr from konfai.utils.ome_zarr import ( - _zarr_v3_available, clear_ome_zarr_cache, create_ome_zarr_store, get_ome_zarr_info, @@ -159,10 +158,6 @@ def test_creating_a_store_writes_no_pixel_bytes(tmp_path: Path) -> None: assert written < metadata_only, f"{written} bytes written for an untouched store" -@pytest.mark.skipif( - not _zarr_v3_available(), - reason="NGFF RFC-5 displacement fields need a zarr v3 store (zarr>=3, Python>=3.11)", -) def test_a_streamed_displacement_field_says_that_it_is_one(tmp_path: Path) -> None: """A field written region by region must be as self-describing as one written whole. @@ -187,3 +182,20 @@ def test_a_store_is_not_a_field_unless_it_was_asked_to_be(tmp_path: Path) -> Non clear_ome_zarr_cache() assert not is_displacement_field(tmp_path / "image.ome.zarr") + + +def test_stores_keep_the_byte_shuffled_blosc_compressor(tmp_path: Path) -> None: + """The zarrista writer's default is zstd-0; measured on a CT-like uint16 volume that costs + +19 % of disk and ~+11 % on the streamed read against the byte-shuffled lz4 every 1.8.2 store + carries, so the v2 compressor is pinned, and an appended level takes level 0's own.""" + from konfai.utils.ome_zarr import append_ome_zarr_levels, write_ome_zarr + + volume = np.arange(1 * 16 * 16 * 16, dtype=np.uint16).reshape(1, 16, 16, 16) + store = tmp_path / "v.ome.zarr" + write_ome_zarr(store, volume, spacing=[1.0, 1.0, 1.0], origin=[0.0, 0.0, 0.0]) + append_ome_zarr_levels(store, [4]) + group = zarr.open_group(str(store), mode="r") + for key in ("scale0/image", "scale1/image"): + compressor = group[key].metadata.to_dict().get("compressor") + assert compressor is not None and compressor["id"] == "blosc", (key, compressor) + assert compressor["cname"] == "lz4" and compressor["shuffle"] == 1, (key, compressor) diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py index cee992c4..fa9f0d96 100644 --- a/tests/unit/test_runtime.py +++ b/tests/unit/test_runtime.py @@ -33,7 +33,6 @@ from konfai.predictor import Predictor from konfai.trainer import Trainer from konfai.utils.errors import ConfigError -from konfai.utils.ome_zarr import _zarr_v3_available from konfai.utils.runtime import ( DistributedObject, State, @@ -845,7 +844,7 @@ def test_zarr_keeps_a_small_share_whole(monkeypatch, cores: int, expected: int) """A third of a 24-core share is the measured point; a third of four cores is one chunk in flight, which on a remote root is the whole of the read's parallelism.""" zarr = pytest.importorskip("zarr") - if not _zarr_v3_available(): # 2.x has no config object, and no async reader to share the cores with + if not hasattr(zarr, "config"): # 2.x has no config object, and no async reader to share the cores with pytest.skip("zarr 2.x has no async reader to size") previous = zarr.config.get("async.concurrency") try: diff --git a/tests/unit/test_sweep_tiling.py b/tests/unit/test_sweep_tiling.py index ebc2620c..9fdc771d 100644 --- a/tests/unit/test_sweep_tiling.py +++ b/tests/unit/test_sweep_tiling.py @@ -430,10 +430,10 @@ def test_a_region_read_is_charged_only_for_what_the_block_grid_adds( a region under one stored block decodes the block anyway and simply wastes more of it.""" source, _volume = _sheared_fixture(tmp_path) manager = _manager(source, [Save(f"{tmp_path / 'out'}:h5")]) - assert manager.region_reads(6)[0] == 0, "a store with no block grid adds nothing" + assert manager.region_reads(6).widest_excess == 0, "a store with no block grid adds nothing" _chunked(monkeypatch, (16, 128, 128)) manager._read_granularity = patching_module._UNRESOLVED - assert manager.region_reads(16)[0] < manager.region_reads(4)[0], ( + assert manager.region_reads(16).widest_excess < manager.region_reads(4).widest_excess, ( "a region under one stored block wastes more of the block it decodes, not less" ) diff --git a/tests/unit/test_transformer_workflow.py b/tests/unit/test_transformer_workflow.py index 8916460c..a7c65d41 100644 --- a/tests/unit/test_transformer_workflow.py +++ b/tests/unit/test_transformer_workflow.py @@ -1730,10 +1730,10 @@ def _write_snapshot_cohort(tmp_path: Path) -> Path: CT -> C (Clip -> Expand -> Brightness -> Write /out_c:h5): EXPAND 3 case(s) -> 9 cop(ies): 5 STREAM (shared read pass), 1 STREAM (own pass), 0 WHOLE-VOLUME, 3 SKIP (copy already written) (1 cop(ies)) own pass: the only copy of this case still to write; a shared pass with one member is its own sweep. CT -> D (Clip -> Reduce -> Write /out_d:h5): REDUCE 3 case(s) -> 1 output 'atlas': REDUCE - 4.5 resident region(s) of 4 row(s) = 0.00 GiB (incremental accumulator) - reads: 1 of 3 member(s) sit on nii.gz, which decodes the whole volume behind every region read: 2 decodes per member (one per region), 2 in all + 4.5 resident region(s) of 3 row(s) = 0.00 GiB (incremental accumulator) + reads: 1 of 3 member(s) sit on nii.gz, which decodes the whole volume behind every region read: 3 decodes per member (one per region), 3 in all put a Save ...:h5 before the Reduce so each member is materialized on a bounded store first - peak ~= 72.00 KiB vs the regions' share of the budget, 80.00 KiB of 160.00 KiB per rank + peak ~= 66.00 KiB vs the regions' share of the budget, 80.00 KiB of 160.00 KiB per rank cases: CASE_000, CASE_001, CASE_002 CT -> E (Clip -> Standardize -> Reduce -> Write /out_e:h5): REDUCE 3 case(s) -> 1 output 'atlas': REFUSED case 'CASE_000': stage 1 'Standardize' needs whole-volume statistics, but an earlier stage changes the values: the stored volume's statistic is not this stage's input. diff --git a/tests/unit/test_warp.py b/tests/unit/test_warp.py index 47c8be50..ffcce329 100644 --- a/tests/unit/test_warp.py +++ b/tests/unit/test_warp.py @@ -29,18 +29,10 @@ from konfai.data.transform import LocalityKind, Resample, Save from konfai.utils.dataset import Attribute, Dataset from konfai.utils.errors import TransformError -from konfai.utils.ome_zarr import _zarr_v3_available from oracle_support import geometry, manager pytest.importorskip("SimpleITK") -# A field records its bound in an RFC-5 store, which is zarr v3 (NGFF >= 0.5): and zarr 2.x, the -# newest release for Python 3.10, cannot write one. Everything else here reads an h5 field. -_needs_rfc5 = pytest.mark.skipif( - not _zarr_v3_available(), - reason="a displacement field's bound is recorded in a zarr v3 store (zarr>=3, Python>=3.11)", -) - SPACING = (2.0, 1.0, 1.0) # (x, y, z) SimpleITK order