nps-cli 0.1.2__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nps_cli-0.1.5/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: nps-cli
3
+ Version: 0.1.5
4
+ Summary: A simple CLI for sampling point clouds from large volumetric datasets
5
+ Author: Jakob Troidl
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: click
11
+ Requires-Dist: cloud-volume!=12.13.*
12
+ Requires-Dist: volara>=2.0.1
13
+ Requires-Dist: pocaduck
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Requires-Dist: black; extra == "dev"
17
+ Requires-Dist: build; extra == "dev"
18
+ Requires-Dist: twine; extra == "dev"
19
+ Requires-Dist: ruff; extra == "dev"
20
+
21
+ [![pypi](https://badge.fury.io/py/nps-cli.svg)](https://badge.fury.io/py/nps-cli)
22
+ [![Python Version](https://img.shields.io/pypi/pyversions/nps-cli.svg)](https://pypi.org/project/nps-cli/)
23
+
24
+ # nps: distributed point cloud sampling
25
+
26
+ `nps` samples point clouds of every segment in a large segmentation volumes
27
+ * Compatible with [CloudVolume](https://github.com/seung-lab/cloud-volume) (precomputed or graphene).
28
+ * It processes the volume blockwise on local, LSF or Slurm workers and writes the points in [pocaduck](https://github.com/JaneliaSciComp/pocaduck) format.
29
+
30
+ ```bash
31
+ pip install nps-cli
32
+ nps --help
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ # Whole volume
39
+ nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation
40
+
41
+ # Subvolume (begin_x begin_y begin_z end_x end_y end_z, in voxels), 1% of each segment
42
+ nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation \
43
+ --bbox 15347 19712 18606 15859 20224 19118 --fraction 0.01
44
+
45
+ # Also store the supervoxel ID of every point, looked up in a separate volume
46
+ # that shares the voxel grid of --cv-path
47
+ nps --cv-path precomputed://gs://<bucket>/agglomerated \
48
+ --svid-cv-path precomputed://gs://<bucket>/supervoxels
49
+
50
+ # Graphene: read supervoxels from the same volume (agglomerate=False)
51
+ nps --cv-path graphene://https://<server>/segmentation/table/<table> --sample_svids
52
+ ```
53
+
54
+ Key options (run `nps --help` for all of them):
55
+
56
+ | Option | Default | Meaning |
57
+ | --- | --- | --- |
58
+ | `--fraction` | `0.001` | Fraction of each segment's voxels to sample (at least 1 per segment and block). |
59
+ | `--mip` | `0` | Resolution level to sample at. Coordinates are voxels of this mip. |
60
+ | `--bbox` | whole volume | ROI in voxels. |
61
+ | `--block-size` | `128 128 128` | Block size per task, in voxels. |
62
+ | `--worker-type` / `--num-workers` / `--cpus-per-worker` / `--queue` | `LocalWorker` / `8` / `4` / `local` | Where and how the blocks are processed. |
63
+ | `--fill-missing` | off | Treat missing chunks as empty instead of raising an error. |
64
+ | `--overwrite` | off | Delete points of an earlier run in `--output-dir` instead of refusing to start. |
65
+ | `--skip-empty` / `--block-mask` / `--mask-mip` | on | Skipping empty blocks, see below. |
66
+
67
+ ### Output
68
+
69
+ ```
70
+ <output-dir>/
71
+ block_mask.npy # occupancy mask from the empty-block pre-scan
72
+ points/
73
+ unified_index.db # pocaduck index over all workers
74
+ worker_<id>/... # parquet files + per-worker index
75
+ ```
76
+
77
+ Each point is `[x, y, z]`, or `[x, y, z, svid]` when SVIDs are sampled, stored under its segment label.
78
+
79
+ `nps` refuses to write into an `--output-dir` that already holds points, since every
80
+ run re-samples all blocks and old points would show up as duplicates. Pass
81
+ `--overwrite` to delete them first.
82
+
83
+ ### Skipping empty blocks
84
+
85
+ Before sampling, `nps` scans the ROI at a coarse mip (`--mask-mip`, default the
86
+ coarsest one) and never dispatches blocks without labels. The mask is dilated by
87
+ one block, so thin structures lost in downsampling are still sampled. Use
88
+ `--no-skip-empty` to disable the scan, or `--block-mask <path>` to reuse a
89
+ `block_mask.npy` from an earlier run with the same `--bbox`, `--block-size` and `--mip`.
90
+
91
+ ## Reading point clouds
92
+
93
+ ```python
94
+ from pocaduck import Query, StorageConfig
95
+
96
+ query = Query(storage_config=StorageConfig(base_path="<output-dir>/points"))
97
+ labels = query.get_labels()
98
+ points = query.get_points(label=12345) # all points of one segment
99
+ query.close()
100
+ ```
101
+
102
+ See the [pocaduck docs](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#querying-point-clouds)
103
+ for more, including [optimizing](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#running-the-optimization-pipeline) the storage for fast reads.
104
+
105
+ ## Example scripts
106
+
107
+ | Script | What it does |
108
+ | --- | --- |
109
+ | [`liconn_local.sh`](scripts/liconn_local.sh) | Points only, local workers |
110
+ | [`liconn_svids_local.sh`](scripts/liconn_svids_local.sh) | Points + SVIDs from a separate precomputed volume |
111
+ | [`flywire_cluster.sh`](scripts/flywire_cluster.sh) | Points + SVIDs for FlyWire on LSF (`LABEL_NAME=flywire_v141_m783` for the 2023 snapshot) |
112
+
113
+ ## Release
114
+
115
+ ```bash
116
+ pip install -e ".[dev]"
117
+ python -m build
118
+ twine upload dist/*
119
+ ```
@@ -0,0 +1,99 @@
1
+ [![pypi](https://badge.fury.io/py/nps-cli.svg)](https://badge.fury.io/py/nps-cli)
2
+ [![Python Version](https://img.shields.io/pypi/pyversions/nps-cli.svg)](https://pypi.org/project/nps-cli/)
3
+
4
+ # nps: distributed point cloud sampling
5
+
6
+ `nps` samples point clouds of every segment in a large segmentation volumes
7
+ * Compatible with [CloudVolume](https://github.com/seung-lab/cloud-volume) (precomputed or graphene).
8
+ * It processes the volume blockwise on local, LSF or Slurm workers and writes the points in [pocaduck](https://github.com/JaneliaSciComp/pocaduck) format.
9
+
10
+ ```bash
11
+ pip install nps-cli
12
+ nps --help
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # Whole volume
19
+ nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation
20
+
21
+ # Subvolume (begin_x begin_y begin_z end_x end_y end_z, in voxels), 1% of each segment
22
+ nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation \
23
+ --bbox 15347 19712 18606 15859 20224 19118 --fraction 0.01
24
+
25
+ # Also store the supervoxel ID of every point, looked up in a separate volume
26
+ # that shares the voxel grid of --cv-path
27
+ nps --cv-path precomputed://gs://<bucket>/agglomerated \
28
+ --svid-cv-path precomputed://gs://<bucket>/supervoxels
29
+
30
+ # Graphene: read supervoxels from the same volume (agglomerate=False)
31
+ nps --cv-path graphene://https://<server>/segmentation/table/<table> --sample_svids
32
+ ```
33
+
34
+ Key options (run `nps --help` for all of them):
35
+
36
+ | Option | Default | Meaning |
37
+ | --- | --- | --- |
38
+ | `--fraction` | `0.001` | Fraction of each segment's voxels to sample (at least 1 per segment and block). |
39
+ | `--mip` | `0` | Resolution level to sample at. Coordinates are voxels of this mip. |
40
+ | `--bbox` | whole volume | ROI in voxels. |
41
+ | `--block-size` | `128 128 128` | Block size per task, in voxels. |
42
+ | `--worker-type` / `--num-workers` / `--cpus-per-worker` / `--queue` | `LocalWorker` / `8` / `4` / `local` | Where and how the blocks are processed. |
43
+ | `--fill-missing` | off | Treat missing chunks as empty instead of raising an error. |
44
+ | `--overwrite` | off | Delete points of an earlier run in `--output-dir` instead of refusing to start. |
45
+ | `--skip-empty` / `--block-mask` / `--mask-mip` | on | Skipping empty blocks, see below. |
46
+
47
+ ### Output
48
+
49
+ ```
50
+ <output-dir>/
51
+ block_mask.npy # occupancy mask from the empty-block pre-scan
52
+ points/
53
+ unified_index.db # pocaduck index over all workers
54
+ worker_<id>/... # parquet files + per-worker index
55
+ ```
56
+
57
+ Each point is `[x, y, z]`, or `[x, y, z, svid]` when SVIDs are sampled, stored under its segment label.
58
+
59
+ `nps` refuses to write into an `--output-dir` that already holds points, since every
60
+ run re-samples all blocks and old points would show up as duplicates. Pass
61
+ `--overwrite` to delete them first.
62
+
63
+ ### Skipping empty blocks
64
+
65
+ Before sampling, `nps` scans the ROI at a coarse mip (`--mask-mip`, default the
66
+ coarsest one) and never dispatches blocks without labels. The mask is dilated by
67
+ one block, so thin structures lost in downsampling are still sampled. Use
68
+ `--no-skip-empty` to disable the scan, or `--block-mask <path>` to reuse a
69
+ `block_mask.npy` from an earlier run with the same `--bbox`, `--block-size` and `--mip`.
70
+
71
+ ## Reading point clouds
72
+
73
+ ```python
74
+ from pocaduck import Query, StorageConfig
75
+
76
+ query = Query(storage_config=StorageConfig(base_path="<output-dir>/points"))
77
+ labels = query.get_labels()
78
+ points = query.get_points(label=12345) # all points of one segment
79
+ query.close()
80
+ ```
81
+
82
+ See the [pocaduck docs](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#querying-point-clouds)
83
+ for more, including [optimizing](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#running-the-optimization-pipeline) the storage for fast reads.
84
+
85
+ ## Example scripts
86
+
87
+ | Script | What it does |
88
+ | --- | --- |
89
+ | [`liconn_local.sh`](scripts/liconn_local.sh) | Points only, local workers |
90
+ | [`liconn_svids_local.sh`](scripts/liconn_svids_local.sh) | Points + SVIDs from a separate precomputed volume |
91
+ | [`flywire_cluster.sh`](scripts/flywire_cluster.sh) | Points + SVIDs for FlyWire on LSF (`LABEL_NAME=flywire_v141_m783` for the 2023 snapshot) |
92
+
93
+ ## Release
94
+
95
+ ```bash
96
+ pip install -e ".[dev]"
97
+ python -m build
98
+ twine upload dist/*
99
+ ```
@@ -0,0 +1,2 @@
1
+ from .sample_points import SamplePoints as SamplePoints
2
+ from .sample_points import consolidate_indexes as consolidate_indexes
@@ -0,0 +1,141 @@
1
+ """
2
+ Per-block occupancy mask computed from a coarse mip level.
3
+
4
+ Reading a chunk from a (sharded) precomputed volume costs roughly the same
5
+ regardless of how many voxels are requested, so checking each block for
6
+ emptiness at sampling time does not pay off. Instead we make one pass over the
7
+ ROI at the coarsest available mip, where a single chunk covers hundreds of
8
+ sampling blocks, and record for every block whether it contains any label.
9
+ The mask is then used by :class:`SamplePoints` to skip empty blocks inside the
10
+ daisy scheduler, so workers never touch them.
11
+ """
12
+
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from itertools import product
15
+
16
+ import numpy as np
17
+ from cloudvolume import CloudVolume
18
+ from funlib.geometry import Coordinate, Roi
19
+ from volara.datasets import CloudVolumeWrapper
20
+
21
+ # Approximate edge length, in coarse voxels, of the region read per job.
22
+ _TARGET_TILE_VOXELS = 128
23
+
24
+
25
+ def _open_coarse(store: str, mip: int, timestamp: int, agglomerate: bool) -> CloudVolume:
26
+ return CloudVolume(
27
+ store,
28
+ mip=mip,
29
+ use_https=True,
30
+ agglomerate=agglomerate,
31
+ timestamp=timestamp,
32
+ fill_missing=True, # missing chunks are, by definition, empty
33
+ bounded=False, # tiles at the ROI edge may poke out of the volume
34
+ )
35
+
36
+
37
+ def _tile_job(args):
38
+ """Read one coarse tile and return which of its blocks are non-empty."""
39
+ vol, tile_index, block_lo, block_hi, roi_begin, roi_end, block_size, factor = args
40
+ block_lo, block_hi = np.array(block_lo), np.array(block_hi)
41
+ roi_begin, roi_end = np.array(roi_begin), np.array(roi_end)
42
+ block_size, factor = np.array(block_size), np.array(factor)
43
+
44
+ # Coarse voxel range covering all blocks in this tile (clipped to the ROI).
45
+ fine_lo = roi_begin + block_lo * block_size
46
+ fine_hi = np.minimum(roi_begin + block_hi * block_size, roi_end)
47
+ coarse_lo = fine_lo // factor
48
+ coarse_hi = -(-fine_hi // factor) # ceil
49
+ data = np.asarray(
50
+ vol[tuple(slice(int(a), int(b)) for a, b in zip(coarse_lo, coarse_hi))]
51
+ ).squeeze(axis=-1)
52
+
53
+ out = np.zeros(block_hi - block_lo, dtype=bool)
54
+ for idx in product(*(range(n) for n in out.shape)):
55
+ b = block_lo + np.array(idx)
56
+ lo = (roi_begin + b * block_size) // factor - coarse_lo
57
+ hi = -(-(np.minimum(roi_begin + (b + 1) * block_size, roi_end)) // factor) - coarse_lo
58
+ out[idx] = data[tuple(slice(int(a), int(c)) for a, c in zip(lo, hi))].any()
59
+ return tile_index, out
60
+
61
+
62
+ def _dilate(mask: np.ndarray, radius: int) -> np.ndarray:
63
+ """Binary dilation with a cube of the given radius (no scipy dependency)."""
64
+ if radius <= 0:
65
+ return mask
66
+ padded = np.pad(mask, radius)
67
+ out = np.zeros_like(mask)
68
+ for shift in product(range(2 * radius + 1), repeat=mask.ndim):
69
+ sl = tuple(slice(s, s + n) for s, n in zip(shift, mask.shape))
70
+ out |= padded[sl]
71
+ return out
72
+
73
+
74
+ def compute_block_mask(
75
+ labels: CloudVolumeWrapper,
76
+ roi: Roi,
77
+ block_size: Coordinate,
78
+ mask_mip: int | None = None,
79
+ num_workers: int = 8,
80
+ dilate: int = 1,
81
+ ) -> np.ndarray:
82
+ """
83
+ Compute a boolean array, indexed by block index within ``roi``, that is
84
+ True for blocks containing at least one non-zero label.
85
+
86
+ Args:
87
+ labels: The label volume that will be sampled.
88
+ roi: ROI to be processed, in voxel coordinates of ``labels.mip``
89
+ (i.e. the frame daisy blocks are expressed in).
90
+ block_size: Sampling block size in the same frame.
91
+ mask_mip: Mip level to read for the occupancy check. Defaults to the
92
+ coarsest mip available.
93
+ num_workers: Number of threads reading coarse tiles. Reads are I/O and
94
+ decompression bound and release the GIL, so threads scale as well
95
+ as processes here while avoiding multiprocessing entirely (forked
96
+ and spawned pools both died with BrokenProcessPool on LSF nodes).
97
+ dilate: Grow the mask by this many blocks in every direction. Guards
98
+ against thin structures that vanish when downsampled.
99
+ """
100
+ base = CloudVolume(str(labels.store), mip=labels.mip, use_https=True)
101
+ if mask_mip is None:
102
+ mask_mip = max(base.available_mips)
103
+ factor = np.asarray(base.mip_resolution(mask_mip)) / np.asarray(
104
+ base.mip_resolution(labels.mip)
105
+ )
106
+ assert np.allclose(factor, np.round(factor)), (
107
+ f"mip {mask_mip} resolution is not an integer multiple of mip {labels.mip}"
108
+ )
109
+ factor = np.round(factor).astype(int)
110
+
111
+ roi_begin = np.array(roi.begin)
112
+ roi_end = np.array(roi.end)
113
+ block_size = np.array(block_size)
114
+ n_blocks = -(-(roi_end - roi_begin) // block_size)
115
+
116
+ # Blocks per tile per axis: enough that a tile is ~_TARGET_TILE_VOXELS wide.
117
+ blocks_per_tile = np.maximum(
118
+ 1, _TARGET_TILE_VOXELS // np.maximum(1, block_size // factor)
119
+ )
120
+ n_tiles = -(-n_blocks // blocks_per_tile)
121
+
122
+ coarse = _open_coarse(
123
+ str(labels.store), mask_mip, labels.timestamp, labels.agglomerate
124
+ )
125
+ jobs = []
126
+ for t in product(*(range(n) for n in n_tiles)):
127
+ t = np.array(t)
128
+ lo = t * blocks_per_tile
129
+ hi = np.minimum(lo + blocks_per_tile, n_blocks)
130
+ jobs.append(
131
+ (coarse, tuple(t), tuple(lo), tuple(hi), tuple(roi_begin),
132
+ tuple(roi_end), tuple(block_size), tuple(factor))
133
+ )
134
+
135
+ mask = np.zeros(n_blocks, dtype=bool)
136
+ with ThreadPoolExecutor(max_workers=num_workers) as pool:
137
+ for tile_index, sub in pool.map(_tile_job, jobs):
138
+ lo = np.array(tile_index) * blocks_per_tile
139
+ mask[tuple(slice(a, a + n) for a, n in zip(lo, sub.shape))] = sub
140
+
141
+ return _dilate(mask, dilate)
@@ -0,0 +1,248 @@
1
+ import glob
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import uuid
6
+ from contextlib import contextmanager
7
+ from typing import Literal
8
+
9
+ import daisy
10
+ import duckdb
11
+ import numpy as np
12
+ import pandas as pd
13
+ import pyarrow as pa
14
+ import pyarrow.parquet as pq
15
+ from daisy import Block
16
+ from funlib.geometry import Coordinate, Roi
17
+ from volara.blockwise import BlockwiseTask
18
+ from volara.datasets import CloudVolumeWrapper, Dataset
19
+ from volara.utils import PydanticCoordinate
20
+
21
+ _INDEX_SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS {name} (
23
+ label UBIGINT,
24
+ block_id VARCHAR,
25
+ file_path VARCHAR,
26
+ point_count UBIGINT
27
+ )
28
+ """
29
+
30
+
31
+ class SamplePoints(BlockwiseTask):
32
+ task_type: Literal["sample_pc"] = "sample_pc"
33
+ out_dir: str
34
+ labels: CloudVolumeWrapper
35
+ svids: CloudVolumeWrapper | None = None
36
+ block_mask: str | None = None
37
+ block_size: PydanticCoordinate
38
+ fraction: float
39
+ fit: Literal["shrink"] = "shrink"
40
+ read_write_conflict: Literal[False] = False
41
+
42
+ @property
43
+ def task_name(self) -> str:
44
+ return f"{self.labels.name}-{self.task_type}"
45
+
46
+ @property
47
+ def write_roi(self) -> Roi:
48
+ total_roi = self.labels.array("r").roi
49
+ if self.roi is not None:
50
+ total_roi = total_roi.intersect(self.roi)
51
+ return total_roi
52
+
53
+ @property
54
+ def voxel_size(self) -> Coordinate:
55
+ return self.labels.voxel_size
56
+
57
+ @property
58
+ def write_size(self) -> Coordinate:
59
+ return self.block_size
60
+
61
+ @property
62
+ def context_size(self) -> Coordinate:
63
+ return Coordinate((0,) * self.write_size.dims)
64
+
65
+ def drop_artifacts(self):
66
+ # init() recreates the directory when the task is run.
67
+ shutil.rmtree(self.out_dir, ignore_errors=True)
68
+
69
+ def check_block_func(self):
70
+ """
71
+ Daisy calls this in the scheduler before dispatching a block; returning
72
+ True means "already done". If a block mask is given, report empty blocks
73
+ as done so they are never sent to a worker.
74
+ """
75
+ base = super().check_block_func()
76
+ if self.block_mask is None:
77
+ return base
78
+
79
+ mask = np.load(self.block_mask)
80
+ roi_begin = np.array(self.write_roi.begin)
81
+ block_size = np.array(self.block_size)
82
+
83
+ def check_block(block: Block) -> bool:
84
+ index = (np.array(block.write_roi.begin) - roi_begin) // block_size
85
+ if not mask[tuple(index)]:
86
+ return True
87
+ return base(block)
88
+
89
+ return check_block
90
+
91
+ @property
92
+ def output_datasets(self) -> list[Dataset]:
93
+ return []
94
+
95
+ def sample_pc_in_block(self, block: Block, labels: np.ndarray, svids: np.ndarray | None, offset: Coordinate, writer: "_PointWriter"):
96
+ block_id = block.block_id[1]
97
+
98
+ seg_ids, pts = self.sample_segment_points(labels, self.fraction)
99
+ logging.info(f"sampled {len(pts)} points in block {block_id}")
100
+
101
+ packed_pts = (pts + np.array(offset)).astype(np.uint64)
102
+ if self.svids is not None:
103
+ sampled_svids = svids[pts[:, 0], pts[:, 1], pts[:, 2]].reshape(-1, 1)
104
+ packed_pts = np.concatenate((packed_pts, sampled_svids.astype(np.uint64)), axis=1)
105
+
106
+ writer.write(block_id, seg_ids, packed_pts)
107
+
108
+ def init(self):
109
+ os.makedirs(self.out_dir, exist_ok=True)
110
+
111
+ @contextmanager
112
+ def process_block_func(self):
113
+ data = self.labels.array("r")
114
+ if self.svids is not None:
115
+ supervoxels = self.svids.array("r")
116
+
117
+ try:
118
+ worker_id = daisy.Context.from_env()["worker_id"]
119
+ except KeyError:
120
+ worker_id = 0
121
+ # One writer per worker process instead of a pocaduck Ingestor per
122
+ # block, which re-read and re-wrote the worker's whole parquet file
123
+ # and ran two DuckDB queries per segment on every block.
124
+ writer = _PointWriter(self.out_dir, worker_id)
125
+
126
+ def process_block(block: Block):
127
+ # Index with the Roi (world/absolute voxel coords), not with
128
+ # to_slices(): numpy-style keys on a funlib Array are zero-based and
129
+ # would read from the wrong place on volumes with a voxel offset.
130
+ # Only squeeze the channel axis; edge blocks can be 1 voxel thick.
131
+ labels = np.asarray(data[block.write_roi]).squeeze(axis=-1)
132
+
133
+ offset = block.write_roi.get_begin()
134
+
135
+ if self.svids is not None:
136
+ s = np.asarray(supervoxels[block.write_roi]).squeeze(axis=-1)
137
+ assert s.shape == labels.shape, (
138
+ f"label block {labels.shape} and svid block {s.shape} differ in "
139
+ f"{block.write_roi}; the label and supervoxel volumes must share "
140
+ "resolution and offset at the chosen mip"
141
+ )
142
+ else:
143
+ s = None
144
+
145
+ self.sample_pc_in_block(block, labels, s, offset, writer)
146
+
147
+ # Index consolidation happens once in the driver after all blocks are
148
+ # done (see consolidate_indexes), not concurrently in every worker.
149
+ yield process_block
150
+
151
+ def sample_segment_points(self, labels, fraction, background=0):
152
+ """
153
+ Sample max(1, int(n * fraction)) voxels without replacement from every
154
+ segment with n voxels. Returns the segment ID of every sampled point
155
+ and the (N, 3) voxel coordinates within ``labels``.
156
+
157
+ Vectorized: one sort over the foreground voxels instead of one full
158
+ ``labels == seg`` scan per segment.
159
+ """
160
+ flat = labels.ravel()
161
+ idx = np.flatnonzero(flat != background) if background is not None else np.arange(flat.size)
162
+ vals = flat[idx]
163
+
164
+ # Shuffle, then stable-sort by segment: groups segments together with a
165
+ # random order inside each group, so the first k of a group are a
166
+ # uniform sample without replacement.
167
+ perm = np.random.permutation(len(idx))
168
+ perm = perm[np.argsort(vals[perm], kind="stable")]
169
+ idx, vals = idx[perm], vals[perm]
170
+
171
+ _, starts, counts = np.unique(vals, return_index=True, return_counts=True)
172
+ k = np.maximum(1, (counts * fraction).astype(np.int64))
173
+ rank = np.arange(len(vals)) - np.repeat(starts, counts)
174
+ keep = rank < np.repeat(k, counts)
175
+
176
+ coords = np.column_stack(np.unravel_index(idx[keep], labels.shape))
177
+ return vals[keep], coords
178
+
179
+
180
+ class _PointWriter:
181
+ """
182
+ Writes sampled points of one worker in the layout of pocaduck's Ingestor
183
+ (``worker_<id>/data/*.parquet`` plus ``worker_<id>/index_<id>.db``), so
184
+ :func:`consolidate_indexes` and ``pocaduck.Query`` work unchanged.
185
+
186
+ Every block is written (parquet first, then its index rows) before the
187
+ block returns: daisy marks a block done as soon as process_block returns
188
+ and does not wait for worker teardown, so buffering across blocks would
189
+ lose data.
190
+ """
191
+
192
+ def __init__(self, out_dir: str, worker_id):
193
+ worker_dir = os.path.join(out_dir, f"worker_{worker_id}")
194
+ self.data_dir = os.path.join(worker_dir, "data")
195
+ self.db_path = os.path.join(worker_dir, f"index_{worker_id}.db")
196
+ os.makedirs(self.data_dir, exist_ok=True)
197
+ # Unique prefix: a restarted worker can reuse a worker_id and must not
198
+ # overwrite files of a previous run.
199
+ self.prefix = f"{worker_id}-{uuid.uuid4().hex[:8]}"
200
+
201
+ def write(self, block_id, seg_ids: np.ndarray, points: np.ndarray):
202
+ if len(points) == 0:
203
+ return
204
+ file_path = os.path.join(self.data_dir, f"{self.prefix}-{block_id}.parquet")
205
+ data = pa.FixedSizeListArray.from_arrays(pa.array(points.ravel()), points.shape[1])
206
+ table = pa.table({
207
+ "label": seg_ids.astype(np.int64),
208
+ "block_id": np.full(len(points), block_id, dtype=np.int64),
209
+ # Same list<uint64> column the pocaduck Ingestor writes.
210
+ "data": data.cast(pa.list_(pa.uint64())),
211
+ })
212
+ # Write-then-rename so a killed worker never leaves a truncated file.
213
+ pq.write_table(table, file_path + ".tmp")
214
+ os.replace(file_path + ".tmp", file_path)
215
+
216
+ segs, counts = np.unique(seg_ids, return_counts=True)
217
+ index = pd.DataFrame({
218
+ "label": segs.astype(np.uint64),
219
+ "block_id": str(block_id),
220
+ "file_path": file_path,
221
+ "point_count": counts.astype(np.uint64),
222
+ })
223
+ # Short-lived connection: the driver attaches these files for
224
+ # consolidation while worker processes may still be alive.
225
+ con = duckdb.connect(self.db_path)
226
+ con.execute(_INDEX_SCHEMA.format(name="point_cloud_index"))
227
+ con.register("new_rows", index)
228
+ con.execute("INSERT INTO point_cloud_index SELECT * FROM new_rows")
229
+ con.close()
230
+
231
+
232
+ def consolidate_indexes(out_dir: str) -> str:
233
+ """
234
+ Merge all worker indexes into ``unified_index.db`` (what pocaduck's Query
235
+ reads). Replaces ``Ingestor.consolidate_indexes``, which pulls every row
236
+ through Python and was run concurrently by every worker.
237
+ """
238
+ output_path = os.path.join(out_dir, "unified_index.db")
239
+ if os.path.exists(output_path):
240
+ os.remove(output_path)
241
+ con = duckdb.connect(output_path)
242
+ con.execute(_INDEX_SCHEMA.format(name="point_cloud_index"))
243
+ for path in sorted(glob.glob(os.path.join(out_dir, "worker_*", "index_*.db"))):
244
+ con.execute(f"ATTACH '{path}' AS w (READ_ONLY)")
245
+ con.execute("INSERT INTO point_cloud_index SELECT * FROM w.point_cloud_index")
246
+ con.execute("DETACH w")
247
+ con.close()
248
+ return output_path
@@ -0,0 +1,117 @@
1
+ import faulthandler
2
+ import os
3
+ import time
4
+
5
+ import click
6
+ import numpy as np
7
+ from funlib.geometry import Coordinate, Roi
8
+ from volara.datasets import CloudVolumeWrapper
9
+ from volara.workers import LSFWorker, LocalWorker, SlurmWorker
10
+
11
+ from nps.blockwise.block_mask import compute_block_mask
12
+ from nps.blockwise.sample_points import SamplePoints, consolidate_indexes
13
+
14
+
15
+ @click.command(context_settings=dict(help_option_names=['-h', '--help']))
16
+ @click.option('--cv-path', required=True, help='Path to CloudVolume data.')
17
+ @click.option('--svid-cv-path', default=None, help='Separate CloudVolume path holding supervoxel IDs (e.g. a precomputed watershed volume). Implies SVID sampling. Must share the voxel grid of --cv-path at the chosen mip.')
18
+ @click.option('--mip', default=0, type=int, show_default=True, help='MIP level to use.')
19
+ @click.option('--timestamp', default=int(time.time()), help='Optional timestamp for the dataset version (graphene only).')
20
+ @click.option('--sample_svids', is_flag=True, default=False, help='Sample SVIDs in addition to points (default: False). Without --svid-cv-path, supervoxels are read from --cv-path with agglomerate=False (graphene only).')
21
+ @click.option('--fill-missing', is_flag=True, default=False, help='Return 0 for missing chunks instead of raising an error (default: False).')
22
+ @click.option('--output-dir', '-o', default='./nps_output', show_default=True, type=click.Path(file_okay=False, writable=True), help='Output directory.')
23
+ @click.option('--overwrite', is_flag=True, default=False, help='Delete points from an earlier run in --output-dir before sampling (default: False).')
24
+ @click.option('--worker-type', default='LocalWorker', show_default=True, type=click.Choice(['LocalWorker', 'LSFWorker', 'SlurmWorker'], case_sensitive=True), help='Type of worker to use for sampling.')
25
+ @click.option('--num-workers', default=8, show_default=True, type=int, help='Number of workers for blockwise sampling.')
26
+ @click.option('--cpus-per-worker', default=4, show_default=True, type=int, help='Number of CPUs per worker.')
27
+ @click.option('--queue', default='local', show_default=True, help='Queue name (LSF/Slurm backends).')
28
+ @click.option('--fraction', default=0.001, show_default=True, type=float, help='Fraction of points to sample [0.0, 1.0].')
29
+ @click.option('--block-size', nargs=3, type=int, default=(128, 128, 128), show_default=True, help='Block size in voxels (X Y Z).')
30
+ @click.option('--bbox', nargs=6, type=int, default=None, help='Bounding box: begin_x begin_y begin_z end_x end_y end_z (in voxels).')
31
+ @click.option('--skip-empty/--no-skip-empty', default=True, show_default=True, help='Pre-scan a coarse mip to find blocks without labels and skip them.')
32
+ @click.option('--mask-mip', default=None, type=int, help='Mip level used for the empty-block pre-scan. [default: coarsest available]')
33
+ @click.option('--block-mask', default=None, type=click.Path(exists=True, dir_okay=False), help='Reuse an existing block_mask.npy from a previous run with identical --bbox, --block-size and --mip instead of re-scanning.')
34
+ def main(cv_path, svid_cv_path, mip, timestamp, output_dir, num_workers, cpus_per_worker, queue, fraction, block_size, sample_svids, worker_type, bbox, fill_missing, skip_empty, mask_mip, block_mask, overwrite):
35
+
36
+ # On a native crash (segfault) dump every thread's Python stack to stderr so
37
+ # the cause shows up in the job log instead of a bare "exit code 139".
38
+ faulthandler.enable()
39
+
40
+ output_dir = os.path.abspath(output_dir)
41
+ points_dir = os.path.join(output_dir, 'points')
42
+ # Every run re-samples all blocks, so points left over from an earlier run
43
+ # would end up as duplicates. Check before the (slow) empty-block pre-scan.
44
+ if os.path.isdir(points_dir) and os.listdir(points_dir):
45
+ if not overwrite:
46
+ raise click.UsageError(
47
+ f"{points_dir} already holds points from an earlier run. "
48
+ "Pass --overwrite to delete them or choose another --output-dir."
49
+ )
50
+ click.echo(f"--overwrite: deleting points from an earlier run in {points_dir}")
51
+ os.makedirs(points_dir, exist_ok=True)
52
+
53
+ click.echo(f"Reading CloudVolume at {cv_path} (mip={mip}, timestamp={timestamp})")
54
+
55
+ base = os.path.basename(output_dir)
56
+ common = dict(mip=mip, timestamp=timestamp, fill_missing=fill_missing)
57
+ labels = CloudVolumeWrapper(data_name=base + "_labels", store=cv_path, **common)
58
+
59
+ if sample_svids or svid_cv_path:
60
+ svid_store = svid_cv_path or cv_path
61
+ click.echo(f"Sampling SVIDs in addition to points from {svid_store}...")
62
+ # agglomerate=False selects the watershed layer for graphene and is
63
+ # ignored by precomputed volumes, so one code path serves both cases.
64
+ svids = CloudVolumeWrapper(data_name=base + "_svids", store=svid_store, agglomerate=False, **common)
65
+ else:
66
+ click.echo("Sampling points only (no SVIDs)...")
67
+ svids = None
68
+
69
+ if worker_type == 'LocalWorker':
70
+ click.echo("Using LocalWorker for sampling.")
71
+ worker_config = LocalWorker()
72
+ elif worker_type == 'LSFWorker':
73
+ click.echo(f"Using LSFWorker with queue '{queue}' and {cpus_per_worker} CPUs per worker.")
74
+ worker_config = LSFWorker(queue=queue, num_cpus=cpus_per_worker)
75
+ elif worker_type == 'SlurmWorker':
76
+ click.echo(f"Using SlurmWorker with queue '{queue}' and {cpus_per_worker} CPUs per worker.")
77
+ worker_config = SlurmWorker(queue=queue, num_cpus=cpus_per_worker)
78
+
79
+ roi = None
80
+ if bbox is not None:
81
+ begin, end = bbox[:3], bbox[3:]
82
+ roi = (begin, tuple(e - b for b, e in zip(begin, end)))
83
+
84
+ if block_mask is None and skip_empty:
85
+ total_roi = labels.array("r").roi
86
+ if roi is not None:
87
+ total_roi = total_roi.intersect(Roi(*roi))
88
+ click.echo(f"Scanning mip {mask_mip if mask_mip is not None else 'coarsest'} for empty blocks in {total_roi}...")
89
+ mask = compute_block_mask(labels, total_roi, Coordinate(*block_size), mask_mip=mask_mip, num_workers=num_workers)
90
+ block_mask = os.path.join(output_dir, "block_mask.npy")
91
+ np.save(block_mask, mask)
92
+ click.echo(f"{mask.sum():,} of {mask.size:,} blocks ({mask.mean():.0%}) contain labels; the rest will be skipped. Mask saved to {block_mask}")
93
+ elif block_mask is not None:
94
+ click.echo(f"Using block mask {block_mask}")
95
+
96
+ task = SamplePoints(
97
+ labels=labels,
98
+ svids=svids,
99
+ block_mask=block_mask,
100
+ block_size=np.array(block_size),
101
+ num_workers=num_workers,
102
+ out_dir=points_dir,
103
+ fraction=fraction,
104
+ worker_config=worker_config,
105
+ roi=roi,
106
+ )
107
+
108
+ click.echo("Running task...")
109
+ task.drop() # also deletes points_dir (see SamplePoints.drop_artifacts)
110
+ task.run_blockwise(multiprocessing=True)
111
+ click.echo("Consolidating worker indexes...")
112
+ consolidate_indexes(points_dir)
113
+ click.secho("✅ Done!", fg='green')
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: nps-cli
3
+ Version: 0.1.5
4
+ Summary: A simple CLI for sampling point clouds from large volumetric datasets
5
+ Author: Jakob Troidl
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: click
11
+ Requires-Dist: cloud-volume!=12.13.*
12
+ Requires-Dist: volara>=2.0.1
13
+ Requires-Dist: pocaduck
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Requires-Dist: black; extra == "dev"
17
+ Requires-Dist: build; extra == "dev"
18
+ Requires-Dist: twine; extra == "dev"
19
+ Requires-Dist: ruff; extra == "dev"
20
+
21
+ [![pypi](https://badge.fury.io/py/nps-cli.svg)](https://badge.fury.io/py/nps-cli)
22
+ [![Python Version](https://img.shields.io/pypi/pyversions/nps-cli.svg)](https://pypi.org/project/nps-cli/)
23
+
24
+ # nps: distributed point cloud sampling
25
+
26
+ `nps` samples point clouds of every segment in a large segmentation volumes
27
+ * Compatible with [CloudVolume](https://github.com/seung-lab/cloud-volume) (precomputed or graphene).
28
+ * It processes the volume blockwise on local, LSF or Slurm workers and writes the points in [pocaduck](https://github.com/JaneliaSciComp/pocaduck) format.
29
+
30
+ ```bash
31
+ pip install nps-cli
32
+ nps --help
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ # Whole volume
39
+ nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation
40
+
41
+ # Subvolume (begin_x begin_y begin_z end_x end_y end_z, in voxels), 1% of each segment
42
+ nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation \
43
+ --bbox 15347 19712 18606 15859 20224 19118 --fraction 0.01
44
+
45
+ # Also store the supervoxel ID of every point, looked up in a separate volume
46
+ # that shares the voxel grid of --cv-path
47
+ nps --cv-path precomputed://gs://<bucket>/agglomerated \
48
+ --svid-cv-path precomputed://gs://<bucket>/supervoxels
49
+
50
+ # Graphene: read supervoxels from the same volume (agglomerate=False)
51
+ nps --cv-path graphene://https://<server>/segmentation/table/<table> --sample_svids
52
+ ```
53
+
54
+ Key options (run `nps --help` for all of them):
55
+
56
+ | Option | Default | Meaning |
57
+ | --- | --- | --- |
58
+ | `--fraction` | `0.001` | Fraction of each segment's voxels to sample (at least 1 per segment and block). |
59
+ | `--mip` | `0` | Resolution level to sample at. Coordinates are voxels of this mip. |
60
+ | `--bbox` | whole volume | ROI in voxels. |
61
+ | `--block-size` | `128 128 128` | Block size per task, in voxels. |
62
+ | `--worker-type` / `--num-workers` / `--cpus-per-worker` / `--queue` | `LocalWorker` / `8` / `4` / `local` | Where and how the blocks are processed. |
63
+ | `--fill-missing` | off | Treat missing chunks as empty instead of raising an error. |
64
+ | `--overwrite` | off | Delete points of an earlier run in `--output-dir` instead of refusing to start. |
65
+ | `--skip-empty` / `--block-mask` / `--mask-mip` | on | Skipping empty blocks, see below. |
66
+
67
+ ### Output
68
+
69
+ ```
70
+ <output-dir>/
71
+ block_mask.npy # occupancy mask from the empty-block pre-scan
72
+ points/
73
+ unified_index.db # pocaduck index over all workers
74
+ worker_<id>/... # parquet files + per-worker index
75
+ ```
76
+
77
+ Each point is `[x, y, z]`, or `[x, y, z, svid]` when SVIDs are sampled, stored under its segment label.
78
+
79
+ `nps` refuses to write into an `--output-dir` that already holds points, since every
80
+ run re-samples all blocks and old points would show up as duplicates. Pass
81
+ `--overwrite` to delete them first.
82
+
83
+ ### Skipping empty blocks
84
+
85
+ Before sampling, `nps` scans the ROI at a coarse mip (`--mask-mip`, default the
86
+ coarsest one) and never dispatches blocks without labels. The mask is dilated by
87
+ one block, so thin structures lost in downsampling are still sampled. Use
88
+ `--no-skip-empty` to disable the scan, or `--block-mask <path>` to reuse a
89
+ `block_mask.npy` from an earlier run with the same `--bbox`, `--block-size` and `--mip`.
90
+
91
+ ## Reading point clouds
92
+
93
+ ```python
94
+ from pocaduck import Query, StorageConfig
95
+
96
+ query = Query(storage_config=StorageConfig(base_path="<output-dir>/points"))
97
+ labels = query.get_labels()
98
+ points = query.get_points(label=12345) # all points of one segment
99
+ query.close()
100
+ ```
101
+
102
+ See the [pocaduck docs](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#querying-point-clouds)
103
+ for more, including [optimizing](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#running-the-optimization-pipeline) the storage for fast reads.
104
+
105
+ ## Example scripts
106
+
107
+ | Script | What it does |
108
+ | --- | --- |
109
+ | [`liconn_local.sh`](scripts/liconn_local.sh) | Points only, local workers |
110
+ | [`liconn_svids_local.sh`](scripts/liconn_svids_local.sh) | Points + SVIDs from a separate precomputed volume |
111
+ | [`flywire_cluster.sh`](scripts/flywire_cluster.sh) | Points + SVIDs for FlyWire on LSF (`LABEL_NAME=flywire_v141_m783` for the 2023 snapshot) |
112
+
113
+ ## Release
114
+
115
+ ```bash
116
+ pip install -e ".[dev]"
117
+ python -m build
118
+ twine upload dist/*
119
+ ```
@@ -2,6 +2,9 @@ README.md
2
2
  pyproject.toml
3
3
  nps/__init__.py
4
4
  nps/main.py
5
+ nps/blockwise/__init__.py
6
+ nps/blockwise/block_mask.py
7
+ nps/blockwise/sample_points.py
5
8
  nps_cli.egg-info/PKG-INFO
6
9
  nps_cli.egg-info/SOURCES.txt
7
10
  nps_cli.egg-info/dependency_links.txt
@@ -1,5 +1,6 @@
1
1
  click
2
- volara
2
+ cloud-volume!=12.13.*
3
+ volara>=2.0.1
3
4
  pocaduck
4
5
 
5
6
  [dev]
@@ -0,0 +1,2 @@
1
+ nps
2
+ nps_output
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nps-cli"
7
- version = "0.1.2"
7
+ version = "0.1.5"
8
8
  description = "A simple CLI for sampling point clouds from large volumetric datasets"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.11"
@@ -12,7 +12,11 @@ license = "MIT"
12
12
  authors = [{ name = "Jakob Troidl" }]
13
13
  dependencies = [
14
14
  "click",
15
- "volara",
15
+ # 12.13.x passes the nominal chunk size instead of the real cutout size to
16
+ # the compressed_segmentation decoder for sharded chunks; partial chunks at
17
+ # the volume edge then segfault (fixed again in 12.14).
18
+ "cloud-volume!=12.13.*",
19
+ "volara>=2.0.1",
16
20
  "pocaduck",
17
21
  ]
18
22
 
@@ -36,7 +40,7 @@ sample-points = "nps.blockwise:SamplePoints"
36
40
  nps = "nps.main:main"
37
41
 
38
42
  [tool.setuptools.packages.find]
39
- include = ["nps"]
43
+ include = ["nps*"]
40
44
 
41
45
  [tool.ruff]
42
46
  exclude = ["dist", "build", "nps.egg-info", "volara_logs", "nps_output"]
nps_cli-0.1.2/PKG-INFO DELETED
@@ -1,100 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: nps-cli
3
- Version: 0.1.2
4
- Summary: A simple CLI for sampling point clouds from large volumetric datasets
5
- Author: Jakob Troidl
6
- License-Expression: MIT
7
- Classifier: Programming Language :: Python :: 3.11
8
- Requires-Python: >=3.11
9
- Description-Content-Type: text/markdown
10
- Requires-Dist: click
11
- Requires-Dist: volara
12
- Requires-Dist: pocaduck
13
- Provides-Extra: dev
14
- Requires-Dist: pytest; extra == "dev"
15
- Requires-Dist: black; extra == "dev"
16
- Requires-Dist: build; extra == "dev"
17
- Requires-Dist: twine; extra == "dev"
18
- Requires-Dist: ruff; extra == "dev"
19
-
20
- [![pypi](https://badge.fury.io/py/nps-cli.svg)](https://badge.fury.io/py/nps-cli)
21
- [![Python Version](https://img.shields.io/pypi/pyversions/nps-cli.svg)](https://pypi.org/project/nps-cli/)
22
-
23
- # CLI for Distributed Point Cloud Sampling
24
-
25
- ```
26
- pip install nps-cli
27
- ````
28
- ### Get started
29
-
30
- ```zsh
31
- nps --help
32
- Usage: nps [OPTIONS]
33
-
34
- Options:
35
- --cv-path TEXT Path to CloudVolume data. [required]
36
- --mip INTEGER MIP level to use. [default: 0]
37
- --timestamp INTEGER Optional timestamp for the dataset version
38
- (graphene only).
39
- --sample_svids Sample SVIDs in addition to points (default:
40
- False) Graphene only.
41
- -o, --output-dir DIRECTORY Output directory. [default: ./nps_output]
42
- --worker-type [LocalWorker|LSFWorker|SlurmWorker]
43
- Type of worker to use for sampling.
44
- [default: LocalWorker]
45
- --num-workers INTEGER Number of workers for blockwise sampling.
46
- [default: 8]
47
- --cpus-per-worker INTEGER Number of CPUs per worker. [default: 4]
48
- --queue TEXT Queue name (for LSF backend). [default:
49
- local]
50
- --fraction FLOAT Fraction of points to sample [0.0, 1.0].
51
- [default: 0.001]
52
- --bbox INTEGER... Bounding box: begin_x begin_y begin_z
53
- end_x end_y end_z (in voxels).
54
- --block-size INTEGER... Block size in voxels (X Y Z). [default:
55
- 128, 128, 128]
56
- -h, --help Show this message and exit.
57
- ```
58
-
59
- ### Example usage
60
-
61
- ```bash
62
- nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation
63
- ```
64
-
65
- Sample point clouds within a FlyEM Hemibrain subvolume:
66
-
67
- ```bash
68
- nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation --bbox 15347 19712 18606 15859 20224 19118 --fraction 0.01
69
- ```
70
-
71
- ### Reading Point Clouds
72
-
73
- Please refer to the [pocaduck](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#querying-point-clouds) repo on how to read point clouds from the output directory:
74
-
75
- ```python
76
- from pocaduck import Query
77
-
78
- # Create a query object
79
- query = Query(storage_config=<PATH>) # path to folder where nps output is stored
80
-
81
- # Get all available labels
82
- labels = query.get_labels()
83
- print(f"Available labels: {labels}")
84
-
85
- # Get all points for a label (aggregated across all blocks)
86
- points = query.get_points(label=12345)
87
- print(f"Retrieved {points.shape[0]} points for label 12345")
88
-
89
- # Close the query connection when done
90
- query.close()
91
- ```
92
-
93
- For optimized point cloud reading, consider [this](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#running-the-optimization-pipeline).
94
-
95
-
96
- ### Deploy
97
- ```python
98
- python -m build
99
- twine upload dist/*
100
- ```
nps_cli-0.1.2/README.md DELETED
@@ -1,81 +0,0 @@
1
- [![pypi](https://badge.fury.io/py/nps-cli.svg)](https://badge.fury.io/py/nps-cli)
2
- [![Python Version](https://img.shields.io/pypi/pyversions/nps-cli.svg)](https://pypi.org/project/nps-cli/)
3
-
4
- # CLI for Distributed Point Cloud Sampling
5
-
6
- ```
7
- pip install nps-cli
8
- ````
9
- ### Get started
10
-
11
- ```zsh
12
- nps --help
13
- Usage: nps [OPTIONS]
14
-
15
- Options:
16
- --cv-path TEXT Path to CloudVolume data. [required]
17
- --mip INTEGER MIP level to use. [default: 0]
18
- --timestamp INTEGER Optional timestamp for the dataset version
19
- (graphene only).
20
- --sample_svids Sample SVIDs in addition to points (default:
21
- False) Graphene only.
22
- -o, --output-dir DIRECTORY Output directory. [default: ./nps_output]
23
- --worker-type [LocalWorker|LSFWorker|SlurmWorker]
24
- Type of worker to use for sampling.
25
- [default: LocalWorker]
26
- --num-workers INTEGER Number of workers for blockwise sampling.
27
- [default: 8]
28
- --cpus-per-worker INTEGER Number of CPUs per worker. [default: 4]
29
- --queue TEXT Queue name (for LSF backend). [default:
30
- local]
31
- --fraction FLOAT Fraction of points to sample [0.0, 1.0].
32
- [default: 0.001]
33
- --bbox INTEGER... Bounding box: begin_x begin_y begin_z
34
- end_x end_y end_z (in voxels).
35
- --block-size INTEGER... Block size in voxels (X Y Z). [default:
36
- 128, 128, 128]
37
- -h, --help Show this message and exit.
38
- ```
39
-
40
- ### Example usage
41
-
42
- ```bash
43
- nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation
44
- ```
45
-
46
- Sample point clouds within a FlyEM Hemibrain subvolume:
47
-
48
- ```bash
49
- nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation --bbox 15347 19712 18606 15859 20224 19118 --fraction 0.01
50
- ```
51
-
52
- ### Reading Point Clouds
53
-
54
- Please refer to the [pocaduck](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#querying-point-clouds) repo on how to read point clouds from the output directory:
55
-
56
- ```python
57
- from pocaduck import Query
58
-
59
- # Create a query object
60
- query = Query(storage_config=<PATH>) # path to folder where nps output is stored
61
-
62
- # Get all available labels
63
- labels = query.get_labels()
64
- print(f"Available labels: {labels}")
65
-
66
- # Get all points for a label (aggregated across all blocks)
67
- points = query.get_points(label=12345)
68
- print(f"Retrieved {points.shape[0]} points for label 12345")
69
-
70
- # Close the query connection when done
71
- query.close()
72
- ```
73
-
74
- For optimized point cloud reading, consider [this](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#running-the-optimization-pipeline).
75
-
76
-
77
- ### Deploy
78
- ```python
79
- python -m build
80
- twine upload dist/*
81
- ```
nps_cli-0.1.2/nps/main.py DELETED
@@ -1,77 +0,0 @@
1
- import os
2
- import numpy as np
3
- import click
4
- import time
5
-
6
- from nps.blockwise.sample_points import SamplePoints
7
- from volara.datasets import CloudVolumeWrapper
8
- from volara.workers import LSFWorker, LocalWorker, SlurmWorker
9
-
10
- @click.command(context_settings=dict(help_option_names=['-h', '--help']))
11
- @click.option('--cv-path', required=True, help='Path to CloudVolume data.')
12
- @click.option('--mip', default=0, type=int, show_default=True, help='MIP level to use.')
13
- @click.option('--timestamp', default=int(time.time()), help='Optional timestamp for the dataset version (graphene only).')
14
- @click.option('--sample_svids', is_flag=True, default=False, help='Sample SVIDs in addition to points (default: False).')
15
- @click.option('--output-dir', '-o', default='./nps_output', show_default=True, type=click.Path(file_okay=False, writable=True), help='Output directory.')
16
- @click.option('--worker-type', default='LocalWorker', show_default=True, type=click.Choice(['LocalWorker', 'LSFWorker', 'SlurmWorker'], case_sensitive=True), help='Type of worker to use for sampling.')
17
- @click.option('--num-workers', default=8, show_default=True, type=int, help='Number of workers for blockwise sampling.')
18
- @click.option('--cpus-per-worker', default=4, show_default=True, type=int, help='Number of CPUs per worker.')
19
- @click.option('--queue', default='local', show_default=True, help='Queue name (for LSF backend).')
20
- @click.option('--fraction', default=0.001, show_default=True, type=float, help='Fraction of points to sample [0.0, 1.0].')
21
- @click.option('--block-size', nargs=3, type=int, default=(128, 128, 128), show_default=True, help='Block size in voxels (X Y Z).')
22
- @click.option('--bbox', nargs=6, type=int, default=None, help='Bounding box: begin_x begin_y begin_z end_x end_y end_z (in voxels).')
23
- def main(cv_path, mip, timestamp, output_dir, num_workers, cpus_per_worker, queue, fraction, block_size, sample_svids, worker_type, bbox):
24
-
25
- click.echo(f"Reading CloudVolume at {cv_path} (mip={mip}, timestamp={timestamp})")
26
-
27
- labels_name = os.path.dirname(output_dir) + "_labels"
28
- labels = CloudVolumeWrapper(data_name=labels_name, store=cv_path, mip=mip, timestamp=timestamp)
29
-
30
- if sample_svids:
31
- click.echo("Sampling SVIDs in addition to points...")
32
- svids_name = os.path.dirname(output_dir) + "_svids"
33
- svids = CloudVolumeWrapper(data_name=svids_name, store=cv_path, mip=mip, timestamp=timestamp, agglomerate=False)
34
- else:
35
- click.echo("Sampling points only (no SVIDs)...")
36
- svids = None
37
-
38
- points_dir = os.path.join(output_dir, 'points')
39
- if not os.path.exists(points_dir):
40
- os.makedirs(points_dir)
41
-
42
- if worker_type == 'LocalWorker':
43
- click.echo("Using LocalWorker for sampling.")
44
- worker_config = LocalWorker()
45
- elif worker_type == 'LSFWorker':
46
- click.echo(f"Using LSFWorker with queue '{queue}' and {cpus_per_worker} CPUs per worker.")
47
- worker_config = LSFWorker(queue=queue, num_cpus=cpus_per_worker)
48
- elif worker_type == 'SlurmWorker':
49
- click.echo(f"Using SlurmWorker with queue '{queue}' and {cpus_per_worker} CPUs per worker.")
50
- worker_config = SlurmWorker(queue=queue, num_cpus=cpus_per_worker)
51
-
52
- roi = None
53
- if bbox is not None:
54
- begin = bbox[:3]
55
- end = bbox[3:]
56
- shape = tuple(e - b for b, e in zip(begin, end))
57
- roi = (begin, shape)
58
-
59
- task = SamplePoints(
60
- labels=labels,
61
- svids=svids,
62
- block_size=np.array(block_size),
63
- num_workers=num_workers,
64
- out_dir=points_dir,
65
- fraction=fraction,
66
- worker_config=worker_config,
67
- roi=roi,
68
- )
69
-
70
- click.echo("Running task...")
71
- task.drop()
72
- task.run_blockwise(multiprocessing=True)
73
- click.secho("✅ Done!", fg='green')
74
-
75
-
76
- if __name__ == "__main__":
77
- main()
@@ -1,100 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: nps-cli
3
- Version: 0.1.2
4
- Summary: A simple CLI for sampling point clouds from large volumetric datasets
5
- Author: Jakob Troidl
6
- License-Expression: MIT
7
- Classifier: Programming Language :: Python :: 3.11
8
- Requires-Python: >=3.11
9
- Description-Content-Type: text/markdown
10
- Requires-Dist: click
11
- Requires-Dist: volara
12
- Requires-Dist: pocaduck
13
- Provides-Extra: dev
14
- Requires-Dist: pytest; extra == "dev"
15
- Requires-Dist: black; extra == "dev"
16
- Requires-Dist: build; extra == "dev"
17
- Requires-Dist: twine; extra == "dev"
18
- Requires-Dist: ruff; extra == "dev"
19
-
20
- [![pypi](https://badge.fury.io/py/nps-cli.svg)](https://badge.fury.io/py/nps-cli)
21
- [![Python Version](https://img.shields.io/pypi/pyversions/nps-cli.svg)](https://pypi.org/project/nps-cli/)
22
-
23
- # CLI for Distributed Point Cloud Sampling
24
-
25
- ```
26
- pip install nps-cli
27
- ````
28
- ### Get started
29
-
30
- ```zsh
31
- nps --help
32
- Usage: nps [OPTIONS]
33
-
34
- Options:
35
- --cv-path TEXT Path to CloudVolume data. [required]
36
- --mip INTEGER MIP level to use. [default: 0]
37
- --timestamp INTEGER Optional timestamp for the dataset version
38
- (graphene only).
39
- --sample_svids Sample SVIDs in addition to points (default:
40
- False) Graphene only.
41
- -o, --output-dir DIRECTORY Output directory. [default: ./nps_output]
42
- --worker-type [LocalWorker|LSFWorker|SlurmWorker]
43
- Type of worker to use for sampling.
44
- [default: LocalWorker]
45
- --num-workers INTEGER Number of workers for blockwise sampling.
46
- [default: 8]
47
- --cpus-per-worker INTEGER Number of CPUs per worker. [default: 4]
48
- --queue TEXT Queue name (for LSF backend). [default:
49
- local]
50
- --fraction FLOAT Fraction of points to sample [0.0, 1.0].
51
- [default: 0.001]
52
- --bbox INTEGER... Bounding box: begin_x begin_y begin_z
53
- end_x end_y end_z (in voxels).
54
- --block-size INTEGER... Block size in voxels (X Y Z). [default:
55
- 128, 128, 128]
56
- -h, --help Show this message and exit.
57
- ```
58
-
59
- ### Example usage
60
-
61
- ```bash
62
- nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation
63
- ```
64
-
65
- Sample point clouds within a FlyEM Hemibrain subvolume:
66
-
67
- ```bash
68
- nps --cv-path precomputed://gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation --bbox 15347 19712 18606 15859 20224 19118 --fraction 0.01
69
- ```
70
-
71
- ### Reading Point Clouds
72
-
73
- Please refer to the [pocaduck](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#querying-point-clouds) repo on how to read point clouds from the output directory:
74
-
75
- ```python
76
- from pocaduck import Query
77
-
78
- # Create a query object
79
- query = Query(storage_config=<PATH>) # path to folder where nps output is stored
80
-
81
- # Get all available labels
82
- labels = query.get_labels()
83
- print(f"Available labels: {labels}")
84
-
85
- # Get all points for a label (aggregated across all blocks)
86
- points = query.get_points(label=12345)
87
- print(f"Retrieved {points.shape[0]} points for label 12345")
88
-
89
- # Close the query connection when done
90
- query.close()
91
- ```
92
-
93
- For optimized point cloud reading, consider [this](https://github.com/JaneliaSciComp/pocaduck?tab=readme-ov-file#running-the-optimization-pipeline).
94
-
95
-
96
- ### Deploy
97
- ```python
98
- python -m build
99
- twine upload dist/*
100
- ```
@@ -1 +0,0 @@
1
- nps
File without changes
File without changes