From e071cc09d686a84d97cb19debcd383f8aba5ff7a Mon Sep 17 00:00:00 2001 From: uermel Date: Mon, 5 Oct 2026 18:43:09 -0700 Subject: [PATCH 1/4] chore: build against the copick filament stack (drop at release) copick-utils' filament, instance and pick-identity work uses APIs from the open copick v2.0 PRs #481, #482, #493 and #495 (filament objects, pick identity, the Filaments entity with curves, instance and panoptic segmentations). Until those are released, depend on their stack's tip and lock it. Replace this with a version floor once copick publishes them. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 +- uv.lock | 102 ++++++++++++++++++++++++------------------------- 2 files changed, 52 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0bef83e..b5e2199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,9 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "copick>=2.0.0a1", + # TODO:remove before release -- build against the unreleased copick filament stack (copick#481/#482/#493/#495); + # restore "copick>=" when it is published. + "copick @ git+https://github.com/copick/copick@uermel/panoptic-segmentations-v2", "click", "click-option-group", "numpy", diff --git a/uv.lock b/uv.lock index b954834..83be02d 100644 --- a/uv.lock +++ b/uv.lock @@ -701,7 +701,7 @@ wheels = [ [[package]] name = "copick" version = "2.0.0a1" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/copick/copick?rev=uermel%2Fpanoptic-segmentations-v2#a5bf1949970593f9b65a6bb28d1d4ef2ebc8e429" } dependencies = [ { name = "aiohttp" }, { name = "click" }, @@ -733,10 +733,6 @@ dependencies = [ { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/40/65bd5bb0c774b964e489683fa541d8fda09b3c2bbe51b0fa9ec73011ff97/copick-2.0.0a1.tar.gz", hash = "sha256:39256a1671d82088e8a3aebd237b322e498d5b38e2f5ff008ba9f586bf87c101", size = 47806055, upload-time = "2026-08-18T16:41:20.765Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/d7/a44a4a97bebd025b484e5c49f59627ba7ba67136d16d0f83f50a392864c1/copick-2.0.0a1-py3-none-any.whl", hash = "sha256:6c8002976c2e485d4e1d8814ee8aa096f76be8f82531ff3559e11e2469285ed9", size = 235457, upload-time = "2026-08-18T16:41:18.84Z" }, -] [[package]] name = "copick-utils" @@ -782,7 +778,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=25.1.0" }, { name = "click" }, { name = "click-option-group" }, - { name = "copick", specifier = ">=2.0.0a1" }, + { name = "copick", git = "https://github.com/copick/copick?rev=uermel%2Fpanoptic-segmentations-v2" }, { name = "hatch-vcs", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.25.0" }, { name = "manifold3d" }, @@ -1435,7 +1431,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2319,16 +2315,16 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "aiohttp" }, - { name = "dask" }, - { name = "deprecated" }, - { name = "fsspec", extra = ["s3"] }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "rangehttpserver" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "toolz" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" } }, + { name = "aiohttp", marker = "python_full_version <= '3.11'" }, + { name = "dask", marker = "python_full_version <= '3.11'" }, + { name = "deprecated", marker = "python_full_version <= '3.11'" }, + { name = "fsspec", extra = ["s3"], marker = "python_full_version <= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, + { name = "rangehttpserver", marker = "python_full_version <= '3.11'" }, + { name = "requests", marker = "python_full_version <= '3.11'" }, + { name = "scikit-image", marker = "python_full_version <= '3.11'" }, + { name = "toolz", marker = "python_full_version <= '3.11'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/45/b5b9933ec62028453dc841d25ea66d65405d77d2eb5bb34043621b0c294c/ome_zarr-0.16.0.tar.gz", hash = "sha256:0d31f3409c0f4ad67f04b9a4e9e8855e6e6b040e84d5c9be1095638f90d6eeb9", size = 86252, upload-time = "2026-04-14T09:43:32.354Z" } wheels = [ @@ -2351,18 +2347,18 @@ resolution-markers = [ "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "aiohttp" }, - { name = "dask" }, - { name = "deprecated" }, - { name = "fsspec", extra = ["s3"] }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "aiohttp", marker = "python_full_version > '3.11'" }, + { name = "dask", marker = "python_full_version > '3.11'" }, + { name = "deprecated", marker = "python_full_version > '3.11'" }, + { name = "fsspec", extra = ["s3"], marker = "python_full_version > '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "ome-zarr-models" }, - { name = "rangehttpserver" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "toolz" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "ome-zarr-models", marker = "python_full_version > '3.11'" }, + { name = "rangehttpserver", marker = "python_full_version > '3.11'" }, + { name = "requests", marker = "python_full_version > '3.11'" }, + { name = "scikit-image", marker = "python_full_version > '3.11'" }, + { name = "toolz", marker = "python_full_version > '3.11'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/98/f39cf0ec00ad64996ed2d2d6059d15a30c7b8f559cd2a216589885001a6a/ome_zarr-0.18.0.tar.gz", hash = "sha256:5cc0fc682b0c177fa32e54f4479b2436f79850a171e83ad4c6b08fec32e66cd0", size = 106039, upload-time = "2026-06-17T16:22:07.76Z" } @@ -2375,10 +2371,10 @@ name = "ome-zarr-models" version = "1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pydantic", marker = "python_full_version > '3.11'" }, + { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "pydantic-zarr", version = "0.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/e7/c37b8896a24eabc76d6a5e7855f722792de5bdf3f5ccc562001738be4f7e/ome_zarr_models-1.6.tar.gz", hash = "sha256:00735ad939d2b5f6a4f583cc19af1e8a5247fa1e93f1f08191143cd913b9633a", size = 106913, upload-time = "2026-03-10T10:14:28.369Z" } @@ -2931,9 +2927,9 @@ resolution-markers = [ "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pydantic" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "pydantic", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/19/ad4f22b7a395408c90cc0f213d70cf5b9ed5f3b0db2b319cacff93b66850/pydantic_zarr-0.9.2.tar.gz", hash = "sha256:59f536cbc456ff4dc64d887d651476e2a6a2ca9f3ea13228dce4e3aeb1cf00b9", size = 80379, upload-time = "2026-03-18T14:38:30.766Z" } wheels = [ @@ -2953,9 +2949,9 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pydantic" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/57/aac9e500abe15719b7406d55871fc8787e7d0ebf83de5f98d44471139f70/pydantic_zarr-0.10.0.tar.gz", hash = "sha256:996b5b4225fb05757e7fff831ee7a75c61f54514a4c2493c7b79635bade7b415", size = 80374, upload-time = "2026-04-23T14:23:12.575Z" } wheels = [ @@ -3380,7 +3376,7 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -3459,7 +3455,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -3674,7 +3670,7 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -3694,7 +3690,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/22/5de8a60ec7cd23436a9a5e2c5be8866556796307e0f54e0fbc2bf1981677/tifffile-2026.8.16.tar.gz", hash = "sha256:523a18b8183253e1e594162eed45f157c959d8cff6decde3f2d58f20a5a45389", size = 442370, upload-time = "2026-08-15T16:09:35.736Z" } wheels = [ @@ -4069,12 +4065,12 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version < '3.12'" }, + { name = "google-crc32c", marker = "python_full_version < '3.12'" }, + { name = "numcodecs", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } wheels = [ @@ -4094,12 +4090,12 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version >= '3.12'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, + { name = "numcodecs", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/15/436cb1d3bbe86173bd44ce7a34ecb210d0c0416946e337858149a905ef5a/zarr-3.3.0.tar.gz", hash = "sha256:cd0c8cf738b4bb4807815bc1255acad5bdf1a7b7264b606c5a1bc0d0392a306b", size = 943626, upload-time = "2026-07-30T16:35:10.491Z" } wheels = [ From 5da0f9965fa3be866e2bacfc5b44caea55946d59 Mon Sep 17 00:00:00 2001 From: uermel Date: Mon, 5 Oct 2026 18:09:02 -0700 Subject: [PATCH 2/4] fix: pick tools keep identity and place particles at their centre picksin, picksout and clippicks rebuilt their output from picks.numpy()/ from_numpy(), which dropped every pick's instance_id and score. For a filament object that loses which filament a pick belongs to. They also tested the pick's location, not its particle centre: copick defines the centre as the location plus the transform's translation (a shift, e.g. from a RELION import). Now they test the centre and copy the surviving points unchanged and in order, so orientation, shift, instance_id and score survive. Also: - An empty result writes an empty pick set. Before, the run was reported as an error and an earlier output in the same place was left on disk. - An instance segmentation works as a reference (any non-zero ID is inside), and a panoptic one is tested on its label channel. - clippicks --ref-seg built its distance field on the segmentation's (z, y, x) grid but indexed it with (x, y, z) indices, which is wrong for any non-cubic volume. It now indexes (z, y, x) and rounds to the nearest voxel, matching copick's voxel-to-Angstrom mapping (voxel i at i * spacing). - picks2seg, picks2mesh, picks2sphere, picks2ellipsoid, picks2plane, picks2surface and io.readers.coordinates use particle centres. The shared helpers are in copick_utils.util.picks. Covered by tests/test_pick_tools.py; every case fails on the previous code. Co-Authored-By: Claude Opus 5.5 --- src/copick_utils/cli/clippicks.py | 5 + src/copick_utils/cli/picksin.py | 5 + src/copick_utils/cli/picksout.py | 5 + .../converters/ellipsoid_from_picks.py | 8 +- .../converters/mesh_from_picks.py | 8 +- .../converters/plane_from_picks.py | 8 +- .../converters/segmentation_from_picks.py | 13 +- .../converters/sphere_from_picks.py | 8 +- .../converters/surface_from_picks.py | 8 +- src/copick_utils/io/readers.py | 24 +- .../logical/distance_operations.py | 55 ++-- src/copick_utils/logical/point_operations.py | 237 ++++++++---------- src/copick_utils/util/picks.py | 106 ++++++++ tests/conftest.py | 43 ++++ tests/test_pick_tools.py | 162 ++++++++++++ 15 files changed, 483 insertions(+), 212 deletions(-) create mode 100644 src/copick_utils/util/picks.py create mode 100644 tests/conftest.py create mode 100644 tests/test_pick_tools.py diff --git a/src/copick_utils/cli/clippicks.py b/src/copick_utils/cli/clippicks.py index f05defd..29df8ae 100644 --- a/src/copick_utils/cli/clippicks.py +++ b/src/copick_utils/cli/clippicks.py @@ -65,6 +65,11 @@ def clippicks( Exactly one reference must be supplied via `--ref-mesh`, `--ref-seg`, or `--ref-tomogram`. Distances are measured in angstroms. + Each pick is measured at its particle centre (its location plus the shift stored in its + transform). Kept picks are written unchanged and in their original order, so their + orientations, instance IDs (e.g. filament IDs) and scores survive. If no pick is kept, an + empty pick set is written. + URI Format: \b diff --git a/src/copick_utils/cli/picksin.py b/src/copick_utils/cli/picksin.py index 4287a41..0239d18 100644 --- a/src/copick_utils/cli/picksin.py +++ b/src/copick_utils/cli/picksin.py @@ -50,6 +50,11 @@ def picksin( whose coordinates fall inside it are written to the output. This is the inverse of `copick logical picksout`, which keeps the picks that fall outside the volume instead. + Each pick is tested at its particle centre (its location plus the shift stored in its + transform). Kept picks are written unchanged and in their original order, so their + orientations, instance IDs (e.g. filament IDs) and scores survive. If no pick is kept, an + empty pick set is written. + URI Format: \b diff --git a/src/copick_utils/cli/picksout.py b/src/copick_utils/cli/picksout.py index ae720d5..ed42bcf 100644 --- a/src/copick_utils/cli/picksout.py +++ b/src/copick_utils/cli/picksout.py @@ -52,6 +52,11 @@ def picksout( whose coordinates fall outside it are written to the output. This is the inverse of `copick logical picksin`, which keeps the picks that fall inside the volume instead. + Each pick is tested at its particle centre (its location plus the shift stored in its + transform). Kept picks are written unchanged and in their original order, so their + orientations, instance IDs (e.g. filament IDs) and scores survive. If no pick is kept, an + empty pick set is written. + URI Format: \b diff --git a/src/copick_utils/converters/ellipsoid_from_picks.py b/src/copick_utils/converters/ellipsoid_from_picks.py index e073537..661d0d2 100644 --- a/src/copick_utils/converters/ellipsoid_from_picks.py +++ b/src/copick_utils/converters/ellipsoid_from_picks.py @@ -11,6 +11,7 @@ validate_points, ) from copick_utils.converters.lazy_converter import create_lazy_batch_converter +from copick_utils.util.picks import pick_centres if TYPE_CHECKING: from copick.models import CopickMesh, CopickRun @@ -317,9 +318,10 @@ def create_ellipsoid_from_points(cluster_points): def ellipsoid_from_picks_standard(picks, run, object_name, session_id, user_id, voxel_spacing=None, **kwargs): - """Adapt ellipsoid_from_picks to the lazy-batch contract: take a CopickPicks and extract its points.""" - pos, _ = picks.numpy() - if pos is None or len(pos) == 0: + """Adapt ellipsoid_from_picks to the lazy-batch contract: take a CopickPicks and extract its particle centres + (``location`` plus the transform's shift).""" + pos = pick_centres(picks) + if len(pos) == 0: logger.warning("Could not load pick data") return None return ellipsoid_from_picks(pos, run, object_name, session_id, user_id, **kwargs) diff --git a/src/copick_utils/converters/mesh_from_picks.py b/src/copick_utils/converters/mesh_from_picks.py index 8677c1b..8f8a5c0 100644 --- a/src/copick_utils/converters/mesh_from_picks.py +++ b/src/copick_utils/converters/mesh_from_picks.py @@ -10,6 +10,7 @@ validate_points, ) from copick_utils.converters.lazy_converter import create_lazy_batch_converter +from copick_utils.util.picks import pick_centres if TYPE_CHECKING: from copick.models import CopickMesh, CopickRun @@ -189,9 +190,10 @@ def create_mesh_from_points(cluster_points): # Lazy batch converter for new architecture def mesh_from_picks_standard(picks, run, object_name, session_id, user_id, voxel_spacing=None, **kwargs): - """Adapt mesh_from_picks to the lazy-batch contract: take a CopickPicks and extract its points.""" - pos, _ = picks.numpy() - if pos is None or len(pos) == 0: + """Adapt mesh_from_picks to the lazy-batch contract: take a CopickPicks and extract its particle centres + (``location`` plus the transform's shift).""" + pos = pick_centres(picks) + if len(pos) == 0: logger.warning("Could not load pick data") return None return mesh_from_picks(pos, run, object_name, session_id, user_id, **kwargs) diff --git a/src/copick_utils/converters/plane_from_picks.py b/src/copick_utils/converters/plane_from_picks.py index d8f6271..cc363d9 100644 --- a/src/copick_utils/converters/plane_from_picks.py +++ b/src/copick_utils/converters/plane_from_picks.py @@ -11,6 +11,7 @@ validate_points, ) from copick_utils.converters.lazy_converter import create_lazy_batch_converter +from copick_utils.util.picks import pick_centres if TYPE_CHECKING: from copick.models import CopickMesh, CopickRun @@ -232,9 +233,10 @@ def create_plane_from_points(cluster_points): # Lazy batch converter for new architecture def plane_from_picks_standard(picks, run, object_name, session_id, user_id, voxel_spacing=None, **kwargs): - """Adapt plane_from_picks to the lazy-batch contract: take a CopickPicks and extract its points.""" - pos, _ = picks.numpy() - if pos is None or len(pos) == 0: + """Adapt plane_from_picks to the lazy-batch contract: take a CopickPicks and extract its particle centres + (``location`` plus the transform's shift).""" + pos = pick_centres(picks) + if len(pos) == 0: logger.warning("Could not load pick data") return None return plane_from_picks(pos, run, object_name, session_id, user_id, **kwargs) diff --git a/src/copick_utils/converters/segmentation_from_picks.py b/src/copick_utils/converters/segmentation_from_picks.py index cf11935..509e900 100644 --- a/src/copick_utils/converters/segmentation_from_picks.py +++ b/src/copick_utils/converters/segmentation_from_picks.py @@ -5,6 +5,7 @@ from copick_utils.converters.lazy_converter import create_lazy_batch_converter from copick_utils.io.zarr import get_level_array +from copick_utils.util.picks import pick_centres if TYPE_CHECKING: from copick.models import CopickObject, CopickPicks, CopickRun, CopickSegmentation @@ -54,14 +55,10 @@ def get_relative_target_coordinates(center, delta, shape): radius_voxel = max(radius / voxel_spacing, 1) delta = int(np.ceil(radius_voxel)) - # Paint each pick as a sphere - for point in pick.points: - # Convert the pick's location from angstroms to voxel units - cx, cy, cz = ( - point.location.x / voxel_spacing, - point.location.y / voxel_spacing, - point.location.z / voxel_spacing, - ) + # Paint each pick as a sphere at its particle centre (location plus the transform's shift) + for centre in pick_centres(pick): + # Convert the particle centre from angstroms to voxel units + cx, cy, cz = centre / voxel_spacing # Calculate subarray bounds xLow, xHigh = get_relative_target_coordinates(cx, delta, seg_volume.shape[2]) diff --git a/src/copick_utils/converters/sphere_from_picks.py b/src/copick_utils/converters/sphere_from_picks.py index ca81158..b4bc879 100644 --- a/src/copick_utils/converters/sphere_from_picks.py +++ b/src/copick_utils/converters/sphere_from_picks.py @@ -11,6 +11,7 @@ validate_points, ) from copick_utils.converters.lazy_converter import create_lazy_batch_converter +from copick_utils.util.picks import pick_centres if TYPE_CHECKING: from copick.models import CopickMesh, CopickRun @@ -287,9 +288,10 @@ def sphere_from_picks( # Lazy batch converter for new architecture def sphere_from_picks_standard(picks, run, object_name, session_id, user_id, voxel_spacing=None, **kwargs): - """Adapt sphere_from_picks to the lazy-batch contract: take a CopickPicks and extract its points.""" - pos, _ = picks.numpy() - if pos is None or len(pos) == 0: + """Adapt sphere_from_picks to the lazy-batch contract: take a CopickPicks and extract its particle centres + (``location`` plus the transform's shift).""" + pos = pick_centres(picks) + if len(pos) == 0: logger.warning("Could not load pick data") return None return sphere_from_picks(pos, run, object_name, session_id, user_id, **kwargs) diff --git a/src/copick_utils/converters/surface_from_picks.py b/src/copick_utils/converters/surface_from_picks.py index 2c33399..3f17026 100644 --- a/src/copick_utils/converters/surface_from_picks.py +++ b/src/copick_utils/converters/surface_from_picks.py @@ -13,6 +13,7 @@ validate_points, ) from copick_utils.converters.lazy_converter import create_lazy_batch_converter +from copick_utils.util.picks import pick_centres if TYPE_CHECKING: from copick.models import CopickMesh, CopickRun @@ -318,9 +319,10 @@ def create_surface_from_points(cluster_points): # Lazy batch converter for new architecture def surface_from_picks_standard(picks, run, object_name, session_id, user_id, voxel_spacing=None, **kwargs): - """Adapt surface_from_picks to the lazy-batch contract: take a CopickPicks and extract its points.""" - pos, _ = picks.numpy() - if pos is None or len(pos) == 0: + """Adapt surface_from_picks to the lazy-batch contract: take a CopickPicks and extract its particle centres + (``location`` plus the transform's shift).""" + pos = pick_centres(picks) + if len(pos) == 0: logger.warning("Could not load pick data") return None return surface_from_picks(pos, run, object_name, session_id, user_id, **kwargs) diff --git a/src/copick_utils/io/readers.py b/src/copick_utils/io/readers.py index 118f299..27df162 100644 --- a/src/copick_utils/io/readers.py +++ b/src/copick_utils/io/readers.py @@ -1,6 +1,7 @@ -import numpy as np from copick.util.uri import resolve_copick_objects +from copick_utils.util.picks import pick_centres + def tomogram(run, voxel_size: float = 10, algorithm: str = "wbp", raise_error: bool = False, verbose=True): """Read a tomogram from a copick run as a NumPy array. @@ -143,7 +144,8 @@ def coordinates( """Read pick coordinates from a copick run as an array of voxel indices. Looks up the picks for `name` (optionally filtered by `user_id` and - `session_id`) and returns their locations divided by `voxel_size`. If several + `session_id`) and returns their particle centres (location plus the transform's + shift) divided by `voxel_size`. If several pick sets match, the first is used; if none match, reports the available picks. Args: @@ -194,19 +196,5 @@ def coordinates( f"Defaulting to loading:\n {picks[0]}\n", ) - points = picks[0].points - - # Initialize an array to store the coordinates - nPoints = len(picks[0].points) # Number of points retrieved - coordinates = np.zeros([len(picks[0].points), 3]) # Create an empty array to hold the (z, y, x) coordinates - - # Iterate over all points and convert their locations to coordinates in voxel space - for ii in range(nPoints): - coordinates[ii,] = [ - points[ii].location.z / voxel_size, # Scale z-coordinate by voxel size - points[ii].location.y / voxel_size, # Scale y-coordinate by voxel size - points[ii].location.x / voxel_size, - ] # Scale x-coordinate by voxel size - - # Return the array of coordinates - return coordinates + # Particle centres (location plus the transform's shift), converted to (z, y, x) voxel coordinates + return pick_centres(picks[0])[:, ::-1] / voxel_size diff --git a/src/copick_utils/logical/distance_operations.py b/src/copick_utils/logical/distance_operations.py index 4b86d12..a5a3946 100644 --- a/src/copick_utils/logical/distance_operations.py +++ b/src/copick_utils/logical/distance_operations.py @@ -11,6 +11,7 @@ ) from copick_utils.converters.lazy_converter import create_lazy_batch_converter from copick_utils.io.zarr import get_level_array +from copick_utils.util.picks import point_centres, segmentation_label_volume, store_pick_subset, voxel_indices if TYPE_CHECKING: from copick.models import CopickMesh, CopickPicks, CopickRun, CopickSegmentation @@ -585,6 +586,10 @@ def limit_picks_by_distance( """ Limit picks to those within a certain distance of a reference surface. + A pick is measured at its particle centre (``location`` plus the transform's shift). Kept picks are written + unchanged and in their original order: transform, ``instance_id`` and ``score`` survive. If no pick passes, an + empty pick set is written. + Args: picks: CopickPicks to limit run: CopickRun object @@ -609,13 +614,14 @@ def limit_picks_by_distance( "One of reference_mesh, reference_segmentation, or reference_tomogram_info must be provided", ) - # Load pick data - points, transforms = picks.numpy() - if points is None or len(points) == 0: + # Load pick data; picks are tested at their particle centres (location plus the transform's shift) + points = picks.points or [] + if len(points) == 0: logger.error("Could not load pick data") return None - pick_positions = points[:, :3] # Use only x, y, z coordinates + pick_positions = point_centres(points) + within_or_beyond = "beyond" if invert else "within" # Handle tomogram boundary reference (direct distance calculation, no distance field) if reference_tomogram_info is not None: @@ -626,27 +632,19 @@ def limit_picks_by_distance( # Apply invert logic: default keeps within distance, invert keeps beyond final_valid = pick_distances > max_distance if invert else pick_distances <= max_distance - if not np.any(final_valid): - within_or_beyond = "beyond" if invert else "within" - logger.warning(f"No picks {within_or_beyond} {max_distance} units of tomogram boundary") - return None - # Handle segmentation or mesh reference (uses distance field) else: # Create distance field from reference if reference_segmentation is not None: - ref_seg_array = reference_segmentation.numpy() + ref_seg_array = segmentation_label_volume(reference_segmentation) if ref_seg_array is None or ref_seg_array.size == 0: logger.error("Could not load reference segmentation data") return None - # Use reference segmentation's coordinate space + # The field is on the reference segmentation's own (z, y, x) grid; voxel i sits at i * spacing. field_voxel_spacing = reference_segmentation.voxel_size distance_field = _create_distance_field_from_segmentation(ref_seg_array, field_voxel_spacing) - - # Convert pick coordinates to voxel indices in reference segmentation space - pick_voxel_coords = pick_positions / field_voxel_spacing - pick_voxel_indices = np.floor(pick_voxel_coords).astype(int) + pick_voxel_indices = voxel_indices(pick_positions, field_voxel_spacing) else: # reference_mesh is not None ref_mesh = reference_mesh.mesh @@ -673,7 +671,7 @@ def limit_picks_by_distance( field_size = coord_bounds[1] - coord_bounds[0] field_shape = np.ceil(field_size / field_voxel_spacing).astype(int) - # Create distance field from mesh in this coordinate space. + # Create distance field from mesh in this coordinate space (axis order x, y, z). # Pass origin=coord_bounds[0] so the field aligns with the pick-index origin below. distance_field = _create_distance_field_from_mesh( ref_mesh, @@ -692,10 +690,6 @@ def limit_picks_by_distance( axis=1, ) - if not np.any(valid_picks): - logger.warning("No picks within distance field bounds") - return None - # Get distances for valid picks valid_indices = pick_voxel_indices[valid_picks] pick_distances = distance_field[valid_indices[:, 0], valid_indices[:, 1], valid_indices[:, 2]] @@ -707,22 +701,15 @@ def limit_picks_by_distance( final_valid = np.zeros(len(points), dtype=bool) final_valid[valid_picks] = distance_valid - if not np.any(final_valid): - within_or_beyond = "beyond" if invert else "within" - logger.warning(f"No picks {within_or_beyond} {max_distance} units of reference surface") - return None - - # Filter picks - valid_points = points[final_valid] - valid_transforms = transforms[final_valid] if transforms is not None else None + if not np.any(final_valid): + logger.warning( + f"No picks {within_or_beyond} {max_distance} units of the reference; writing an empty pick set", + ) - # Create output picks - output_picks = run.new_picks(object_name, session_id, user_id, exist_ok=True) - output_picks.from_numpy(positions=valid_points, transforms=valid_transforms) - output_picks.store() + # Kept picks are written unchanged and in order (transform, instance_id and score survive) + output_picks = store_pick_subset(points, final_valid, run, object_name, session_id, user_id) - stats = {"points_created": len(valid_points)} - within_or_beyond = "beyond" if invert else "within" + stats = {"points_created": int(np.count_nonzero(final_valid))} logger.info(f"Limited picks to {stats['points_created']} points {within_or_beyond} {max_distance} units") return output_picks, stats diff --git a/src/copick_utils/logical/point_operations.py b/src/copick_utils/logical/point_operations.py index 7d5a2bb..1c65831 100644 --- a/src/copick_utils/logical/point_operations.py +++ b/src/copick_utils/logical/point_operations.py @@ -7,6 +7,7 @@ from copick.util.log import get_logger from copick_utils.converters.lazy_converter import create_lazy_batch_converter +from copick_utils.util.picks import point_centres, segmentation_label_volume, store_pick_subset, voxel_indices if TYPE_CHECKING: from copick.models import CopickMesh, CopickPicks, CopickRun, CopickSegmentation @@ -55,36 +56,83 @@ def _check_points_in_segmentation( Check which points are inside a segmentation volume. Args: - points: Array of points to check (N, 3) in physical coordinates - segmentation_array: Binary segmentation array + points: Array of points to check (N, 3) in physical (x, y, z) coordinates + segmentation_array: Segmentation array (z, y, x); any non-zero voxel is inside (binary, multilabel, or an + instance segmentation's IDs) voxel_spacing: Spacing between voxels Returns: Boolean array indicating which points are inside the segmentation """ - # Convert points to voxel coordinates - voxel_coords = np.round(points / voxel_spacing).astype(int) - - # Check bounds - valid_bounds = ( - (voxel_coords[:, 0] >= 0) - & (voxel_coords[:, 0] < segmentation_array.shape[2]) - & (voxel_coords[:, 1] >= 0) - & (voxel_coords[:, 1] < segmentation_array.shape[1]) - & (voxel_coords[:, 2] >= 0) - & (voxel_coords[:, 2] < segmentation_array.shape[0]) - ) + zyx = voxel_indices(points, voxel_spacing) + valid_bounds = np.all((zyx >= 0) & (zyx < np.array(segmentation_array.shape)), axis=1) inside = np.zeros(len(points), dtype=bool) + valid = zyx[valid_bounds] + if len(valid) > 0: + inside[valid_bounds] = segmentation_array[valid[:, 0], valid[:, 1], valid[:, 2]] > 0 + return inside - # Check only points within bounds - valid_coords = voxel_coords[valid_bounds] - if len(valid_coords) > 0: - # Check if voxels are non-zero (inside segmentation) - voxel_values = segmentation_array[valid_coords[:, 2], valid_coords[:, 1], valid_coords[:, 0]] - inside[valid_bounds] = voxel_values > 0 - return inside +def _inside_reference( + centres: np.ndarray, + reference_mesh: Optional["CopickMesh"], + reference_segmentation: Optional["CopickSegmentation"], +) -> Optional[np.ndarray]: + """Which particle centres lie inside the reference mesh or segmentation (None if the reference cannot be read).""" + if reference_mesh is not None: + ref_mesh = reference_mesh.mesh + if ref_mesh is None: + logger.error("Could not load reference mesh data") + return None + + if isinstance(ref_mesh, tm.Scene): + if len(ref_mesh.geometry) == 0: + logger.error("Reference mesh is empty") + return None + ref_mesh = tm.util.concatenate(list(ref_mesh.geometry.values())) + + return _check_points_in_mesh(centres, ref_mesh) + + ref_seg_array = segmentation_label_volume(reference_segmentation) + if ref_seg_array is None or ref_seg_array.size == 0: + logger.error("Could not load reference segmentation data") + return None + return _check_points_in_segmentation(centres, ref_seg_array, reference_segmentation.voxel_size) + + +def _filter_picks_by_reference( + picks: "CopickPicks", + run: "CopickRun", + object_name: str, + session_id: str, + user_id: str, + reference_mesh: Optional["CopickMesh"], + reference_segmentation: Optional["CopickSegmentation"], + keep_inside: bool, +) -> Optional[Tuple["CopickPicks", Dict[str, int]]]: + """Keep the picks whose particle centres are inside (or outside) a reference, unchanged and in order.""" + if reference_mesh is None and reference_segmentation is None: + raise ValueError("Either reference_mesh or reference_segmentation must be provided") + + points = picks.points or [] + if len(points) == 0: + logger.error("Could not load pick data") + return None + + inside_mask = _inside_reference(point_centres(points), reference_mesh, reference_segmentation) + if inside_mask is None: + return None + + keep = inside_mask if keep_inside else ~inside_mask + where = "inside" if keep_inside else "outside" + if not np.any(keep): + logger.warning(f"No picks found {where} reference volume; writing an empty pick set") + + output_picks = store_pick_subset(points, keep, run, object_name, session_id, user_id) + stats = {"points_created": int(np.count_nonzero(keep))} + logger.info(f"Kept {stats['points_created']} of {len(points)} picks {where} reference volume") + return output_picks, stats def picks_inclusion_by_mesh( @@ -100,10 +148,15 @@ def picks_inclusion_by_mesh( """ Filter picks to include only those inside a reference mesh or segmentation. + A pick is tested at its particle centre (``location`` plus the transform's shift). Kept picks are written + unchanged and in their original order: transform, ``instance_id`` and ``score`` survive. If no pick is inside, + an empty pick set is written. + Args: picks: CopickPicks to filter reference_mesh: Reference CopickMesh (either this or reference_segmentation must be provided) - reference_segmentation: Reference CopickSegmentation + reference_segmentation: Reference CopickSegmentation (binary, multilabel, instance, or the label channel + of a panoptic segmentation; any non-zero voxel is inside) run: CopickRun object object_name: Name for the output picks session_id: Session ID for the output picks @@ -115,61 +168,16 @@ def picks_inclusion_by_mesh( Stats dict contains 'points_created'. """ try: - if reference_mesh is None and reference_segmentation is None: - raise ValueError("Either reference_mesh or reference_segmentation must be provided") - - # Load pick data - points, transforms = picks.numpy() - if points is None or len(points) == 0: - logger.error("Could not load pick data") - return None - - pick_positions = points[:, :3] # Use only x, y, z coordinates - - # Check which points are inside the reference - if reference_mesh is not None: - ref_mesh = reference_mesh.mesh - if ref_mesh is None: - logger.error("Could not load reference mesh data") - return None - - if isinstance(ref_mesh, tm.Scene): - if len(ref_mesh.geometry) == 0: - logger.error("Reference mesh is empty") - return None - ref_mesh = tm.util.concatenate(list(ref_mesh.geometry.values())) - - inside_mask = _check_points_in_mesh(pick_positions, ref_mesh) - - else: # reference_segmentation is not None - ref_seg_array = reference_segmentation.numpy() - if ref_seg_array is None or ref_seg_array.size == 0: - logger.error("Could not load reference segmentation data") - return None - - inside_mask = _check_points_in_segmentation( - pick_positions, - ref_seg_array, - reference_segmentation.voxel_size, - ) - - if not np.any(inside_mask): - logger.warning("No picks found inside reference volume") - return None - - # Filter picks to include only those inside - included_points = points[inside_mask] - included_transforms = transforms[inside_mask] if transforms is not None else None - - # Create output picks - output_picks = run.new_picks(object_name, session_id, user_id, exist_ok=True) - output_picks.from_numpy(positions=included_points, transforms=included_transforms) - output_picks.store() - - stats = {"points_created": len(included_points)} - logger.info(f"Included {stats['points_created']} picks inside reference volume") - return output_picks, stats - + return _filter_picks_by_reference( + picks, + run, + object_name, + session_id, + user_id, + reference_mesh, + reference_segmentation, + keep_inside=True, + ) except Exception as e: logger.error(f"Error filtering picks by inclusion: {e}") return None @@ -188,10 +196,15 @@ def picks_exclusion_by_mesh( """ Filter picks to exclude those inside a reference mesh or segmentation. + A pick is tested at its particle centre (``location`` plus the transform's shift). Kept picks are written + unchanged and in their original order: transform, ``instance_id`` and ``score`` survive. If every pick is + inside, an empty pick set is written. + Args: picks: CopickPicks to filter reference_mesh: Reference CopickMesh (either this or reference_segmentation must be provided) - reference_segmentation: Reference CopickSegmentation + reference_segmentation: Reference CopickSegmentation (binary, multilabel, instance, or the label channel + of a panoptic segmentation; any non-zero voxel is inside) run: CopickRun object object_name: Name for the output picks session_id: Session ID for the output picks @@ -203,66 +216,16 @@ def picks_exclusion_by_mesh( Stats dict contains 'points_created'. """ try: - if reference_mesh is None and reference_segmentation is None: - raise ValueError("Either reference_mesh or reference_segmentation must be provided") - - # Load pick data - points, transforms = picks.numpy() - if points is None or len(points) == 0: - logger.error("Could not load pick data") - return None - - pick_positions = points[:, :3] # Use only x, y, z coordinates - - # Check which points are inside the reference - if reference_mesh is not None: - ref_mesh = reference_mesh.mesh - if ref_mesh is None: - logger.error("Could not load reference mesh data") - return None - - if isinstance(ref_mesh, tm.Scene): - if len(ref_mesh.geometry) == 0: - logger.error("Reference mesh is empty") - return None - ref_mesh = tm.util.concatenate(list(ref_mesh.geometry.values())) - - inside_mask = _check_points_in_mesh(pick_positions, ref_mesh) - - else: # reference_segmentation is not None - ref_seg_array = reference_segmentation.numpy() - if ref_seg_array is None or ref_seg_array.size == 0: - logger.error("Could not load reference segmentation data") - return None - - inside_mask = _check_points_in_segmentation( - pick_positions, - ref_seg_array, - reference_segmentation.voxel_size, - ) - - # Invert mask to exclude points inside - outside_mask = ~inside_mask - - if not np.any(outside_mask): - logger.warning("No picks found outside reference volume") - return None - - # Filter picks to exclude those inside - excluded_points = points[outside_mask] - excluded_transforms = transforms[outside_mask] if transforms is not None else None - - # Create output picks - output_picks = run.new_picks(object_name, session_id, user_id, exist_ok=True) - output_picks.from_numpy(positions=excluded_points, transforms=excluded_transforms) - output_picks.store() - - stats = {"points_created": len(excluded_points)} - logger.info( - f"Excluded {len(points) - stats['points_created']} picks inside reference volume, kept {stats['points_created']} picks", + return _filter_picks_by_reference( + picks, + run, + object_name, + session_id, + user_id, + reference_mesh, + reference_segmentation, + keep_inside=False, ) - return output_picks, stats - except Exception as e: logger.error(f"Error filtering picks by exclusion: {e}") return None diff --git a/src/copick_utils/util/picks.py b/src/copick_utils/util/picks.py new file mode 100644 index 0000000..825022f --- /dev/null +++ b/src/copick_utils/util/picks.py @@ -0,0 +1,106 @@ +"""Helpers that keep a pick's identity intact while tools filter, paint or measure picks. + +A pick's particle centre is its ``location`` plus the translation of its ``transformation`` (copick's geometry +documentation, "Shifts"). Tools that place or test particles use that centre, not ``location`` alone. + +Tools that keep a subset of picks copy the surviving points themselves instead of rebuilding them from arrays, so each +point keeps its transform (rotation and shift), ``instance_id`` and ``score``, and the points keep their order. For +filament objects the order is the order along the filament, and ``instance_id`` is the filament. +""" + +from typing import TYPE_CHECKING, Sequence + +import numpy as np + +if TYPE_CHECKING: + from copick.models import CopickPicks, CopickPoint, CopickRun, CopickSegmentation + + +def point_centres(points: Sequence["CopickPoint"]) -> np.ndarray: + """The particle centres of picks, in Angstrom. + + Args: + points: Copick points. + + Returns: + (N, 3) array of ``location + transformation[:3, 3]`` in (x, y, z) order. + """ + if len(points) == 0: + return np.zeros((0, 3), dtype=float) + locations = np.array([[p.location.x, p.location.y, p.location.z] for p in points], dtype=float) + shifts = np.array([np.asarray(p.transformation, dtype=float)[:3, 3] for p in points], dtype=float) + return locations + shifts + + +def pick_centres(picks: "CopickPicks") -> np.ndarray: + """The particle centres of a pick set, in Angstrom. + + Args: + picks: Copick pick set. + + Returns: + (N, 3) array of ``location + transformation[:3, 3]`` in (x, y, z) order. + """ + return point_centres(picks.points or []) + + +def store_pick_subset( + points: Sequence["CopickPoint"], + keep: np.ndarray, + run: "CopickRun", + object_name: str, + session_id: str, + user_id: str, +) -> "CopickPicks": + """Store the kept points, unchanged and in their original order, as a pick set. + + An empty selection stores an empty pick set, so an earlier result in the same place does not survive a run that + kept nothing. + + Args: + points: The input points. + keep: Boolean mask over ``points``. + run: Run to write into. + object_name: Object name of the output pick set. + session_id: Session ID of the output pick set. + user_id: User ID of the output pick set. + + Returns: + The stored pick set. + """ + keep = np.asarray(keep, dtype=bool) + if keep.shape != (len(points),): + raise ValueError(f"The selection has shape {keep.shape}, but there are {len(points)} points.") + kept = [p.model_copy(deep=True) for p, k in zip(points, keep) if k] + output = run.new_picks(object_name, session_id, user_id, exist_ok=True) + output.points = kept + output.store() + return output + + +def segmentation_label_volume(segmentation: "CopickSegmentation") -> np.ndarray: + """The volume a tool tests positions against: the label channel of a panoptic segmentation, otherwise the + segmentation itself (binary, multilabel or instance; non-zero means inside). + + Args: + segmentation: Copick segmentation. + + Returns: + (Z, Y, X) array. + """ + if getattr(segmentation, "is_panoptic", False): + return segmentation.numpy(channel="label") + return segmentation.numpy() + + +def voxel_indices(positions: np.ndarray, voxel_spacing: float) -> np.ndarray: + """The (z, y, x) indices of the voxels holding positions, under copick's mapping of voxel ``i`` to ``i * s``. + + Args: + positions: (N, 3) positions in Angstrom, (x, y, z) order. + voxel_spacing: Voxel spacing in Angstrom. + + Returns: + (N, 3) integer array in (z, y, x) order. + """ + return np.round(np.asarray(positions, dtype=float) / float(voxel_spacing)).astype(int)[:, ::-1] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8faadb1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""Shared fixtures: a small filesystem copick project in a temporary directory.""" + +import json + +import copick +import pytest + +OBJECTS = [ + { + "name": "microtubule", + "is_particle": True, + "label": 1, + "radius": 120.0, + "metadata": {"copick": {"filament": {"polar": True}}}, + }, + {"name": "ribosome", "is_particle": True, "label": 2, "radius": 150.0}, + {"name": "membrane", "is_particle": False, "label": 3}, +] + + +@pytest.fixture +def copick_root(tmp_path): + """A copick root with one run (``run1``), a filament object, a particle object and a segmentation object.""" + config = { + "config_type": "filesystem", + "name": "test", + "description": "copick-utils test project", + "version": "1.0.0", + "pickable_objects": OBJECTS, + "overlay_root": f"local://{tmp_path / 'overlay'}", + "overlay_fs_args": {"auto_mkdir": True}, + } + path = tmp_path / "copick_config.json" + path.write_text(json.dumps(config)) + root = copick.from_file(str(path)) + root.new_run("run1") + return root + + +@pytest.fixture +def run(copick_root): + """The fixture project's single run.""" + return copick_root.get_run("run1") diff --git a/tests/test_pick_tools.py b/tests/test_pick_tools.py new file mode 100644 index 0000000..7a545db --- /dev/null +++ b/tests/test_pick_tools.py @@ -0,0 +1,162 @@ +"""Pick tools keep each pick's identity and place or test it at its particle centre (location plus shift).""" + +import numpy as np +import pytest +from copick_utils.converters.segmentation_from_picks import from_picks +from copick_utils.io.readers import coordinates +from copick_utils.logical.distance_operations import limit_picks_by_distance +from copick_utils.logical.point_operations import picks_exclusion_by_mesh, picks_inclusion_by_mesh +from scipy.spatial.transform import Rotation + +VS = 10.0 + + +def _transforms(n, shifts=None, seed=0): + rng = np.random.default_rng(seed) + t = np.tile(np.eye(4), (n, 1, 1)) + t[:, :3, :3] = Rotation.random(n, random_state=rng).as_matrix() + if shifts is not None: + t[:, :3, 3] = shifts + return t + + +def _picks(run, positions, transforms, ids, scores, session="in"): + picks = run.new_picks("microtubule", session, "test", exist_ok=True) + picks.from_numpy(np.asarray(positions, float), transforms, instance_ids=np.asarray(ids), scores=np.asarray(scores)) + return picks + + +def _segmentation(run, array, name="membrane", session="ref", **kwargs): + run.new_voxel_spacing(VS, exist_ok=True) + seg = run.new_segmentation(VS, name, session, user_id="test", exist_ok=True, **kwargs) + seg.from_numpy(array) + return seg + + +def _state(points): + return [ + ( + (p.location.x, p.location.y, p.location.z), + np.round(np.asarray(p.transformation), 9).tolist(), + p.instance_id, + p.score, + ) + for p in points + ] + + +@pytest.fixture +def box_reference(run): + """A segmentation whose voxels x, y, z in [0, 10) are inside (shape z=12, y=14, x=16, not cubic).""" + array = np.zeros((12, 14, 16), dtype=np.uint8) + array[:10, :10, :10] = 1 + return _segmentation(run, array) + + +def test_picksin_keeps_identity_order_and_tests_centres(run, box_reference): + # Five picks along a filament; the third has its location outside the box but its centre inside, the fourth + # the opposite. Positions are in Angstrom. + positions = np.array([[10, 10, 10], [30, 30, 30], [120, 50, 50], [50, 50, 50], [80, 80, 80]], float) + shifts = np.zeros((5, 3)) + shifts[2] = [-80, 0, 0] # centre (40, 50, 50): inside + shifts[3] = [100, 0, 0] # centre (150, 50, 50): outside + transforms = _transforms(5, shifts) + picks = _picks(run, positions, transforms, ids=[1, 1, 1, 1, 2], scores=[0.9, 0.8, 0.7, 0.6, 0.5]) + + out, stats = picks_inclusion_by_mesh( + picks, + run, + "microtubule", + "kept", + "test", + reference_segmentation=box_reference, + ) + + expected = [p for i, p in enumerate(picks.points) if i in (0, 1, 2, 4)] + assert stats["points_created"] == 4 + assert _state(out.points) == _state(expected) + + out, stats = picks_exclusion_by_mesh( + picks, + run, + "microtubule", + "dropped", + "test", + reference_segmentation=box_reference, + ) + assert _state(out.points) == _state([picks.points[3]]) + + +def test_empty_result_replaces_an_earlier_output(run, box_reference): + picks = _picks(run, [[10, 10, 10]], _transforms(1), ids=[3], scores=[1.0]) + picks_inclusion_by_mesh(picks, run, "microtubule", "result", "test", reference_segmentation=box_reference) + assert len(run.get_picks("microtubule", "test", "result")[0].points) == 1 + + # Every pick is now inside, so nothing survives exclusion: the earlier result must not be left behind. + out, stats = picks_exclusion_by_mesh( + picks, + run, + "microtubule", + "result", + "test", + reference_segmentation=box_reference, + ) + assert stats["points_created"] == 0 + run.refresh_picks() + stored = run.get_picks("microtubule", "test", "result") + assert len(stored) == 1 and len(stored[0].points) == 0 + + +def test_instance_reference_counts_every_instance_as_inside(run): + array = np.zeros((12, 14, 16), dtype=np.uint16) + array[2:4, 2:4, 2:4] = 1 + array[6:8, 6:8, 6:8] = 7 + instances = _segmentation(run, array, name="microtubule", session="inst", is_instance=True) + picks = _picks(run, [[30, 30, 30], [70, 70, 70], [100, 100, 100]], _transforms(3), ids=[1, 2, 3], scores=[1, 1, 1]) + out, stats = picks_inclusion_by_mesh(picks, run, "microtubule", "kept", "test", reference_segmentation=instances) + assert [p.instance_id for p in out.points] == [1, 2] + + +def test_clippicks_reference_segmentation_is_indexed_zyx(run): + # Non-cubic reference (z=4, y=20, x=60) with one voxel set at x=50, y=10, z=2. With x/z swapped, the pick near + # it would index out of bounds and be dropped. + array = np.zeros((4, 20, 60), dtype=np.uint8) + array[2, 10, 50] = 1 + reference = _segmentation(run, array, session="clip") + positions = [[500, 100, 20], [100, 100, 20]] # 0 and 400 Angstrom from the voxel + shifts = np.array([[0, 0, 0], [380, 0, 0]], float) # the second's centre is 20 Angstrom away + picks = _picks(run, positions, _transforms(2, shifts), ids=[4, 5], scores=[0.3, 0.4]) + + out, stats = limit_picks_by_distance( + picks, + run, + "microtubule", + "clipped", + "test", + reference_segmentation=reference, + max_distance=50.0, + ) + assert stats["points_created"] == 2 + assert _state(out.points) == _state(picks.points) + + out, stats = limit_picks_by_distance( + picks, + run, + "microtubule", + "far", + "test", + reference_segmentation=reference, + max_distance=50.0, + invert=True, + ) + assert stats["points_created"] == 0 and len(out.points) == 0 + + +def test_painting_and_coordinates_use_centres(run): + picks = _picks(run, [[30, 40, 50]], _transforms(1, np.array([[20, 0, -10]])), ids=[1], scores=[1.0]) + volume = from_picks(picks, np.zeros((10, 10, 10), np.uint8), radius=5.0, label_value=4, voxel_spacing=VS) + assert volume[4, 4, 5] == 4 # centre (50, 40, 40) Angstrom -> voxel z=4, y=4, x=5 + assert volume[5, 4, 3] == 0 # location (30, 40, 50) is not painted + + zyx = coordinates(run, "microtubule", "test", "in", voxel_size=VS) + np.testing.assert_allclose(zyx, [[4.0, 4.0, 5.0]]) From 775528afd4bad6f28bf310717ec7ff9d932bbd27 Mon Sep 17 00:00:00 2001 From: uermel Date: Mon, 5 Oct 2026 23:42:30 -0700 Subject: [PATCH 3/4] chore: relock copick at the pushed core stack head (drop at release) uermel/panoptic-segmentations-v2 now points at dcbbd5e (untyped segmentation queries select binary and multilabel); a5bf194 is no longer on a branch. Co-Authored-By: Claude Opus 5.5 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 83be02d..672b4b6 100644 --- a/uv.lock +++ b/uv.lock @@ -701,7 +701,7 @@ wheels = [ [[package]] name = "copick" version = "2.0.0a1" -source = { git = "https://github.com/copick/copick?rev=uermel%2Fpanoptic-segmentations-v2#a5bf1949970593f9b65a6bb28d1d4ef2ebc8e429" } +source = { git = "https://github.com/copick/copick?rev=uermel%2Fpanoptic-segmentations-v2#dcbbd5e287bbe58f2e14999d8124db14f414f072" } dependencies = [ { name = "aiohttp" }, { name = "click" }, From e3c48c0aa0fbed861cd8e8b0c3d24f7708732501 Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 6 Oct 2026 02:04:38 -0700 Subject: [PATCH 4/4] chore: require copick 2.0.0a3 copick 2.0.0a3 ships the v2.0 filament stack this builds on (copick#481, #482, #493, #495), so the build-against pin to the unreleased branch goes, and uv.lock resolves copick from PyPI. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 +- uv.lock | 104 +++++++++++++++++++++++++------------------------ 2 files changed, 55 insertions(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b5e2199..3e33a01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,7 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - # TODO:remove before release -- build against the unreleased copick filament stack (copick#481/#482/#493/#495); - # restore "copick>=" when it is published. - "copick @ git+https://github.com/copick/copick@uermel/panoptic-segmentations-v2", + "copick>=2.0.0a3,<3", "click", "click-option-group", "numpy", diff --git a/uv.lock b/uv.lock index 672b4b6..15423e0 100644 --- a/uv.lock +++ b/uv.lock @@ -700,8 +700,8 @@ wheels = [ [[package]] name = "copick" -version = "2.0.0a1" -source = { git = "https://github.com/copick/copick?rev=uermel%2Fpanoptic-segmentations-v2#dcbbd5e287bbe58f2e14999d8124db14f414f072" } +version = "2.0.0a3" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, @@ -733,6 +733,10 @@ dependencies = [ { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/91/26/065b6dc5ea4685ec8212329b1e72bc25523f6fe6958902cc4398d622fd63/copick-2.0.0a3.tar.gz", hash = "sha256:63ebfb8ea6fd3e4106d3e3e47c93181ed89f42a2388e68dee19c14f276e39d60", size = 47884273, upload-time = "2026-10-06T08:10:42.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/25/0ea1d3d5a9c9fed0b270bcd533f798f6356654dfd09dd0264f060b40f367/copick-2.0.0a3-py3-none-any.whl", hash = "sha256:f83efe9e5f52e40c5641ac3018f4e25f26a27ff51f6d6f117ec8390d0299ea3e", size = 277010, upload-time = "2026-10-06T08:10:38.833Z" }, +] [[package]] name = "copick-utils" @@ -778,7 +782,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=25.1.0" }, { name = "click" }, { name = "click-option-group" }, - { name = "copick", git = "https://github.com/copick/copick?rev=uermel%2Fpanoptic-segmentations-v2" }, + { name = "copick", specifier = ">=2.0.0a3,<3" }, { name = "hatch-vcs", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "hatchling", marker = "extra == 'dev'", specifier = ">=1.25.0" }, { name = "manifold3d" }, @@ -1431,7 +1435,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2315,16 +2319,16 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "aiohttp", marker = "python_full_version <= '3.11'" }, - { name = "dask", marker = "python_full_version <= '3.11'" }, - { name = "deprecated", marker = "python_full_version <= '3.11'" }, - { name = "fsspec", extra = ["s3"], marker = "python_full_version <= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, - { name = "rangehttpserver", marker = "python_full_version <= '3.11'" }, - { name = "requests", marker = "python_full_version <= '3.11'" }, - { name = "scikit-image", marker = "python_full_version <= '3.11'" }, - { name = "toolz", marker = "python_full_version <= '3.11'" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, + { name = "aiohttp" }, + { name = "dask" }, + { name = "deprecated" }, + { name = "fsspec", extra = ["s3"] }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "rangehttpserver" }, + { name = "requests" }, + { name = "scikit-image" }, + { name = "toolz" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/45/b5b9933ec62028453dc841d25ea66d65405d77d2eb5bb34043621b0c294c/ome_zarr-0.16.0.tar.gz", hash = "sha256:0d31f3409c0f4ad67f04b9a4e9e8855e6e6b040e84d5c9be1095638f90d6eeb9", size = 86252, upload-time = "2026-04-14T09:43:32.354Z" } wheels = [ @@ -2347,18 +2351,18 @@ resolution-markers = [ "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "aiohttp", marker = "python_full_version > '3.11'" }, - { name = "dask", marker = "python_full_version > '3.11'" }, - { name = "deprecated", marker = "python_full_version > '3.11'" }, - { name = "fsspec", extra = ["s3"], marker = "python_full_version > '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "aiohttp" }, + { name = "dask" }, + { name = "deprecated" }, + { name = "fsspec", extra = ["s3"] }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "ome-zarr-models", marker = "python_full_version > '3.11'" }, - { name = "rangehttpserver", marker = "python_full_version > '3.11'" }, - { name = "requests", marker = "python_full_version > '3.11'" }, - { name = "scikit-image", marker = "python_full_version > '3.11'" }, - { name = "toolz", marker = "python_full_version > '3.11'" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "ome-zarr-models" }, + { name = "rangehttpserver" }, + { name = "requests" }, + { name = "scikit-image" }, + { name = "toolz" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/98/f39cf0ec00ad64996ed2d2d6059d15a30c7b8f559cd2a216589885001a6a/ome_zarr-0.18.0.tar.gz", hash = "sha256:5cc0fc682b0c177fa32e54f4479b2436f79850a171e83ad4c6b08fec32e66cd0", size = 106039, upload-time = "2026-06-17T16:22:07.76Z" } @@ -2371,10 +2375,10 @@ name = "ome-zarr-models" version = "1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "python_full_version > '3.11'" }, - { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "pydantic" }, + { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "pydantic-zarr", version = "0.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/e7/c37b8896a24eabc76d6a5e7855f722792de5bdf3f5ccc562001738be4f7e/ome_zarr_models-1.6.tar.gz", hash = "sha256:00735ad939d2b5f6a4f583cc19af1e8a5247fa1e93f1f08191143cd913b9633a", size = 106913, upload-time = "2026-03-10T10:14:28.369Z" } @@ -2927,9 +2931,9 @@ resolution-markers = [ "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, - { name = "packaging", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, - { name = "pydantic", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/19/ad4f22b7a395408c90cc0f213d70cf5b9ed5f3b0db2b319cacff93b66850/pydantic_zarr-0.9.2.tar.gz", hash = "sha256:59f536cbc456ff4dc64d887d651476e2a6a2ca9f3ea13228dce4e3aeb1cf00b9", size = 80379, upload-time = "2026-03-18T14:38:30.766Z" } wheels = [ @@ -2949,9 +2953,9 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/57/aac9e500abe15719b7406d55871fc8787e7d0ebf83de5f98d44471139f70/pydantic_zarr-0.10.0.tar.gz", hash = "sha256:996b5b4225fb05757e7fff831ee7a75c61f54514a4c2493c7b79635bade7b415", size = 80374, upload-time = "2026-04-23T14:23:12.575Z" } wheels = [ @@ -3376,7 +3380,7 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -3455,7 +3459,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -3670,7 +3674,7 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } wheels = [ @@ -3690,7 +3694,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/22/5de8a60ec7cd23436a9a5e2c5be8866556796307e0f54e0fbc2bf1981677/tifffile-2026.8.16.tar.gz", hash = "sha256:523a18b8183253e1e594162eed45f157c959d8cff6decde3f2d58f20a5a45389", size = 442370, upload-time = "2026-08-15T16:09:35.736Z" } wheels = [ @@ -4065,12 +4069,12 @@ resolution-markers = [ "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig", marker = "python_full_version < '3.12'" }, - { name = "google-crc32c", marker = "python_full_version < '3.12'" }, - { name = "numcodecs", marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "packaging", marker = "python_full_version < '3.12'" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "donfig" }, + { name = "google-crc32c" }, + { name = "numcodecs" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } wheels = [ @@ -4090,12 +4094,12 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig", marker = "python_full_version >= '3.12'" }, - { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, - { name = "numcodecs", marker = "python_full_version >= '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "donfig" }, + { name = "google-crc32c" }, + { name = "numcodecs" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/15/436cb1d3bbe86173bd44ce7a34ecb210d0c0416946e337858149a905ef5a/zarr-3.3.0.tar.gz", hash = "sha256:cd0c8cf738b4bb4807815bc1255acad5bdf1a7b7264b606c5a1bc0d0392a306b", size = 943626, upload-time = "2026-07-30T16:35:10.491Z" } wheels = [