Transform configuration#
Transform configuration lives under the Transformer root object. It runs no
model — nor does EVALUATION, and the difference is what comes out: an
evaluation measures, this one makes. It reads a dataset, applies a chain of
transforms, and writes a dataset. If you never train anything, this is the only
page you need.
Transformer:
name: RESAMPLE_TO_ISO
on_fallback: warn
Dataset:
dataset_filenames:
- ./Raw:mha
memory_budget: 8G
groups_src:
CT:
groups_dest:
CT_iso:
transforms:
ResampleToResolution:
spacing: [1.0, 1.0, 1.0]
Write:
dataset: ./Out:omezarr
That writes ./Out/<case>/CT_iso.ome.zarr/ for every case in ./Raw, one slab
at a time — the whole volume is never held in memory.
Running it#
konfai TRANSFORM --config Transform.yml # transform
konfai TRANSFORM --config Transform.yml --plan # print the plan and stop
konfai TRANSFORM --config Transform.yml --cpu 4 # shard the cases over 4 processes
A case whose output already exists is skipped — rerunning after an
interruption resumes where it stopped. Pass -y/--overwrite to recompute
everything.
Read transforms run on CPU; --gpu matters only when a chain embeds a
KonfAIInference stage, whose nested inference does use the device. That stage
is whole-volume and its GPU/RAM usage lives outside memory_budget — the
plan prints a note saying it cannot bound those chains. There is no -tb: the
workflow emits no scalars.
Read the plan before you read anything else#
Every run prints its plan first, and a run that proceeds writes it to
./Transforms/<name>/plan.txt, next to an outputs.json naming where each
chain’s deliverable lands. No data is written until the plan is accepted, and
--plan prints and stops without leaving either file behind.
[Transformer] plan over 1 rank(s) | per-rank budget 7.45 GiB ('8G') | fallback working set
= case x 4 B x 2 (in-flight copy), headers-only estimate | output dtype/channels assumed
float32 / source channels until the first slab
CT -> CT_iso: 120 case(s) -- 118 STREAM, 2 WHOLE-VOLUME, 0 SKIP (output already written)
(2 case(s)) WHOLE-VOLUME: stage 1 'Standardize' needs whole-volume statistics, but an
earlier stage changes the values -- the stored volume's statistic is not this stage's input.
worst fallback case ~= 3.10 GiB vs per-rank budget 7.45 GiB
The verdicts, and each one is a fact about your run:
STREAM — the case is read and written region by region. Memory is one slab, whatever the volume’s size.
WHOLE-VOLUME — the case is assembled in memory, then written. Always correct, never bounded. The line says which stage refused and why.
SKIP — the output already exists; nothing is recomputed.
REDUCE / REFUSED — a chain that folds the cohort into one entry (Reduce) prints one line for the whole cohort rather than one per case:
REDUCEwhen the fold streams,REFUSEDwhen it cannot. A reduction has no whole-volume path to fall back to, soREFUSEDrefuses the run whateveron_fallbacksays.
The plan is a measurement, not a prediction: it opens a real region-write
stream on each destination and removes it immediately, so the verdict it prints
is the one the run will act on. That is why --plan still touches the output
directories.
Two numbers it prints are estimates, and it says so rather than hiding it. The
case sizes come from headers alone (prod(shape) x 4 bytes), and the output
dtype is a hypothesis until the first slab is computed. A case sitting within a
few percent of the budget can still exceed it.
When a chain cannot stream#
on_fallback decides what a WHOLE-VOLUME verdict means to you:
Value |
Effect |
|---|---|
|
Take the whole-volume path silently — but the plan still names it. |
|
Same, plus a warning line after the plan. |
|
Refuse the run. Nothing is written. A fallback only discovered mid-run (a failed sweep, a |
Independently of on_fallback, a case that cannot stream and does not fit
memory_budget always refuses the whole run, before the first byte. Writing
40 cases and dying on the 41st is worse than writing nothing: every case’s size
is readable from headers, so there is no reason to find out late.
The usual reasons a chain refuses to stream, and what to do:
Reason in the plan |
Fix |
|---|---|
a stage declares |
Some transforms genuinely need the volume ( |
a statistic after a value-changing stage |
Insert a |
the destination cannot serve region writes |
Write to |
a halo too wide for the grid |
The transform’s neighbourhood is over half the slab extent; it is cheaper to load the volume. |
Save: unlocks chains that would otherwise refuse#
[Clip, Standardize] cannot stream — after Clip, the stored volume’s
statistics are no longer Standardize’s input. Cutting the chain in two fixes
it:
transforms:
Clip: {min_value: 0.0, max_value: 400.0}
Save: {dataset: ./Work:h5} # milestone: the statistic seeds from THIS
Standardize: {inverse: false}
Write: {dataset: ./Out:omezarr} # deliverable
Save and Write are the same mechanism with different intent. Write is
the deliverable: required, planned, resumed, reported. Save is an
opportunistic milestone: it is never written when the Write after it already
exists (the boundary skips the whole prefix). Both need a dataset of their own
in this workflow: a Save: without one would write next to your source, and
that is refused at parse time.
What the config refuses, and when#
Everything decidable from the config alone is refused at parse time, before a byte is read:
a chain that does not end with a
Write(it would read everything and write nothing), or a transform placed after the terminalWrite(its result goes nowhere);two chains writing the same
(dataset, group)— the second would find the first one’s output and report the case as already done;a
Writewhose destination is inside a source dataset — reading is lazy and streaming re-reads the source while writing, so an in-place transform would read its own half-written output;a
Savewith nodatasetof its own, which would write next to the source;any structural key the grammar does not list, and any stage argument its constructor does not take. A typo’d
memory_budge:orClip: {min_val: …}would otherwise be ignored and its default used silently; here it is an error naming the exact path and the legal keys. (A stage that takes**kwargsor resolves nowhere is left to the loader’s own error, and the contents ofsubset:are not walked.)
Fields#
Field |
Type |
Default |
Effect |
|---|---|---|---|
|
string |
|
Names the run folder under |
|
|
|
What a whole-volume case means. |
|
int |
|
The seed every |
|
mapping |
|
Sources, chains, budget. |
Under Dataset::
Field |
Type |
Default |
Effect |
|---|---|---|---|
|
list of |
|
Where cases are read. |
|
size string or number |
|
Per-rank ceiling. A bare number is GiB; |
|
string / list / null |
|
Restricts which cases run: a flat selector — a case name, a case-list file, |
|
mapping |
— |
The chains, keyed by source group then destination group. |
The grammar is deliberately smaller than Train’s Dataset:. There is no
patch: (the planner cuts slabs; declaring a patch size would be guessing on
KonfAI’s behalf, and would make the output depend on it), no batch_size, no
validation, no shuffle, no is_input (every group is an input when there is
no network).
Note
Several groups_src keep only the cases present in all of them: a case with
an image but no label disappears from the run. The plan prints how many were
dropped, before writing anything.
Three cardinalities, and where a chain changes its own#
A chain is 1-to-1 by default: one case in, one entry out. Two markers change that, at a declared position — everything before the marker runs at the old cardinality, everything after it at the new one.
Marker |
Cardinality |
Writes |
|---|---|---|
(none) |
1-to-1 per case |
one entry per case |
|
N-to-1 over the cohort |
one entry, named by |
|
1-to-N per case |
one entry per copy, named by |
One chain changes its cardinality at most once. Composing the two — augment a cohort, then fold it — is two invocations, the second reading the first one’s output back.
Reduce: N cases, one volume#
Everything before the marker runs once per case, Reduce folds the cohort at
fixed voxel, and everything after it runs once on the result. The chain is
driven by the reduction engine — it walks the output’s regions and, within a
region, the cases — so peak memory is a few regions, never N volumes:
transforms:
Clip: {min_value: 0.0, max_value: 400.0}
Reduce:
operator: Mean
output: template
Write: {dataset: ./Atlas:h5}
That writes one entry named template, whatever the cohort’s size.
Field |
Default |
Effect |
|---|---|---|
|
|
A classpath resolved against |
|
— |
Required: the entry name the single result is written under. |
|
|
How much agreement between members is demanded before a byte is read (below). |
|
|
The tolerance |
|
|
Record the operator and the folded case list in the output’s header — a cohort that silently changed between two runs writes a different volume under the same name, and nothing about the output would look wrong. |
Operators. Mean folds one case at a time, so its working set is two
regions whatever N is. Median needs every case per region, and stacks and
sorts them on top — the plan says how many regions that is, and memory_budget
sizes and refuses against it. Concat puts the cases side by side: the output
carries N × C channels. A custom operator must declare voxel_local = True —
one that reads across space cannot stream and is refused outright. It should
also declare working_multiple if it allocates over the buffer it is handed,
or the plan promises a working set the run exceeds.
Warning
Mean and Median are for intensities. Both answer with values that were in no
input — the median of labels 1 and 5 is 3, a different structure — and
both widen an integer input to float32. Over exactly two cases Median is
Mean, so the robustness the name promises is not there. Fold segmentations
with Vote, which takes the label the most cases agree on, keeps the input
dtype, and breaks a tie toward the smallest label so the fold is reproducible.
grid decides what counts as “the same space”, compared on the grid each
case’s chain lands on (a Resample before the Reduce counts):
strict(default) — equal extents and equalSpacing/Origin/Directionwithingrid_tolerance.shape_only— equal extents alone: the escape hatch for volumes already resampled together but carrying approximate headers.reference:<case>— equal extents, and the output adopts that member’s geometry: how a cohort says its members disagree on their headers and which one to believe.
Warning
Nothing can verify that the members truly live in a common space — only that
they claim to. shape_only and reference: will happily average misaligned
volumes; the result still looks like a volume and is an artefact. Put the
cohort on one grid first — ResampleToReference, below.
A reduction has no whole-volume fallback. Folding every case in memory is
what it exists to avoid, so a Reduce that cannot stream refuses the run
whatever on_fallback says — the plan prints REFUSED and the reason. Only
voxel-local stages (and statistics, seeded by an extra pass of the engine’s
own) may follow the Reduce in the same chain: anything reading across space
belongs in a second chain that reads the written output back.
ResampleToReference: making strict true rather than waived#
A cohort as acquired rarely passes strict: extents differ, and origins can
differ by more than the volumes are wide, because an acquisition’s stage
coordinates are not an anatomical frame. ResampleToReference is what makes
strict true rather than waived — it resamples each case onto the grid of a
declared reference, adopting its extent, spacing, origin and direction:
transforms:
ResampleToReference: {entry: case_0, group: CT, fill: 0.0}
Reduce: {operator: Median, output: template, grid: strict}
Write: {dataset: ./Template:mha}
The reference is a stored image, named by entry — and by group when the
store holds more than one. It is looked up by entry, not by the case being
processed, because one grid serves the whole cohort: in the run’s own
dataset_filenames, or in a store of its own.
transforms:
ResampleToReference: {entry: case_1, group: CT, dataset: ./Raw:mha}
Write: {dataset: ./OnTemplate:mha}
dataset: takes the same path[:format] spec as everywhere else, so the
reference can live anywhere — which is the atlas loop: point round N+1 at the
store round N wrote its template into.
Through a displacement field, in one interpolation#
Add field: and the stage becomes the whole of a registration’s apply step —
sitk.Resample(image, reference_grid, DisplacementFieldTransform(field)). For
each voxel of the target grid the field is read at that voxel’s world
position, added, and the source sampled once at the displaced point:
transforms:
ResampleToReference:
entry: case_0
group: CT
field: ./Fields:mha
field_group: DVF
max_displacement: 4.0
Write: {dataset: ./Registered:mha}
Fields stored beside the cases — one entry per case, in the same roots — need
no path at all: field_group: DVF on its own finds them.
Doing this as two stages instead (resample onto the grid, then Warp) costs
two interpolations, and the second cannot restore the detail the first
smoothed away. That is not a small effect: on a high-frequency volume the
second pass moves voxels by a large fraction of the range, which is exactly why
an atlas rebuilds its appearance from native volumes rather than from resampled
ones.
The field lives on its own grid, usually coarser than either the source or the target, and is read where it is asked — it is defined in world units, so a field solved at 120 µm moves a volume stored at 30 µm without being upsampled first. Outside its own extent the displacement is zero: the transform is the identity where the field says nothing, as SimpleITK has it.
max_displacement sizes the source region each target region must read, and is
checked against every field region actually read — a field that exceeds
what it declared raises rather than sampling zeros, which would show up as a
dark rim around the moved anatomy and nothing else. It takes auto, reading
the bound KonfAI records on a field it writes. With no bound at all the stage
declares WHOLE_VOLUME and says so in the plan, exactly as Warp does.
Note
Warp still exists and is not this: it adds a displacement on the case’s own
grid, which is the shape update of an atlas build, and it neither changes the
grid nor needs a reference. Use it when the field was solved on the very grid
it is applied to.
Naming an image rather than fifteen numbers is deliberate. A grid is an extent
in array order (Z, Y, X) plus an origin, a spacing and a direction in physical
(x, y, z) — transcribing those by hand is the mistake that actually gets made,
and a transposed grid resamples perfectly well onto the wrong place. A header
cannot make that mistake.
Note
It streams: a slab of the output reads only the part of the input under it, so a
case never has to fit in memory. The sampler is sitk.Resample’s — linear with
taps clamped to the buffer, nearest by round-half-up, and fill wherever the
reference grid reaches past the case.
Label maps. Left unset, interpolation is read off the dtype: uint8 takes
the nearest voxel, everything else is interpolated. A dtype cannot decide this
on its own — a CT is int16 and so is nothing else about it — so a label map
stored as anything but uint8 must say so:
ResampleToReference: {entry: case_0, group: Labels, interpolation: nearest}
Getting it wrong is silent. Two labels blended give a third that was never in the source, the dtype is unchanged, and the result is still a label map.
What it refuses, rather than write something plausible and wrong:
a case or a reference carrying no
Origin/Spacing/Direction— without geometry there is no physical space to resample in, and a size ratio must not quietly stand in for one;a reference whose
Directiondiffers from the case’s — the map between them is then a rotation, not a scale and a shift per axis. Reorient first (Canonical);a case that does not meet the reference grid anywhere — its output would be
fillfrom edge to edge, and a median would take that as anatomy.
Partial overlap is legal and ordinary: the rest of the output is fill, and the
plan prints the fraction of the grid each case covers. “Most of this template is
background” is then something read before the run rather than after it.
Expand: one case, N copies#
Expand multiplies, and nothing else. The draws are ordinary stages of the
chain, declared where they apply — so transforms and augmentations interleave
freely after the marker, and T, draw, T, draw means exactly what it reads
like.
Transformer:
name: AUGMENT
manual_seed: 7
Dataset:
dataset_filenames:
- ./Raw:mha
groups_src:
CT:
groups_dest:
CT_aug:
transforms:
Clip: # once per case, shared by the copies
min_value: 0.0
max_value: 400.0
Expand:
nb: 8
pattern: "{name}_r{a:02d}"
Rotate: # a draw, per copy
is_quarter: true
ResampleToResolution: # a transform, per copy
spacing: [2.0, 2.0, 2.0]
Brightness: # another draw, per copy
b_std: 0.2
Write: # once per copy
dataset: ./Augmented:omezarr
That writes ./Augmented/<case>_r01/CT_aug.ome.zarr/ … _r08/, eight entries
per case.
Each stage is parameterised on the grid and the case state the stages before it leave: a draw that permutes axes hands the next stage its own extent, and a resample between two draws is seen by the second. That is the same contract a transform has — a draw is a stage, not a separate phase.
Warning
A bare name resolves against konfai.data.transform first, and only then
against konfai.data.augmentation. Flip, Permute, Mask and Foreign
exist in both, so Flip: {f_prob: [0.33, 0.33, 0.33]} binds the deterministic
transform and fails on an argument it does not take. Spell the draw out:
konfai.data.augmentation:Flip.
pattern is a str.format template and both tokens are required: {name}
keeps cases apart, {a} (1-based) keeps a case’s copies apart. A pattern missing
either is refused at parse time, because every copy would otherwise overwrite the
previous one. Quote it, or YAML reads {name} as a mapping.
Two more parse-time refusals, both because the config would otherwise silently do
nothing: a draw before the marker (it would be a random transform applied once
per case, not a copy), and an Expand with no draw after it (every copy would
be identical).
Augmenting an image and its mask together#
The copies are drawn from manual_seed, not from a shared random generator: each
draw is parameterised from (seed, case, which draw this is). Two chains never
meet and cannot agree on the order they consume a generator in — but they can
derive from one number they both hold. So declaring the same nb in both chains
is enough, and copy k of the mask carries copy k of the image’s rotation:
Transformer:
name: AUGMENT_PAIR
manual_seed: 7
Dataset:
dataset_filenames:
- ./Raw:mha
groups_src:
CT:
groups_dest:
CT_aug:
transforms:
Expand: {nb: 8, pattern: "{name}_r{a:02d}"}
Rotate: {is_quarter: true}
Brightness: {b_std: 0.2} # image only: it does not shift the rotation
Write: {dataset: ./Augmented:omezarr}
SEG:
groups_dest:
SEG_aug:
transforms:
Expand: {nb: 8, pattern: "{name}_r{a:02d}"}
Rotate: {is_quarter: true}
Write: {dataset: ./AugmentedSeg:omezarr}
A draw is keyed on its own class and its rank among draws of that class, not on
its position in the chain — so Brightness, declared in one chain and not the
other, does not desynchronise the Rotate they share.
Expand also takes a seed of its own. Leave it out and the chain inherits
manual_seed, which is what makes the two chains above agree; set it when you
want two chains to draw different copies of the same cases on purpose.
What it costs, and what the plan tells you#
Reads are decompression-bound, so N copies must not cost N reads. The engine picks a regime per copy and the plan prints which:
CT -> CT_aug: EXPAND 40 case(s) -> 320 cop(ies) -- 280 STREAM (shared read pass),
40 STREAM (own pass), 0 WHOLE-VOLUME, 0 SKIP (copy already written)
(40 cop(ies)) own pass: stage 'Rotate' of this copy declares ORIENTATION, so its
read geometry is the draw's own and it sweeps its own pass.
shared read pass — every copy whose stages after the marker are per-voxel rides one read of the case: each slab is decompressed once, each copy applies its draw to it and writes into its own stream. The marginal cost of a copy is its draw and its write, no reads at all.
own pass — a draw that reads somewhere other than its target slab (a rotation, a halo) has its own read geometry, so it sweeps its own pass. The plan names the stage that decided it.
WHOLE-VOLUME — the copy’s chain cannot stream at all; the shared part is still assembled only once for the case.
When the shared prefix is expensive (a Warp, a resample), put a Save before
the Expand: it is materialized once and every copy reads the cache.
Resume is per copy: a copy whose entry exists is skipped, so an interrupted
run picks up at the copy it stopped on — and because the draws come from
manual_seed rather than from the order a generator was consumed in, the copies
it keeps are the copies it would have written.
Statistics a stage can ask for#
A stage that needs a whole-volume figure declares it rather than computing it:
GLOBAL_STAT with the keys it reads. The planner obtains them once, by scanning
the stored entry without materialising it, and the stage is then an ordinary
value map — so it streams. That is how Normalize and Standardize work.
Two grains are available, and they come from the same single pass:
Keys |
What they are |
|---|---|
|
the figure over the whole volume, every channel pooled |
|
one figure per channel |
The per-channel form exists because some quantities have no single mean. The spatial mean of a displacement field is a translation: it has one part per component, and pooling them into one number describes nothing.
Warning
A statistic is the STORED volume’s. It is still the stage’s own input only when
every stage before it preserves statistics — a reorientation does, a Clip does
not. Clip then Normalize therefore takes the whole-volume path, and the plan
says so. Reorder the chain, or cut it with a Save.
Handed the whole volume anyway — a chain that fell back for another reason — a stage should take the statistic from the tensor in hand and record it, so both paths leave the same state behind.
Writing your own transform#
A transform that streams is not a special kind of transform. It is three methods, and the third is the one that matters:
# BoxFilter.py -- importable from the directory you launch konfai from
import torch
import torch.nn.functional as F
from konfai.data.transform import LocalityKind, PatchLocality, Transform
from konfai.utils.dataset import Attribute
class BoxFilter(Transform):
"""Cubic moving average of radius `radius`."""
def __init__(self, radius: int = 1) -> None:
super().__init__()
self.radius = radius
def patch_locality(self, cache_attribute: Attribute) -> PatchLocality:
# Each output voxel depends on a BOUNDED neighbourhood: declare the halo and the
# dispatcher reads the enlarged region, crops after, and streams the whole chain.
if self.radius == 0:
return PatchLocality(LocalityKind.POINTWISE)
return PatchLocality(LocalityKind.HALO, halo=(self.radius,))
def __call__(self, name: str, tensor: torch.Tensor, cache_attribute: Attribute) -> torch.Tensor:
if self.radius == 0:
return tensor
k = 2 * self.radius + 1
return F.avg_pool3d(tensor.to(torch.float32), k, stride=1, padding=self.radius).to(tensor.dtype)
transforms:
BoxFilter:BoxFilter: {radius: 2}
Write: {dataset: ./Out:omezarr}
That is the whole contract. The dispatcher builds the region map from your declaration; you never touch it.
Declare honestly — that is the one rule. patch_locality is a promise about
what your __call__ reads, and nothing verifies it against the code. Declaring
POINTWISE while reading your neighbours produces seams at every slab boundary:
a plausible, wrong image, with no error. When unsure, declare nothing — the
default is WHOLE_VOLUME, which is always correct and merely slower. The plan
will tell you that is what you got.
Two details the example carries on purpose. The internal padding must be the one
the whole-volume path would apply (here avg_pool3d’s zero padding, which the
halo never reaches except at the real volume border) — a reflect pad computed
on the received extent would reflect the patch’s edge, not the volume’s. And
the halo must stay small relative to the slab: a radius over half the extent is
refused (cheaper to load the volume), and the plan says so.
Which kind to declare#
Your |
Declare |
You must also write |
|---|---|---|
the same voxel only |
|
nothing |
a bounded neighbourhood |
|
nothing |
the volume flipped/permuted |
|
|
a translated sub-box |
|
|
the same box, resampled |
|
inherit from |
another grid entirely |
|
|
whole-volume Min/Max/Mean/Std |
|
nothing |
the same, per component |
|
nothing |
genuinely the whole volume |
nothing (the default) |
nothing |
A transform that does not subclass Transform still works — it is wrapped
automatically — but it is treated as WHOLE_VOLUME.
Python API#
from konfai.transformer import build_transform
workflow = build_transform(transform_file="Transform.yml", transforms_dir="./Transforms")
plan = workflow.compute_plan(world_size=1) # dry run: the same verdicts, as objects
print(plan.report())
print([(e.case, e.verdict, e.reason) for e in plan.fallback_entries])
build_transform returns the configured workflow without running it, which
is how the plan is available programmatically. konfai.transformer.transform()
is the execute-everything entrypoint used by the CLI.