niiflow-preproc
Configurable neuroimaging workflows for offline image preprocessing tasks (and more).
niiflow-preproc turns a single YAML/JSON config into a reproducible, parallel processing
run over many images, while keeping the heavy/optional scientific dependencies (ANTs, ANTsPyNet,
DuckDB) isolated from the training stack (niiflow-train).
Key features include:
- Building pipelines dynamically from a series of steps — bias-field
correction, skull-stripping, registration, resampling, intensity normalization, QC,
and more — wiring steps together with a shared context and gating any step with an
enableflag (e.g. probe a QC result to the rest of the pipeline). - Exploring active files dynamically with the
nifti-finderbackend: glob patterns plus composable filters (e.g., metadata value from table, companion-file existence, AND/OR logic) to select exactly the images you want (e.g. healthy T1w–FLAIR pairs in a BIDS dataset). - Staging additional inputs/outputs around each active file: search for companion
files near an anchor and construct output paths via root discovery (
parent_match,mirror,parent_up) and dynamic references ({active.stem},{params...}). - Planning, then executing with a clean separation of phases: discovery + staging produce
a
RunPlan; execution runs it with structured logging and multi-process parallelization. - Orchestrating end-to-end from the CLI: one config wires explorer + staging + pipeline, runs on explicit files and/or directories, and saves/loads plans as DuckDB for fast reuse.
Installation
niiflow-preproc requires Python 3.11+ and pulls in ANTs-based tooling (antspyx,
antspynet), nibabel, PyYAML, and duckdb.
pip install niiflow-preproc
This installs the niiflow-preproc console script. For development inside the
uv workspace, from the repository root:
uv sync --group dev
uv run niiflow-preproc --help
1. Dynamic pipeline execution
A pipeline is a dictionary with a steps entry. Pass it to
dynamic_pipeline(), which builds a Compose of PipelineStage objects and
runs it immediately under a fresh RuntimeContext. steps may be either:
- an ordered list of step specs (position defines order), or
- an id-keyed mapping with an optional top-level
order(used here so steps can reference each other by id).
Each step spec is {name, params, save_outputs, verbose}:
name— a registered stage class (see Available stages).params— keyword inputs to the stage. Values may bectx.references such as"ctx.outputs.<step_id>.<output>"to directly access outputs from previous steps.save_outputs— per-output paths to persist results to disk.
The example below uses simple, explicit paths and ctx. references (no staging). A
single QC step (CheckVoxelSpacing) runs first, and its boolean passed output is
probed into the rest of the pipeline via the enable flag — when QC fails, the
gated steps are skipped:
from niiflow.preproc.pipelines import dynamic_pipeline
dynamic_pipeline(
{
"verbose": True,
"steps": {
# QC gate: always runs, publishes `passed` under id "qc".
"qc": {
"name": "CheckVoxelSpacing",
"params": {
"image": "/data/sub-01/anat/sub-01_T1w.nii.gz",
"expected": [1.0, 1.0, 1.0],
"op": "<=",
"id": "ctx.run_id",
"log": True,
},
"save_outputs": {"report": "/data/derivatives/sub-01_qc.json"},
},
# Gated by QC: skipped entirely when qc.passed is False.
"n4": {
"name": "ANTsBiasFieldCorrection",
"params": {
"image": "/data/sub-01/anat/sub-01_T1w.nii.gz",
"enable": "ctx.outputs.qc.passed",
},
"save_outputs": {"out_image": "/data/derivatives/sub-01_desc-n4_T1w.nii.gz"},
},
"strip": {
"name": "ANTsBrainExtraction",
"params": {
"image": "/data/sub-01/anat/sub-01_T1w.nii.gz",
"modality": "t1",
"enable": "ctx.outputs.qc.passed",
},
"save_outputs": {
"out_image": "/data/derivatives/sub-01_desc-brain_T1w.nii.gz",
"brain_mask": "/data/derivatives/sub-01_desc-brain_mask.nii.gz",
},
},
},
"order": ["qc", "n4", "strip"],
},
run_id="sub-01",
)
Available processing stages
ANTsBiasFieldCorrection, ANTsBrainExtraction, ANTsDenoise,
ANTsPreprocessBrainImage, ANTsRegistration, ANTsApplyTransforms, ANTsResample,
ANTsResampleToTarget, Reorient, ClampIntensities, MinmaxNorm, ZTransformNorm,
PointwiseArithmetic, CenterCrop, CenterPad, CropToMask, CropToRange,
PadToRange, CheckDimensions, CheckVoxelSpacing, ApplyMask, SmoothMask,
RelabelMask, ToNumpy, GetImage, Rename, Delete.
2. Dynamic exploration of active files
When a workflow receives mode: search as an input, an explorer discovers the
active files (the canonical anchor per processing unit, e.g. each subject's T1w).
The explorer lives under that search input's explorer_params: glob patterns and
optional composable filters, backed by
[nifti-finder](https://pypi.org/project/nifti-finder/).
The example below discovers only healthy T1w images that also have a FLAIR companion
in a BIDS dataset, combining two filters with AND logic:
inputs:
mode: search
roots: /root/of/BIDS/dataset
explorer_params:
patterns: "**/anat/*T1w.nii*"
filters:
name: ComposeFilter
kwargs:
logic: AND
filters:
- name: IncludeFromTable
kwargs:
table_path: /root/of/BIDS/dataset/participants.tsv
id_column: dataset_subject_session
criteria_column: group
criteria_value: Control
id_pattern: "{id}"
- name: IncludeIfFileExists
kwargs:
filename_pattern: "*FLAIR.nii*"
patternsaccepts a single glob or a list of globs.filtersis either a single{name, kwargs}filter or aComposeFilterwhosekwargs.filtersis a list combined withlogic: AND/OR.- For resuming interrupted cohort runs, nifti-finder also provides
IncludeFromLogs/ExcludeFromLogsfilters that read a workflowstatus.log(see nifti-finder).
Other inputs shapes: an explicit file path (string/Path), mode: from_file
(path list on disk), or a sequence mixing any of these. Explicit paths bypass
discovery; only search roots are explored.
3. Flexible staging around active files
Staging resolves additional inputs and outputs around each active file without ever
rewriting the active anchor. A FileStager is configured with pointers — dotted
paths into the pipeline params — each marked "input" or "output":
- input pointers may be an explicit path or a search spec (
root+search). - output pointers build a target path from a discovered
rootplus aname. allow_overwritedefaults totrue, so re-staging may replace existing derivative paths. Set it tofalseto treat existing outputs as already done (see Continuing from existing runs).
pointers may also be omitted entirely. Dynamic references ({active.*} / {params.*})
can be resolved throughout params without treating any parameter as a file to locate or
materialise — useful when the pipeline already spells out every path and only needs the
placeholders expanded.
Roots can be discovered relative to the active file: parent_up (N levels up),
parent_match (nearest/farthest ancestor matching a pattern), and mirror (rewrite one
hierarchy into another, e.g. rawdata -> derivatives). Omit root, or omit mode on a
root spec, to start from the active file's parent.
The pipeline and staging specs below work together: step parameters use
{active} and other staging placeholders in save_outputs that
FileStager resolves before execution:
pipeline_params:
verbose: true
steps:
qc:
name: CheckVoxelSpacing
params:
image: "{active}"
expected: [1.0, 1.0, 1.0]
op: "<="
id: "ctx.run_id"
log: true
save_outputs:
report:
root:
mode: parent_match
value: "anat"
mirror:
source: "rawdata"
target: "derivatives/niiflow/qc"
name: "{active.stem}_qc_spacing.json"
strip:
name: ANTsBrainExtraction
params:
image: "{active}"
modality: t1
enable: "ctx.outputs.qc.passed"
save_outputs:
out_image:
root:
mode: parent_match
value: "anat"
mirror:
source: "rawdata"
target: "derivatives/niiflow"
name: "{active.name|strip:_T1w.nii.gz}_desc-brain_T1w.nii.gz"
brain_mask:
root:
mode: parent_match
value: "anat"
mirror:
source: "rawdata"
target: "derivatives/niiflow"
name: "{active.name|strip:_T1w.nii.gz}_desc-brain_mask.nii.gz"
order: [qc, strip]
staging_params:
stager_name: FileStager
params:
pointers:
steps.qc.params.image: input
steps.qc.save_outputs.report: output
steps.strip.params.image: input
steps.strip.save_outputs.out_image: output
steps.strip.save_outputs.brain_mask: output
For an active file .../rawdata/sub-01/anat/sub-01_T1w.nii.gz this stages the T1w as
QC and strip inputs, writes the QC report under derivatives/niiflow/qc/..., and
writes skull-stripped outputs under derivatives/niiflow/... when QC passes.
Supported references inside specs: {active}, {active.name|stem|parent}, and
{params.<dotted.path>[.<attr>]}, with a |strip:<suffix> modifier
(e.g. {active.name|strip:_T1w.nii.gz}). resolve_results may be first, single, or all.
staging_params accepts a single {stager_name, params} dict or a list of such
dicts; when a list is provided, stagers run in order, each transforming the entries
produced by the previous one (useful for splitting input and output resolution, or
layering multiple stager types as more are added):
staging_params:
- stager_name: FileStager
params:
pointers:
steps.qc.save_outputs.report: output
- stager_name: FileStager
params:
pointers:
steps.strip.save_outputs.out_image: output
steps.strip.save_outputs.brain_mask: output
Available stagers
Registered names for stager_name (via create_stager / staging_params):
FileStager— resolve input/output file pointers around the active path (pointers,ensure_inputs_exist,allow_overwrite,allow_failed_entries).EnsureActiveExists— require each entry's active path to exist and be a file (allow_failed_entries). Place it in the chain when actives may be invented mid-staging; the default discovery path already yields real files.ResolveActiveReferences/ResolveParamReferences— expand{active.*}then leftover{params.*}references. Provided workflows always bookend the configured chain with these (even whenstaging_paramsis omitted), so you rarely need to list them explicitly.
4. Workflows: planning and controlled execution
DynamicProcessingWorkflow ties everything together. It separates planning
(discovery + staging → a RunPlan) from execution (running the plan), and provides
structured logging and process-based parallelism.
from niiflow.preproc.workflows import DynamicProcessingWorkflow, dynamic_workflow
workflow = DynamicProcessingWorkflow(
pipeline_params={...}, # shared `steps` spec from section 1/3, or a list (one per active)
staging_params={...}, # the stager from section 3 (optional)
num_workers=1, # serial by default; "auto" or N>1 for parallel cohort runs
logs_root="/data/logs", # writes main.log, status.log, workers.log
timeout=1800, # soft per-entry limit (seconds)
)
# Phase 1 — plan only (collect active files + stage entries, no processing):
plan = workflow.plan(
[
{
"mode": "search",
"roots": "/data/bids",
"explorer_params": {"patterns": "**/anat/*T1w.nii*"},
},
"/data/extra/sub-99_T1w.nii.gz",
]
)
plan.save("/data/plans/run.duckdb") # persist for reuse
# Phase 2 — execute the prepared plan (optionally a contiguous window):
workflow.run_plan(plan)
workflow.run_plan(plan, start=0, end=1000) # or plan[0:1000] then run_plan
# Or end-to-end orchestration (plan / plan-only / from-plan / dry-run):
dynamic_workflow(
settings={"pipeline_params": {...}, "staging_params": {...}, "num_workers": 1},
inputs="/data/extra/sub-99_T1w.nii.gz",
)
# Shard a saved plan across jobs (e.g. cluster array tasks):
dynamic_workflow(from_plan="/data/plans/run.duckdb", start=0, end=1000)
- Planning (
plan) collects active files fromInputData(search/from_file/ explicit paths), stages per-entry parameters, and returns aRunPlanofStagedEntryobjects. A mappingpipeline_paramsis copied to every entry; a list of mappings is aligned 1-to-1 with the collected actives and cannot be used withsearch. To plan and execute, callplanthenrun_plan, or usedynamic_workflow(...)for a single orchestration entry point (plan / plan-only / from-plan / dry-run). The orchestrator returns nothing; persist withsave_plan_toand reload viafrom_plan/RunPlan.loadwhen you need the plan. The CLIdynamic_workflowcommand uses that driver. - Logging is controlled by
logs_rootand themain_logs/status_logs/worker_logs/dev_modeflags. The status log records completion as<active> | SUCCESSor... | FAILURE | <traceback>(or... | STAGING_FAILURE | <message>when staging recorded errors on an entry). A configuredtimeoutis a soft per-entry time limit. Exceeding it does not cancel the worker;status.logrecordsTIMEOUTto mark the overrun. Completion is stillSUCCESSorFAILURE. Retry fromstatus.logusing the lastSUCCESS/FAILUREline per active. - Parallelization defaults to serial (
num_workers=1). Setnum_workersto"auto"(CPU count) or an integer> 1to run entries in aProcessPoolExecutor. For parallel cohort runs, also consider limiting per-process ITK/OMP threads to avoid oversubscription. - RunPlan persistence:
.duckdb(recommended, scalable) or.json(debug). Reload withRunPlan.load(path)and execute without re-discovering or re-staging. - Slicing large plans: select a contiguous window of plan entry indices
(including staging-failed entries) with
plan.slice(start, end)/plan[start:end],workflow.run_plan(plan, start=..., end=...), orRunPlan.load(path, start=..., end=...)/dynamic_workflow(..., from_plan=..., start=..., end=...). Bounds use inclusivestart, exclusiveend, negative indexing, and raise on out-of-range values. Prefer planning once, then run batches viafrom_plan.
Continuing from existing runs
By default, FileStager uses allow_overwrite: true, so a repeat execute may
overwrite derivative outputs. To continue after a partial or failed run, narrow
which actives are discovered or which entries pass staging:
1. Filter actives with status.log (explorer)
Add an IncludeFromLogs or ExcludeFromLogs filter under
inputs.explorer_params.filters (for a mode: search input), pointing at the
workflow status.log from a previous run. Typical patterns: exclude actives that
already logged SUCCESS, or include only actives whose last line is FAILURE.
Filter kwargs are documented in nifti-finder.
inputs:
mode: search
roots: /root/of/BIDS/dataset
explorer_params:
patterns: "**/anat/*T1w.nii*"
filters:
name: ExcludeFromLogs
kwargs:
log_path: /data/logs/status.log
# see nifti-finder for status / path-matching options
2. Skip entries whose staged outputs already exist (FileStager)
Set allow_overwrite: false on FileStager. Resolved output pointers that already
exist raise a staging error; at execution those entries are logged as
STAGING_FAILURE and skipped. Use allow_failed_entries: true so planning continues
for the remaining actives.
staging_params:
stager_name: FileStager
params:
allow_overwrite: false
allow_failed_entries: true
pointers:
steps.strip.save_outputs.out_image: output
# ...
3. Re-execute a saved plan
Set from_plan in a dynamic_workflow config (or call
workflow.run_plan(RunPlan.load(...))) to reload a saved plan without
re-discovery or re-staging. Pair this with status.log filters when you need a fresh
plan that omits already-finished actives.
Available workflows
DynamicProcessingWorkflow.
5. CLI: end-to-end orchestration
The CLI is a thin registry: each subcommand name is an orchestration driver, and the
config file is that driver's keyword arguments. Now, the only registered command is
dynamic_workflow.
An end-to-end example — BIDS T1w discovery (controls with FLAIR companions), QC-gated
skull-stripping, and staged derivative paths — lives at
[configs/bids_controls_t1w_flair.yaml](configs/bids_controls_t1w_flair.yaml).
Replace placeholder paths (/root/of/BIDS/..., /data/...) before running.
# Plan from inputs and execute (config may include save_plan_to):
niiflow-preproc dynamic_workflow configs/bids_controls_t1w_flair.yaml
# Show full tracebacks on error:
niiflow-preproc --debug dynamic_workflow configs/bids_controls_t1w_flair.yaml
# List registered commands:
niiflow-preproc --help
Modes belong in the YAML (not CLI flags), for example:
# plan only (discover + stage, save, do not execute)
plan_only: true
save_plan_to: /data/plans/controls.duckdb
# dry run (print plan; do not save or execute)
dry_run: true
# execute a previously saved plan (no re-discovery / re-staging)
from_plan: /data/plans/controls.duckdb
# omit inputs / save_plan_to when using from_plan
Project structure
niiflow-preproc/
├── pyproject.toml
├── README.md
├── configs/ # example config (BIDS T1w–FLAIR + QC + strip)
├── tests/
└── src/niiflow/preproc/
├── cli/
│ ├── main.py # argparse entry (registered commands + config path)
│ └── commands.py # COMMANDS registry / run()
├── config/
│ └── load.py # load_config (YAML/JSON → dict)
├── data/
│ ├── explorer_factory.py # get_data_explorer (nifti-finder + filters)
│ ├── read_from_file.py # read_paths_from_file
│ └── types.py
├── staging/
│ ├── stager.py # Stager base, StagedEntry, make_entries
│ ├── file_stager.py # FileStager (input/output pointer resolution)
│ ├── stager_factory.py # create_stager / discovery
│ ├── search.py # parent_up / parent_match / mirror_root
│ ├── dynamic_referencing.py # {active.*} / {params...} reference resolution
│ ├── utility.py # EnsureActiveExists and other utility stagers
│ ├── validation.py
│ └── types.py # Root/Input/Output spec types
├── pipelines/
│ ├── dynamic_pipeline.py # dynamic_pipeline (build + execute)
│ └── pipeline_stages/
│ ├── pipeline_stage.py # PipelineStage base + RuntimeContext
│ ├── pipeline_stage_factory.py # create_pipeline_stage / discovery
│ ├── compose.py # Compose (ordered stage runner)
│ ├── arithmetic.py # PointwiseArithmetic
│ ├── bias_field.py # ANTsBiasFieldCorrection
│ ├── skull_stripping.py # ANTsBrainExtraction
│ ├── registration.py # ANTsRegistration / ANTsApplyTransforms
│ ├── resampling.py # ANTsResample / ANTsResampleToTarget
│ ├── denoising.py # ANTsDenoise
│ ├── intensity_normalization.py # Clamp / Minmax / ZTransform
│ ├── croppad.py # CenterCrop / CenterPad / Crop/Pad-to-range
│ ├── qc.py # CheckDimensions / CheckVoxelSpacing
│ ├── masks.py # ApplyMask / SmoothMask / RelabelMask
│ ├── utility.py # Reorient / ToNumpy / GetImage / Rename / Delete
│ └── pipelines.py # ANTsPreprocessBrainImage
├── workflows/
│ ├── workflow.py # ProcessingWorkflow / PlannableWorkflow (execution engine)
│ ├── dynamic_workflow.py # DynamicProcessingWorkflow / dynamic_workflow
│ ├── workflow_factory.py # create_workflow / discovery
│ ├── mixins.py # SupportsInputDiscovery / SupportsStaging
│ ├── types.py # InputData / SearchInput / FromFileInput
│ ├── plan.py # RunPlan (.duckdb / .json persistence)
│ ├── logging_manager.py # main / status / parallel logging
│ └── logging_utils.py
├── functional/ # array/image operations (core preprocessing functions)
└── utils/ # file + misc helpers
Metadata
Release files for niiflow-preproc 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| niiflow_preproc-0.3.0.tar.gz | 163.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| niiflow_preproc-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 295.5 kB
Release files / niiflow_preproc-0.3.0.tar.gz
| Download URL | niiflow_preproc-0.3.0.tar.gz |
|---|---|
| Size | 163.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f27b1b61b3f0813500df9195e5ebaec830c1a081ff695f97d8f1baa84a3f7639
|
|
BLAKE2b-256 checksum How to use checksums |
10aa602dedc89db47f5f51000240adbeeaa28ce29fac54ce5ce24725640c1624
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / niiflow_preproc-0.3.0-py3-none-any.whl
| Download URL | niiflow_preproc-0.3.0-py3-none-any.whl |
|---|---|
| Size | 132.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
202fbdabc27b1851fe0e8ec4046245755abfb86066717e3c04082608cb1d5475
|
|
BLAKE2b-256 checksum How to use checksums |
fe6573162bf5bd2e64ecaa86357dfe29e206c4d26d85013e602a1f86e8b0f530
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|