cuvis-ai-dataloader
Pluggable hyperspectral DataModules for the cuvis-ai ecosystem.
Overview
cuvis-ai-dataloader ships the concrete hyperspectral DataModules for the
Cuvis.AI ecosystem. A
DataModule is the unit the framework uses for both training and inference: it
bundles the data, the labels, the splits, and the train / val / test /
predict dataloaders.
This single plugin holds every concrete loader, with per-format heavy deps gated
behind optional extras. The cuvis SDK lives only here, behind [cu3s]; no
other Cuvis.AI repo pins it.
Module (data_module_name) |
Reads | Labels | Extra |
|---|---|---|---|
cu3s |
one .cu3s session (or a folder of them) via cuvis |
COCO JSON | [cu3s, coco] |
cu3s_multi |
many .cu3s, one frame per CSV row |
per-day COCO JSON | [cu3s, coco] |
npz_multi |
many .npz, one frame per CSV row |
baked mask array |
none |
tiff_paired |
a folder of *.tif / *.tiff cubes via tifffile |
paired PNG | [tiff] |
Key points:
- One plugin, three DataModules, per-format heavy deps behind extras
- The
cuvisSDK is isolated here, behind[cu3s] - Composable split selectors over an attributed sample universe
cuvis/tifffile/pycocotoolsimport lazily, so the plugin registers with any subset of extras
Splits
Splits are defined in one of two ways:
- Selectors (
splits.json) is the general mechanism, shared by every module. Composable selectors over an attributed sample universe are resolved into a committablesplits.jsonby theresolve-splitsCLI, then referenced from aDataConfig.splits. - One
universe.csvvocabulary (source, index+ optionalmaterialized_path, split, annotation, format, group) is read by bothcu3s_multiandnpz_multithrough a shared parser; each module keeps its own reader.cu3s_multimay carry an inlinesplitcolumn (present → module-owned; absent → needs asplits.json), andresolve-splits --from-csvturns that column into a committablesplits.json.npz_multiis selector-only (it rejects asplitcolumn) and requiresmaterialized_path(the.npz); forcu3s_multi,materialized_pathdefaults tosource(a raw.cu3sis its own file).sourceis the posix identity asplits.jsonselector keys on, so one split resolves against both the raw cu3s data and the converted npz.
Installation
uv pip install "cuvis-ai-dataloader[cu3s,coco]" # cu3s + COCO
uv pip install "cuvis-ai-dataloader[tiff]" # TIFF + paired PNG
uv pip install "cuvis-ai-dataloader[all]" # every format
Extras:
cu3s:.cu3ssession reading via thecuvisSDK bindingcoco: COCO-JSON mask labels (pycocotools,scikit-image)tiff: TIFF cube reading (tifffile)all: All formatsbench: Plotting and process-memory deps for the scripts underbenchmarks/dev: Development dependencies
The cu3s extra requires cuvis 3.6.0.0, which pulls the matching cuvis-il.
Cuvis SDK (system install, required for cu3s)
The [cu3s] extra installs the cuvis binding, but that binding needs the system-wide
C++ Cuvis SDK at a matching version (3.6.0) too, or any .cu3s read fails at import with
DLL load failed while importing _cuvis_pyil. See the
Cuvis.AI installation guide for OS
support (Windows / Linux; not macOS), the SDK download, and verification. Quick check once
installed:
uv run python -c "import cuvis; print(cuvis.version())"
Usage
Inference (restore-pipeline) selects a module and its params on the CLI:
restore-pipeline \
--pipeline-path X.yaml \
--plugins-dir <this-repo>/configs/plugins \
--data-module cu3s \
--data-arg cu3s_file_path=X.cu3s \
--data-arg annotation_json_path=Y.json
Training (Train / RestoreTrainRun) selects the same module via the yaml
DataConfig:
data:
data_module: cu3s
splits:
train:
- { kind: file_indices, source: X.cu3s, ids: [0, 2, 3] }
val:
- { kind: file_indices, source: X.cu3s, ids: [1, 5] }
batch_size: 4
params:
cu3s_file_path: X.cu3s
annotation_json_path: Y.json
processing_mode: Reflectance
In-process / notebooks construct the DataModule directly and run it through
the Predictor:
from cuvis_ai_dataloader.data import Cu3sDataModule
from cuvis_ai_core.training import Predictor
from cuvis_ai_core.utils.restore import restore_pipeline
pipeline = restore_pipeline("X.yaml", plugins_dirs=[...])
dm = Cu3sDataModule(cu3s_file_path="X.cu3s", batch_size=1)
Predictor(pipeline, dm).predict()
Threaded cu3s reading (read_threads)
Reading a cu3s frame is dominated by the SDK, not by Python: about 78 ms per cube on a warm
file cache, almost none of it under the interpreter.
read_threads reads a batch on several SessionFile handles at once, all sharing one
ProcessingContext, which is the only topology measured to produce correct cubes.
It is off by default.
data:
data_module: cu3s
batch_size: 8 # the lever: concurrency is bounded by the batch
num_workers: 0 # required; reader threads replace worker processes
params:
cu3s_file_path: X.cu3s
read_threads: 8
Measured on one 940-frame session, CUDA Raw mode, 20 cores and an RTX 4070, SDK 3.6.0, median of five passes per cell (full evidence):
| read_threads | fps | RSS GB |
|---|---|---|
| 1 | 12.9 | 2.7 |
| 2 | 25.6 | 4.5 |
| 4 | 40.7 | 5.2 |
| 6 | 50.7 | 5.7 |
| 8 | 52.6 | 6.3 |
| 16 | 50.1 | 8.5 |
Eight is the knee and, on this SDK build, also the peak: twelve and sixteen are slower, not faster. Six reaches 96% of eight for 91% of its memory. Budget roughly 0.38 GB of RSS per handle, and re-measure the thread count on your own machine rather than copying this table.
Things worth knowing before turning it on:
- It only helps on the GPU. On the SDK's host device, eight handles buy almost nothing.
Leave
sdk_cudaon (its default); see SDK processing device below. - It needs a cuvis binding that releases the GIL. The published
cuvis-il3.6.0.0 wheels do; every wheel before them held it. On a binding that holds it, extra threads are a small loss rather than a gain, so the reader probes the binding once per process and falls back to single-threaded reads with a warning. - Concurrency equals
batch_size. torch hands a dataset a whole batch of indices at once and nothing earlier, sobatch_size: 1gets no speedup fromread_threadsalone;read_aheadis the lever there, and the module warns when threads are configured at batch 1 without it. num_workersmust be 0. Worker processes each build their own sessions and their own processing context, so combining the two multiplies both; the module raises instead.samples_per_frame > 1disables it for the train loader only. The repeat wrapper incuvis-ai-coredoes not forward the batched fetch. Validation, test and predict are unaffected.- Host processing mode barely benefits. There the lever is
processing_thread_countin the SDK settings, not this parameter. - Multi-file divides the budget across recordings unless batches are source-coherent. Without
source_coherent_batches,read_threadsis divided by the sessions the cache holds open (max_open_sessions, default 4), so the handle count stays flat;read_threads: 4over four or more recordings then leaves one thread per recording, only batches spanning several recordings overlap, and the reader warns about the split.source_coherent_batches: truekeeps each batch inside one recording, so the cache stops evicting mid-batch and every recording gets the wholeread_threadsinside itself, at a cost of up tomax_open_sessions x read_threadsopen handles (about 0.38 GB of RSS each): pair it withmax_open_sessions: 2. It changes which samples share a batch and replaces the loader's sampler, so it cannot be used under DDP.
The npz converter takes the same parameter and needs no batch size, since it already knows every index it will read:
cu3s-to-npz --cu3s X.cu3s --out-dir out --annotations sibling --read-threads 8
Read-ahead at batch 1 (read_ahead)
read_threads overlaps the reads inside one batch, so a batch_size: 1 loader (the CuvisNEXT
training wizard's) gains nothing from it: torch asks the dataset for one frame at a time and
Lightning never prefetches for a sized loader, so the SDK read of frame i+1 waits for the model
step on frame i. read_ahead closes that gap. The module owns the loader's batch sampler,
learns the epoch's order the moment torch draws it, and keeps up to read_ahead frames of that
order in flight on reader threads while the model works on the current one. Off by default.
data:
data_module: cu3s
batch_size: 1
num_workers: 0 # required; the read-ahead runs on reader threads in this process
params:
cu3s_file_path: X.cu3s
read_ahead: 1 # frames in flight ahead of the model step
What it costs and what to know:
- One whole cube per frame in flight. A 1000x1080x61 Reflectance cube is about 264 MB, in
host memory, or in device memory with
cuda_cubes. Start at 1: on the CuvisNEXT wizard trainrun one frame in flight hid most of the SDK read (training step 0.24 to 0.165 s) at no extra device memory, and all of it withcuda_cubes(0.128 s at depth 1 and 2 alike). A depth of 2 took the host-cube step to 0.151 s, worth 2 percent of that run because its validation pass dominates each epoch, and opened four more handles. Raise the depth where the training pass is what you are shortening, or where the read is longer than the model step. The cap is 8. - Handles follow the depth, not
read_threads. A read-ahead of depth k runs on a pool k threads wide per recording (read_threadsstill wins when it is larger), so depth 1 opens no extra handle and depth 2 one more (about 0.38 GB of RSS each). - The order is exactly torch's. With
shuffle=Truethe sampler draws the same permutation a plain loader would for the same seed, so a run reproduces frame for frame withread_aheadon or off. - A consumer that leaves the announced order is served synchronously. A
__getitem__caller or an out-of-order fetch makes the plan stop looking ahead for that epoch and say so once; no frame is ever handed out for the wrong index. An iterator dropped mid-epoch (Lightning's two-batch sanity check, an early stop, a statistical pass that stops after its first frames) releases the frames read ahead of it the moment torch lets go of the iterator, so at mostread_aheadreads are wasted and nothing lingers in memory until the next epoch. A reader with reads in flight is never evicted from the cache, and the cache shrinks back tomax_open_sessionson its next access once those reads land. - With
cuda_cubes, the reading thread synchronizes the device before handing a cube over. The SDK's DLPack export carries no stream, so a device-widetorch.cuda.synchronizeon the reader thread is what orders the SDK's writes against the model's stream; the read-ahead hides its latency. Provisional until the SDK offers an event for its buffers. num_workersmust be 0, for the same reason asread_threads; the module raises.samples_per_frame > 1disables it for the train loader only (the repeat wrapper incuvis-ai-coreforwards neither the batched fetch nor the order); the loader warns.- Not under DDP. It replaces the loader's sampler, like
source_coherent_batches, with which it composes. - Where the time goes. On the CuvisNEXT wizard trainrun (RTX 5070 Ti, 1000x1080x61
Reflectance, batch 1) a training step took 0.24 s, of which the SDK read was about 0.10 s and
the cube's host round trip 0.06 s; the read-ahead overlaps the first,
cuda_cubesremoves the second. Measured:read_ahead: 1brings the step to 0.165 s and the wizard's preset run (max_epochs: 20, early stopping on pixel AUROC; both runs stopped after 7 epochs) from 631 to 488 s at no extra device memory; withcuda_cubeson top the step is 0.128 s. The numbers are in the 0.8.0 changelog entry.
SDK processing device (sdk_cuda)
The cuvis SDK can process cubes on the GPU or on the host CPU. This package asks for the GPU,
which is what sdk_cuda: true, the default, means. Turn it off to run on the host.
data:
data_module: cu3s
params:
cu3s_file_path: X.cu3s
sdk_cuda: false # default true
cu3s-to-npz takes --no-sdk-cuda for the same thing.
On one 940-frame session, Raw mode, RTX 4070
(full evidence):
| read_threads | GPU | host | ratio |
|---|---|---|---|
| 1 | 16.8 | 3.1 | 5.4x |
| 8 | 49.3 | 3.7 | 13.5x |
- The flag exists because SDK 3.6.0 changed the default. A process that never calls
cuvis.initnow processes on the host. Nothing in this package called it, so the upgrade would otherwise have moved every cu3s read onto the CPU silently. read_threadsis a GPU-only lever. On the host the 1-to-8 sweep stays flat at about 3 cubes/s, against 2.9x on the GPU. The two parameters are not independent.- A machine without CUDA needs no change. The SDK falls back to the host on its own, so the default costs nothing there.
- The device barely changes the cube, by one LSB on 0.0001% of elements and no more than one LSB anywhere on the measured session: rounding in the cubalize interpolation, well under sensor noise.
- The first
cuvis.initin a process wins. The SDK fixes its device there and silently ignores every later one, returning success. So a host application that already initialized the SDK keeps whatever device it chose, and two DataModules disagreeing in one process is a warning rather than a second switch. This package initializes at DataModule construction and again in each DataLoader worker, which is a fresh process that has initialized nothing.
Device-resident cubes (cuda_cubes)
A cube processed on the GPU is copied into host memory and its device copy freed, only for
torch to copy it straight back for training. cuda_cubes keeps it where it already is and
hands the batch a zero-copy CUDA tensor. Off by default, because it changes what a batch
contains.
data:
data_module: cu3s
batch_size: 8
num_workers: 0 # required
params:
cu3s_file_path: X.cu3s
read_threads: 8
cuda_cubes: true # default false
Cubes per second delivered onto the GPU, one 940-frame session, Raw, RTX 4070
(full evidence):
| read_threads | cuda_cubes: true |
cuda_cubes: false |
ratio |
|---|---|---|---|
| 1 | 24.1 | 15.5 | 1.55x |
| 4 | 74.4 | 34.3 | 2.17x |
| 8 | 84.1 | 37.9 | 2.22x |
batch["cube"]becomes a CUDA tensor instead of a host one. It is the same cube, bit for bit, on the same device torch would have put it on; what changes is that nothing downstream should call.cuda()on it, and anything calling.numpy()on it will now fail.- The gain grows with
read_threads, because the copy is a shared resource that reader threads queue behind. The two parameters compound. num_workersmust be 0 andsdk_cudamust be on; the module raises rather than demoting either.- It turns itself off when the SDK or the device cannot support it, with a warning, and reads through host memory instead.
- It needs
cuvis3.6.0.0, which thecu3sextra requires. The 3.6.0.0rc1 wrapper's device-buffer path raised on first use (fixed upstream in cuvis.python#98), andcuvis.cuda.capabilities()does not catch that, since it probes the native symbols rather than the Python layer over them. - A reader opened with
cuda_cubes: falsein a process that already switched to device cubes copies each cube back to host memory and warns once, since the SDK's switch is process-wide and one-way; its callers keep getting numpy.
NPZ (npz_multi)
npz_multi loads one frame per compressed .npz, selected by a splits.json over a
universe_csv (a universe.csv). It needs no extras (numpy is a core dep) and no Cuvis SDK. Each .npz carries:
cube:[H, W, C]float32wavelengths:[C](cast to int32)mask(optional):[H, W]int32 ground truth (zeros are emitted when absent)class_mask(optional):[H, W]uint8 per-pixel COCO category id (0 = background)
The universe_csv requires source, index plus materialized_path (the .npz, required for npz;
optional annotation, format, group; extra columns are ignored); materialized_path is relative
to the CSV and must not escape it via ... A split column is rejected here (npz is
selector-only). Each sample is
{cube, mask, class_mask, wavelengths, mesu_index, frame_id}. Unlike the cu3s modules, npz_multi
honors pin_memory / persistent_workers / worker_multiprocessing_context (pure-CPU numpy loads
benefit from them).
from cuvis_ai_dataloader.data import MultiNpzDataModule
from cuvis_ai_schemas.training.data import DataSplitConfig, Selector, SelectorKind
splits = DataSplitConfig(
train=[Selector(kind=SelectorKind.FILE_INDICES, source="X.cu3s", ids=[0, 2, 3])],
val=[Selector(kind=SelectorKind.FILE_INDICES, source="X.cu3s", ids=[1, 5])],
)
dm = MultiNpzDataModule(splits=splits, universe_csv="universe.csv", batch_size=4, num_workers=4)
dm.setup("fit")
batch = next(iter(dm.train_dataloader())) # cube [B,H,W,C], mask [B,H,W], ...
In a DataConfig (training / restore-trainrun):
data:
data_module: npz_multi
batch_size: 4
splits:
train:
- { kind: file_indices, source: X.cu3s, ids: [0, 2, 3] }
val:
- { kind: file_indices, source: X.cu3s, ids: [1, 5] }
params:
universe_csv: universe.csv
GUI-authored splits over a cu3s folder (contract)
External split authors (e.g. the CuvisNEXT split designer) write a frozen splits.json
(a serialized DataSplitConfig with file_indices selectors) against a folder of cu3s
files with per-measurement granularity. That contract is cu3s folder mode with
frames: measurements:
data:
data_module: cu3s
batch_size: 1
num_workers: 0
splits:
splits_path: <absolute path to the frozen splits.json>
params:
files: # the recordings to use, when the author knows them
- <absolute path to a .cu3s>
data_dir: <folder holding the .cu3s files>
frames: measurements
recursive: true # walk per-day subfolders (fallback only)
processing_mode: Reflectance
The frozen rules both sides implement:
- Universe = the recordings the run actually uses, one sample per measurement
0..N-1, ordered by(source, index). Which recordings those are is answered in this order: thefileslist when given (nothing is walked, and the list may point outsidedata_dir, e.g. a split spanning two drives); otherwise the sources the split's selectors name, when every selector names its sources (files/file_indices, or set operations over those); otherwise every*.cu3sunderdata_dir, recursive whenrecursive: true. A positional or attribute-driven selector (dir_indices,stems,glob,tag,categories,all) can only be answered by the full universe, so it keeps the walk. Enumeration opens each recording once for its measurement count only (no processing context), so a folder holding recordings the split does not name costs nothing. - Source identity is canonical: the absolute path with forward slashes and
filesystem-true case — Python
Path(p).resolve().as_posix(), C++/QtQFileInfo::canonicalFilePath(). Selectors in the authoredsplits.jsonmust carry exactly this form; a moved or renamed member file fails loud, naming the recording it could not read, rather than silently shrinking a split. Sources reached only through a set operation may be absent (except(files[a], files[gone])is legitimate, and core resolves a set operation's operands without its zero-match check). - An empty
predictstage serves the whole universe, which with an explicitfileslist or a fully source-naming split means the recordings that split uses, not the whole folder. uid=<source>#<index>(the sibling COCO image id equals the read position, so it never extends the uid).universe_hash= sha256 over the ordered uids, each followed by\n(cuvis_ai_core.data.splits_io.universe_hash). Forfile_indicessplits the server treats the hash as informational (only positionaldir_indicessplits are hash-verified); staleness detection is the author's concern.- Annotations are the sibling
<stem>.jsonCOCO next to each cu3s (attached automatically). A recording without one is label-free: every frame of it carries an all-zero mask and thenormaltag, andsetuplogs one WARNING per val or test stage that holds such a recording, since its frames score as normal there. An emptypredictstage means the whole universe. - Training stages require splits.
cu3sdoes not own split semantics:fit/validate/testwith noDataConfig.splitsraise instead of silently iterating the whole universe (which would contaminate statistical initialization with anomalous frames). Split-lesspredictover the whole universe stays valid.
The golden fixture tests/cuvis_ai_dataloader/fixtures/gui_authored_splits.json is the
byte-level reference of the authored shape (the {DATA_DIR} token stands in for the
machine-specific folder); the same file is committed in the CuvisNEXT test suite and its
universe_hash doubles as the shared sha256 test vector. Changing it is a cross-repo
contract change.
Architecture
Concrete DataModules subclass cuvis_ai_core.data.datamodule.BaseCuvisAIDataModule
and implement validate_params plus the selector hooks enumerate(required_attrs)
(the module's attributed sample universe) and build_dataset_from_refs(refs);
a module that owns its own splits also implements build_stage_dataset(stage).
Per-format cube readers and labelers are internal helpers (data/readers/,
data/labelers/), reused but not a plugin contract. Module-top imports stay free
of heavy deps; cuvis / tifffile / pycocotools / scikit-image load lazily
on first use (data/_extras.py).
Development
uv sync --extra dev
uv run pytest tests/ -v
uv run ruff check cuvis_ai_dataloader/ tests/
uv run ruff format cuvis_ai_dataloader/ tests/
uv run mypy cuvis_ai_dataloader/
Git hooks
Enable the repo's hooks once per clone:
git config core.hooksPath .githooks
- pre-commit:
ruff format+ruff check --fixon staged Python, then re-stages. - pre-push:
ruff format --check,ruff check, docstring coverage (uvx interrogate, ≥95%, configured in[tool.interrogate]), andpytest -m "not slow and not gpu".
Skip a hook for one command with --no-verify.
Contributing
Contributions are welcome. Please:
- Ensure tests pass
- Run ruff format and ruff check
- Keep type hints and update docs as needed
License
Licensed under the Apache License 2.0. See LICENSE for details.
Metadata
Release files for cuvis-ai-dataloader 0.8.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cuvis_ai_dataloader-0.8.1.tar.gz | 271.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cuvis_ai_dataloader-0.8.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 367.3 kB
Release files / cuvis_ai_dataloader-0.8.1.tar.gz
| Download URL | cuvis_ai_dataloader-0.8.1.tar.gz |
|---|---|
| Size | 271.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e27c9fba825d6980bb7ade01f04b2d8d270674032e7a26e059a4f88317bde573
|
|
BLAKE2b-256 checksum How to use checksums |
22fef269448039b3ec7da26e70892c4ccb9177a0df0100fb4ebb8a36bdb6b925
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / cuvis_ai_dataloader-0.8.1-py3-none-any.whl
| Download URL | cuvis_ai_dataloader-0.8.1-py3-none-any.whl |
|---|---|
| Size | 95.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ceeb846e2810674cfbe02ebe528fc364bd72220b1ae50bd1d58cf0e063c8e6e9
|
|
BLAKE2b-256 checksum How to use checksums |
993d4292be0abaff4b38c5c5f9330913a2d11658de2b5208302e8a833ca006ae
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log