Converters¶
Every importer and exporter lives in medh5.io and behind medh5 convert.
pip install "medh5[nifti,dicom]" # reading
pip install "medh5[dicomseg]" # writing DICOM SEG (highdicom)
Conversion reports¶
Every conversion returns a report, because the interesting part of an import is not that it succeeded — it is what it had to work out.
from medh5.io.nifti import from_nifti
report = from_nifti({"CT": "ct.nii.gz"}, "case.medh5", masks={"liver": "liver.nii.gz"})
report.ok
report.of_kind("encoding") # what encoding was chosen and why
for note in report.notes:
note.severity # "decision" | "guess" | "warning" | "info"
note.kind, note.message, note.detail
The distinction that matters:
| decision | determined from the data — "the overlap graph has one edge, so layers" |
| guess | assumed, because the source did not say — "timepoints ordered by file mtime" |
| warning | something is wrong or lost |
--report FILE writes it as JSON. A guess is not a failure; it is the thing
you go back and check. A report never replaces a file the command reads or
writes: a --report naming one — through .. or a link included — or lying
inside the tree or nnU-Net dataset the command reads or the nnU-Net dataset it
writes is refused before anything is converted, and so is an output naming one
of the command's inputs.
What each converter does¶
Every refusal below is explained in What the converters refuse, and why; this page is the commands and the contracts.
NIfTI¶
$ medh5 convert from-nifti case.medh5 --image CT=ct.nii.gz --mask liver=liver.nii.gz
$ medh5 convert import-seg-nifti case.medh5 --mask spleen=spleen.nii.gz --id organs
$ medh5 convert to-nifti case.medh5 CT out.nii.gz
$ medh5 convert to-nifti case.medh5 CT liver.nii.gz --annotation organs --class liver
Axis order. NIfTI is (x, y, z); medh5 is (z, y, x). The array is
transposed and the spacing and direction are permuted to match, so the affine
still describes the same physical volume.
Coordinate system. NIfTI is RAS+; medh5 defaults to LPS (as DICOM does). The
conversion is a sign flip on the affine, diag(-1, -1, 1, 1), and nothing else
— no resampling, no reinterpretation. --coord-system RAS keeps RAS.
Round trip. from_nifti → to_nifti reproduces the affine and the voxels.
A file with a scale (scl_slope/scl_inter) comes back as its physical values
by default — floats, with no scale; --stored (physical=False) writes the
stored voxels and the scale, as the file had them.
A 2-D file is a plane at z = 0. A 2-D grid (§3.6) is on the first two world
axes and has no z, so a 2-D NIfTI must lie in the x-y world plane at z = 0
(to 10⁻³ mm), which is where to_nifti writes one back. A plane off it, or not
parallel to it, is refused rather than moved; saved as (x, y, 1) it imports
as a one-slice volume that keeps its z, and the refusal names the option that
does this for you.
plane_as_volume=True imports a plane where it lies (--plane-as-volume;
read_nifti, from_nifti and import_seg_nifti all take it). Every 2-D
image and mask — planes at z = 0 too, so a cohort comes out uniform — is read
as (x, y, 1) before the transpose and keeps its full 4×4 affine, giving a
(1, y, x) grid: byte for byte the import of the plane re-saved as
(x, y, 1). The third axis is the affine's third column, which must be
non-zero and orthogonal to the first two (within ORTHONORMAL_TOL), or the
file is refused: no normal or thickness is invented. A plane_as_volume
decision records each plane's z and normal, and the import activity's params
gain "plane_as_volume": true when a plane was reshaped. A 3-D or 4-D file is
read the same with or without it.
An export says what its numbers are in. Lengths are written in millimetres
— a grid in m or um is scaled to them, and an uncalibrated px grid gets
NIfTI's "unknown" — and a time axis whose frames are evenly spaced becomes
pixdim[4] and toffset, in the grid's time unit. Uneven frames cannot be one
temporal step: their times go to a BIDS sidecar's VolumeTiming beside the
file (out.json for out.nii.gz), which from_nifti reads back, and the
header's step is left at 0. A channel axis is written as one: with a b-value per
channel, four dimensions and a .bval; otherwise NIfTI's vector dimension
(dim[5], vector intent), with per-channel echo and inversion times and flip
angles in the sidecar. An export replaces the per-volume fields and .bval an
earlier export left beside the file, and keeps the sidecar's other fields. An
annotation is exported on its own grid, whichever image is named.
An annotation is its labelmap, ignored voxels included. The whole annotation
is written as its labelmap(), where a voxel of the §7.7 ignore region is the
annotation's ignore_id (65535 unless the header says otherwise). One class
(--class, class_key=) is a 0/1 mask, which has no value for "ignored" and
whose 0 says "examined, absent", so it is refused for an annotation with an
ignore region, and for a class outside the annotation's annotated_class_ids,
which nobody examined. The output is named .nii or .nii.gz; any other
name, or a directory, is refused before anything is written.
Volumes must share one grid — shape, spacing, origin and direction, with
lengths compared in millimetres, and frame times where both volumes state them.
A mask added with import_seg_nifti must sit on the sample's grid in the
same sense, not only match its shape: a mask from another grid is refused
rather than laid onto voxels nobody drew on. assume_aligned=True takes its
voxels anyway, for a header known to be wrong, and records the guess.
scl_slope and scl_inter become the image's rescale, the way the DICOM
modality LUT does. The voxels keep the dtype the file stores them in;
read(physical=True) applies the scale and read() returns the stored values
(§4.2). A mask file carrying a scale of its own is thresholded after it is
applied.
--assume-geometry imports a file that declares no spatial mapping, recording
the fallback as a guess. Without it, such a file is refused. A file that states
no spatial unit (NIfTI's unknown, the header's default, and what an export of
a px grid writes) is read as millimetres, as NIfTI readers commonly do, and
the report records that as a guess too.
Mask names become class keys as a label set allows them — lowercase
letters, digits and underscores — and the name as given is the class's
display name: --mask GTV-1=gtv.nii.gz is the class gtv_1, named GTV-1.
Two names that make one key are refused. A mask added with import_seg_nifti
is found in the sample's label set by the key its name makes.
label_set= fixes the class ids (--label-set FILE). Minted, a class's id
is its rank among the masks one case has, so two cases of a cohort that
segment different organs number them differently. Given a LabelSet, or a
JSON file medh5.labels.load_file reads, from_nifti matches each mask to the
class of the key its name makes (or of its name), mints nothing, and writes
that label set into the sample; a mask it has no class for is refused (E402).
A class examined and found absent is named, not dropped. A class
annotated_classes= (--annotated-classes K1,K2) names that no mask supplies
was searched for and not found (§11.3). Where the import mints its classes it
mints that one too, after the masks' ids, with a label_set decision naming
it; with label_set=, or in import_seg_nifti into a sample that has a label
set, the class must be in it, and the import is refused (E402) otherwise. The
DICOM SEG and RTSTRUCT importers do the same. It was a bare KeyError.
A sample is one subject, and NIfTI names none: without --subject-id
(subject_id=) the sample is a subject of its own, its id the sample's, and
the report records that as a guess (identity).
A 4-D series' extra axis is decided, not assumed. Cine, DCE and 4-D CT put
time there; multi-b-value DWI and multi-echo series put channels there (§3.6),
and NIfTI's header does not distinguish them. A .bval sidecar, a BIDS sidecar,
the intent code or a time unit settles it; otherwise the axis is read as time
and the report records a guess. --fourth-axis time|channel (fourth_axis=)
overrides it, and b-values or echo times go to acquisition. A series stored
on NIfTI's fifth dimension, (x, y, z, 1, n), is where NIfTI-1 puts a voxel's
components: with nothing else to say, it is read as channels, on a guess, and
it never takes frame times from the header, whose temporal step is the empty
fourth dimension's.
DICOM¶
$ medh5 convert from-dicom /studies out/ --group-by subject
$ medh5 convert from-dicom /studies out/ --modality CT --series 1.2.840...
from medh5.io.dicom import scan_dicom, read_series, from_dicom, select_series
report = from_dicom("/studies", "out/", group_by="subject", modalities=["CT"])
out is a directory — each sample named for its subject inside it —
when the tree holds more than one sample, when it is a directory already, or
when it ends in a separator (out/); otherwise it is the file the one sample
is written to.
Slices are ordered by geometry — position projected on the slice normal, not
InstanceNumber, which is a display order and is regularly wrong.
Spacing is measured from the slice origins, not read from
SliceThickness, and every slice must lie within a tenth of a slice, and at
least 0.01 mm, of where the regular stack puts it — or the series is refused,
never resampled. The slice_spacing decision records the worst slice's
distance (max_through_plane_error_mm); Import from
DICOM sets out the
policy.
Modality LUT is stored, not applied. RescaleSlope and RescaleIntercept
become the image's rescale, so read(physical=True) gives HU and read()
gives what the scanner stored.
A per-slice rescale is refused, or applied when asked. An image stores one
modality LUT (§4.2), and a PET series often states one per slice; taking the
first slice's for all of them would misscale the rest, so such a series is
refused, naming per_slice_rescale. With per_slice_rescale="apply"
(--per-slice-rescale apply), each slice becomes stored × slope + intercept,
computed in float64 and stored as float32 with no rescale attributes; a
per_slice_rescale decision lists the distinct slopes and intercepts with the
slices (grid indices along the first axis) that state each, and the import
activity's params gain "per_slice_rescale": "applied". SUV is never
computed, and value_type and units do not change. A slice that states no
rescale among slices that do is refused either way, and a series with one
rescale is written byte for byte the same whatever the option.
Tags are an explicit allow-list. Imaging physics goes to acquisition;
everything else stays out (§11.4). The importer does not de-identify, and
writes no de-identification record: the report says so, and the validator
reports such a file (W903).
Enhanced multi-frame objects are reported, not read. The importer reads classic single-frame series. An Enhanced CT/MR/PET object keeps its geometry in per-frame functional groups it does not read, so such files are skipped and named in the report as a warning — an import that found nothing says why. Convert them to classic instances first. Other multi-frame objects are skipped with a warning too, and an object that carries a patient position but is not an image — an RT Dose grid — with a note.
One series does not stop the tree. A series the importer refuses is left
out with a warning (series) naming it and the reason, and the rest are
imported; a subject left with no series gets no file (subject), and an
import where no series can be read is refused. A pixel decoder's failure is a
refusal naming the file, not a traceback.
MONOCHROME1 is stored as written. Lower stored values are brighter in
such a series; the importer never rewrites values (§4.2), records the
PhotometricInterpretation in acquisition, and warns in the report so a
model trained on intensity can invert them.
Images are named by modality and visit — CT_tp0 on grid ct_tp0,
PT_tp1 on pt_tp1. A study holding several series of one modality numbers
them in SeriesInstanceUID order — MR_1_tp0, MR_2_tp0 — and the report
records which series got which name. The timepoint's series_uids is keyed by
those image ids.
--group-by subject (the default) resolves patient identity across studies
and emits one multi-timepoint sample per patient, falling back to one sample per
study — with a warning and a record — when identity cannot be established.
DICOM SEG¶
Import DICOM SEG and RTSTRUCT walks through an import, step by step.
$ medh5 convert from-dicom-seg seg.dcm case.medh5 --id organs
$ medh5 convert from-dicom-seg seg.dcm case.medh5 --annotated-classes liver,tumor
$ medh5 convert to-dicom-seg case.medh5 organs out.dcm \
--source ct/1.dcm --source ct/2.dcm ...
to-dicom-seg --class K (classes=) exports only the classes named, each one
the annotation examined; --series-description TEXT (series_description=)
is the SEG's SeriesDescription, a DICOM LO of at most 64 characters, and a
longer one is refused.
Frames are placed by geometry, from each frame's PlanePositionSequence —
not by frame index. Each frame must lie on one of the grid's slices and start at
its first voxel, and its row and column directions and pixel spacing must be the
grid's; a SEG drawn on another reconstruction is refused (E405) rather than
force-fitted. A frame's orientation, pixel spacing and segment are read where
the file keeps them, in its own functional groups or the shared ones; frames
turned or spaced against each other are refused, and so is a frame stating no
pixel spacing or naming no segment. Positions are DICOM's (LPS, millimetres),
converted for an RAS grid or one in other length units; a grid in pixels or
another convention is refused (E414). Segments match by label, not by
number, and segments that share a label — one Lesion segment per lesion is
common — are one class: a BINARY SEG's are combined as their union and a
FRACTIONAL one's as their maximum, recorded as a segment_union decision.
Two segments whose labels make one class but code different concepts, or
carry different type modifiers (Left and Right Kidney, both labelled
Kidney), are refused. A BINARY or FRACTIONAL SEG is read; a LABELMAP
one, whose frames hold every segment by pixel value, is refused by name.
Concepts come in with the segments. A class minted from a SEG takes its
property type as an ontology binding, with the system spelled as §5.2 spells
it where the designator is one the converters know (SCT → SNOMED-CT,
RADLEX → RadLex, FMA, UBERON, LN → LOINC, I10 → ICD-10,
DCM) and as the file states it where it is not. Its property category is
kept as properties["dicom_category"], and becomes category where the
concept stands for one: SCT 49755003 is lesion, SCT 260787004 device, SCT
309825002 region; "Anatomical Structure" is an organ's, a vessel's and a
bone's alike, so it sets none. A laterality modifier becomes laterality
(the report's concepts decision lists both); a modifier that is not a
laterality has no field to go in, and the report's modifier decision names
it.
Frames of reference come first. A SEG is matched to the grid that shares
its FrameOfReferenceUID, and a grid — matched or named with --grid — whose
frame is known and is another is refused (E414) before the sample is touched:
equal geometry in two frames is no correspondence, and relating them is
registration, which the importer does not apply. Where the SEG or the grid
states no frame there is nothing to compare; the placement rests on geometry
and the report records a guess. A sample medh5 scrub pseudonymised holds its
frames' pseudonyms: --frame-salt (frame_salt=) gives the scrub's salt, and
the SEG's frame is compared as the scrub wrote it. frames_agree asks the same
question on its own: equal, not, or None when either frame is unknown.
A SEG may omit its empty frames. An import places each frame on the grid by
its own position. read_dicom_seg, which has no grid and builds the SEG's own
volume, puts the planes the file carries on the regular stack they sit on —
its step the stated SpacingBetweenSlices, else the smallest gap or the
least-squares step, and every plane within a tenth of a step, and at least
0.01 mm, of its place, the bound a DICOM series is held to — with the planes
it omits empty; planes no such stack holds are refused, and
read_dicom_seg_frames reads them frame by frame. A SEG of one plane has no
step to measure: its SpacingBetweenSlices is the step, else its
SliceThickness, as a recorded decision, and one stating neither is refused
rather than given 1 mm.
Export writes an annotation onto its own grid only: the source images must
be that grid's slices — rows and columns running the same way at the same
spacing, each image starting at its slice's first voxel — or the export is
refused (E405); nothing is resampled. They must be in the grid's frame of
reference where it states one (E414 otherwise); --frame-salt (frame_salt=)
compares a frame medh5 scrub pseudonymised.
Export writes the classes the annotation examined. A segment's 0 says
examined and absent, which a class outside annotated_class_ids never was, so
such a class is left out, as an unexamined decision, and classes= naming
one is refused; an annotation that examined none is refused. Every class went
out, and one nobody looked for became an empty segment — a verified negative
to whoever read it back.
from medh5.io.dicom_seg import from_dicom_seg, to_dicom_seg
from_dicom_seg("seg.dcm", "case.medh5", ann_id="organs")
to_dicom_seg("case.medh5", "organs", ["ct/1.dcm", "ct/2.dcm"], "out.dcm")
Import preserves overlapping segments and FRACTIONAL values. Export writes
BINARY by default (segmentation_type="binary"), from dense(), which for
a probmap applies the stored threshold — recorded as a threshold
decision. segmentation_type="fractional"
(--segmentation-type fractional) writes a probmap's probabilities as a
FRACTIONAL SEG of fractional_type "PROBABILITY" (the default) or
"OCCUPANCY" (--fractional-type), each value stored out of 255 and so
within 1/510; any other kind is refused (E401). Its fractional decision
gives the largest error, how many voxels moved across the stored threshold,
and says that threshold and normalized are not carried, a SEG having no
field for either.
to_dicom_seg keyword |
Values | Default |
|---|---|---|
segmentation_type |
"binary", "fractional" |
"binary" |
fractional_type |
"PROBABILITY", "OCCUPANCY" |
"PROBABILITY" |
The values are medh5.io.dicom_seg.SEGMENTATION_TYPES and
medh5.io.dicom_seg.FRACTIONAL_TYPES; medh5.io.dicom_seg.MAX_FRACTIONAL_VALUE
is the 255 a fractional value is stored out of.
Each segment carries its class's concepts. The property type is the
class's first code whose system has a designator; the property category
properties["dicom_category"], else the concept its category stands for
(organ, vessel, bone → SCT 91723000 "Anatomical Structure", lesion →
SCT 49755003, device → SCT 260787004, region → SCT 309825002); its
laterality the type modifier (SCT 7771000, 24028007, 51440002). The
algorithm type follows the annotation's prov: a predict activity by a
software agent of stated version is AUTOMATIC, identified by that software
with family DCM 123110 "Artificial Intelligence"; annotate and review are
MANUAL. DICOM requires all three, so where the sample states none the
export writes the generic one — SCT 91723000, MANUAL — and records a guess
(property_type, property_category, algorithm_type) naming the classes.
A more specific concept than the sample states is never written, and a
designator is never invented.
A segment cannot mark an ignore region either — its 0 says examined
and absent — so an annotation that ignores any voxel (§7.7) is refused;
to_nifti with annotation= writes its labelmap, ignore id included. A SEG
holds one segment per class: the export takes a labelmap, layers,
bitmask, instances or probmap annotation, and refuses any other kind
(E401) — a mask, which has no classes, among them.
Writing needs highdicom (pip install "medh5[dicomseg]").
RTSTRUCT¶
The import, same-named ROIs included, is walked through in Import DICOM SEG and RTSTRUCT.
$ medh5 convert from-rtstruct plan.dcm case.medh5 --id contours
$ medh5 convert from-rtstruct plan.dcm case.medh5 --rasterize
$ medh5 convert from-rtstruct plan.dcm case.medh5 --points-id markers
$ medh5 convert to-rtstruct case.medh5 contours out.dcm \
--points markers --source ct/1.dcm --source ct/2.dcm ...
from medh5.io.rtstruct import from_rtstruct, to_rtstruct
from_rtstruct("plan.dcm", "case.medh5", rasterize=False)
Contours stay contours (§8.6), in world coordinates. --rasterize is opt-in,
and the rule it used goes into the provenance graph; a class whose contours
enclose no voxel centre is a warning, since its empty mask reads as examined
and absent. The mask is annotation <id>_mask unless --mask-id ID
(mask_id=) names another.
--label TEXT (label=) is the export's StructureSetLabel, a DICOM SH
of at most 16 characters. A longer one is refused before anything is read; it
was written, and the file was not valid DICOM.
Areas import as contours, markers as points. A medh5 contour is a closed
polygon, so an ROI drawn as CLOSED_PLANAR contours imports, and so does one
drawn as CLOSEDPLANAR_XOR contours that are nested or apart on every slice,
where their exclusive-or is the holes the import marks. Exclusive-or contours
that cross are refused, naming the ROI and the slice, and so is an ROI mixing
exclusive-or with another type (PS3.3 C.8.8.6.1). An ROI of POINT contours —
a marker — imports as rows of a points annotation (§8.5), --points-id ID
(points_id=, <id>_points by default): one row per contour, in the grid's
world coordinates and the contours' frame, of the ROI's class and named for
the ROI, with task other, since a marker is no detection finding; a POINT
contour of more than one point is refused, and a structure set of points
alone writes no contours annotation. An explicit annotated_classes= list
is split between the two: a class drawn as points is examined in the points
annotation, every other class it names in the contours one; all applies to
the contours only. An ROI of OPEN_PLANAR or OPEN_NONPLANAR contours — a
brachytherapy applicator — is left out with a warning per type
(contour_type), so no class is minted or marked examined for it:
--open-curves omit (open_curves="omit") is the only value until open
curves arrive as polylines in 2.2.0. An ROI mixing areas with points or
curves is refused. A contour stating no type is read as CLOSED_PLANAR, on a
recorded guess, and one of fewer than three points is dropped with a warning.
Every contour lies in a slice of the grid, along its first axis, within a tenth of a slice and at least 0.01 mm, the bound the DICOM import holds slices to. A contour between two slices, across several — drawn on another orientation, or not planar — or outside the grid is refused, not moved onto a slice.
The import reads patient coordinates. ContourData is millimetres in LPS,
and the contours are stored in their grid's world coordinates — scaled for a
grid in other length units, flipped in x and y for an RAS one; a grid in pixels
or another convention is refused (E414). Each ROI is in the frame of
reference it states, else the one the file lists: ROIs in several frames are
refused (E414) before the sample is opened, the message saying which ROI is
in which, and an ROI whose frame the file does not list is a warning. The
contours go onto the grid that shares the ROIs' frame, or the sample's only
grid, and a grid — matched or named with --grid — whose frame is known and
is another is refused (E414) before the sample is touched. frame_salt=
compares a frame medh5 scrub pseudonymised, and the contours then carry the
grid's pseudonym. Where the RTSTRUCT or the grid states no frame, the report
records a guess. Into a sample with a label set, an ROI takes the class its
ROIName names, by key or by display name.
ROIs of one class are combined. ROIs whose names make one key — two
called "GTV", or "GTV" and "gtv" — are one class, minted or found in the
label set, and the report records a merged_rois decision naming their ROI
numbers. Their contours combine as §8.6 defines: each polygon's role is its
nesting depth among the class's polygons on its slice, so outlines of two
ROIs that cross are their union. An import where one ROI's contour lies
inside another's on a slice is refused, naming the ROIs and the slice:
combined, the inner one would be a hole in what both drew as an area.
--roi NUMBER=KEY (roi_classes={NUMBER: KEY}, repeatable) puts an ROI in
the class of that key instead — minted, or one the sample's label set holds
(E402 otherwise) — which imports same-named ROIs apart:
Each imported polygon records the slice it lies on as contour_plane
(0, k), in the grid's index space, so by_plane() on the result groups the
structure set the way the planner drew it.
The export writes patient coordinates, as DICOM defines them: millimetres,
LPS. A grid in metres is scaled and an RAS one flipped in x and y; a grid in
pixels or another convention is refused, and so are contours with no grid to
give their units. The source images must share one frame of reference, and the
contours must be in it — the annotation's frame_uid in world space, its
grid's in index space; --frame-salt compares a frame medh5 scrub
pseudonymised. Contours that state no frame are recorded as a guess.
Each ROI is named for its class's display name — where every class exported
has its own one that an ROIName holds, else for its key — and coloured as the
label set colours the class, red where it gives none; text beyond ASCII is
written as UTF-8, under SpecificCharacterSet ISO_IR 192. Every contour is
CLOSED_PLANAR, a hole being a contour nested inside its outer, which readers
that combine an ROI's contours by exclusive-or or even-odd fill read as medh5
does. Crossing contours mean their union in medh5, and those readers cancel
the overlap, so two contours of one class that cross on a plane are a warning
(crossing_contours) naming the planes: merge them into one outline where
such a reader matters. --points ANN (points=) also writes a points
annotation, one ROI per class of POINT contours of one point each, with
RTROIInterpretedType MARKER (contour ROIs are ORGAN); a row with no
class (E402) and a class in both annotations are refused.
nnU-Net v2¶
$ medh5 convert from-nnunet /Dataset001_Liver out/ --coord-system LPS --codec archive
$ medh5 convert to-nnunet /out case1.medh5 case2.medh5 --dataset-name Dataset001_Liver
from medh5.io.nnunetv2 import from_nnunetv2, to_nnunetv2
from_nnunetv2("/Dataset001_Liver", "out/")
to_nnunetv2(["case1.medh5", "case2.medh5"], "/out", dataset_name="Dataset001_Liver")
Each case's channels and per-class masks are bundled into one sample, and each
case is a subject of its own: a dataset of cases names no patients, so the
report records that as a guess (identity) — two cases of one patient are
two subjects, and a split by file is subject-safe only if no patient has two.
An image is named for its channel; nnU-Net reads channel_names as
normalisation schemes, so two channels may share a name (zscore), and those
images are named for their channel index as well (zscore_0000).
Test cases are imported on request. By default the cases are
imagesTr's, and the imagesTs cases left out are counted in a warning
(test_cases). With include_test=True (--include-test) they are
imported too, labeled from labelsTs where it has their labels, and every
case records its folder as a §12.3 split claim: set nnunetv2:<dataset> (the
dataset directory's name), partition train or test, assigned_by "nnU-Net
v2 dataset layout" and the SHA-256 of dataset.json as manifest_sha256. A
case in both folders is refused.
nnU-Net's class ids are kept, so a model trained against the original
dataset still means the same thing. A file's scl_slope/scl_inter mean what
they mean to nnU-Net: an image keeps them as its rescale, and label ids are read
after them (a scaling that makes non-integer labels is refused). Region
labels become §5.1 DAG parents: a region that is the union of two components
is a class whose components name it as a parent, which is exactly what the
hierarchy is for. nnU-Net's ignore label is the annotation's ignore
region (§7.7), both ways: never a class, and never background. A label
volume holds only what labels declares: a value it does not is refused, as
nnU-Net refuses it, rather than read as background for every class. A label
volume is written as it is, through add_segmentation(labelmap=...), each
value naming its class and every region holding it: no mask per class is
built, so a case of 104 classes costs its label volume, not 104 copies of it.
The parsed dataset.json is stashed in extra["nnunetv2"], so to-nnunet
reproduces the original dataset definition rather than inventing one: its
channel names and other fields as stashed, its labels unless a case needs them
extended or renumbered (below), and numTraining and file_ending those of
the files written. Without a stash a channel is named for its image, and a CT
image CT: nnU-Net reads the name as the channel's normalisation, and any name
but CT — CT_tp0, say — z-scores it.
The export checks the dataset before it writes a file, and a refusal of any
case refuses the export with nothing written. nnU-Net reads one
physical space per case and one label table for the dataset, so every channel
and the labels must be one lattice in one frame, convention and unit —
nothing is resampled — and the label table is the union of the cases'
classes, one name per id. Every case must have examined every class
exported (annotated_class_ids): a class one case never looked for would be
written as its background, a negative nobody observed; classes= exports a
subset all of them examined. A dataset directory that already exists may
hold only what the export writes: a NIfTI file in imagesTr, labelsTr,
imagesTs or labelsTs the export would not write — an earlier export's
dropped case or channel, a case since moved between imagesTr and
imagesTs, the other file ending — refuses it, since nnU-Net trains on every
case file it finds. An identical re-export replaces its own files, and other
files are left alone. replace=True (--replace) replaces a dataset
instead: the directory must be a directory, not a link, holding a
dataset.json; the new dataset is written over it in place, file by file,
dataset.json last, and the NIfTI files of those four folders that the
export does not write are removed after it. Every other file is left where it
is. The replace note lists the files removed and, as carried_over, those
left. Every file an export writes goes through the engine's replace (§14.4):
written beside its name and renamed over it once whole, so an export
interrupted part way leaves each file whole — the old one or the new — but
the dataset a mix of the two until it is run again.
A split places the cases. With split="SET_ID" (--split SET_ID) each
case goes where its claim in that set says (§12.3): test to imagesTs, its
labels to labelsTs; train or val to imagesTr, where nnU-Net
cross-validates. A case the set does not place, places twice, or holds out
(holdout, unassigned) is refused. Test cases are not counted in
numTraining, and an unlabeled case claiming test needs no --unlabeled
test; an unlabeled case claiming train or val is refused, even with
--unlabeled test, which would move it out of the partition it claims.
Every file is a name under the dataset. A case's files are named by its
sample_id, which must be a sample key ([A-Za-z0-9_.-], not . or ..),
and no two cases may differ only in case — one would overwrite the other on a
case-insensitive file system; --dataset-name is held to the same rule, and
file_ending= (--file-ending) is .nii.gz (the default) or .nii: every
file is NIfTI. Every
case is a training case: one without the annotation is refused, or with
unlabeled="test" (--unlabeled test) written to imagesTs and left out of
numTraining. Label values are consecutive, as nnU-Net requires: class ids
with a gap — {1, 3}, or a subset --class exports — are written as
1..K in ascending order, and dataset.json records each value's class id as
medh5_class_ids, which from-nnunet reads back, so the ids survive the round
trip. A stashed dataset.json that does not name a class some case carries is
extended with it (a region-based one is refused instead): the class was written
as background.
A case is written only if its labels come back. Region labels are painted
in regions_class_order, as nnU-Net converts them back; without one, each
class is written as its own value. Each exported label is then read back from
the volume outside the ignore region, and a case whose volume does not give one
back — a region its components do not cover, a voxel two classes share — is
refused before any file is written. These refusals carry no §15.2 code: the
samples are valid, and what they fail is nnU-Net's layout.
ITK transforms¶
$ medh5 convert from-itk-transform reg.tfm case.medh5 --id tp0_to_tp1 --fixed ct_tp0 --moving ct_tp1
$ medh5 convert from-itk-transform out0GenericAffine.mat case.medh5 --id syn \
--fixed ct_tp0 --moving ct_tp1 --warp out1Warp.nii.gz --vectors lps
$ medh5 convert to-itk-transform case.medh5 back.tfm --id syn --warp-out back_warp.nii.gz --vectors ras
from medh5.io.itk import from_itk_transform, read_itk_transform, to_itk_transform
from_itk_transform("reg.tfm", "case.medh5", transform_id="tp0_to_tp1",
fixed="ct_tp0", moving="ct_tp1")
to_itk_transform("case.medh5", "back.mat", transform_id="tp0_to_tp1")
read_itk_transform("back.mat").matrix # LPS mm, x_moving = T(x_fixed)
read_itk_transform returns a medh5.io.ItkTransform (its name, matrix
and, for a composite, components), and
medh5.io.write_itk_transform(path, transform) writes one, with no sample
involved.
ITK's convention is the format's (§10.2): a registration of a moving image
to a fixed one maps fixed points to moving points. The caller names the
two grids, in ITK's words, because a transform file names no image; the
transform is stored from the fixed grid's frame to the moving grid's. ITK's
points are LPS millimetres, so the matrix is re-expressed in each grid's
coordinate system and units — an RAS grid flips two axes, a grid in metres
scales the translation — and a grid in px, which has no millimetres, is
refused. The importer reads text files (.tfm, .txt) and ANTs' MATLAB
.mat, holding AffineTransform, MatrixOffsetTransformBase,
TranslationTransform, IdentityTransform, Euler3DTransform (with its
ZYX order where the file sets it), Euler2DTransform,
VersorRigid3DTransform, Similarity3DTransform, Similarity2DTransform,
ScaleTransform, or a CompositeTransform of them; the centre in the fixed
parameters folds into the translation. ITK's HDF5 transform files and
B-splines are refused (they wait for 2.2), as is a transform whose input and
output dimensions differ.
A composite is listed in reverse. ITK applies the last transform it lists
first; §10.5 applies the first component first. Each component is stored as an
affine between intermediate frames pseudo:<id>.mid…, and a composite
<id> relates the two grids' frames.
A displacement field needs --vectors. Some versions of ITK write a
field's vectors in LPS and others in RAS, and the file does not say which:
SimpleITK 2.5 writes a vector image's components as they are (LPS) under
NIfTI's vector intent, and reads a DISPVECT file's as RAS. An ANTs pair —
the affine and its Warp.nii.gz, the warp applied first — is stored as the
warp from the fixed frame to pseudo:<id>.mid, the affine from there to the
moving frame, and a composite <id> between the grids' frames, which earns
reg and is the one shortest route between them. The field is stored on the
fixed grid when its geometry is that grid's, otherwise on a new grid
<id>_field in the fixed frame. --inverse-warp (ANTs' InverseWarp) is
stored linked to the warp by inverse_id; its field lies in the intermediate
frame, so it is refused where that would give the fixed grid's visit a second
frame.
The export writes each matrix as AffineTransform_double_3_3 to a .tfm,
.txt or .mat file (several linear steps as an ITK composite in a text
file, or multiplied into one affine in a .mat), and a displacement applied
first to --warp-out, a DISPVECT NIfTI (intent 1006) in the --vectors
convention. Each file is written to a temporary beside it and renamed over
it.
0.x files¶
$ medh5 migrate old/*.medh5 -o new/ --write-labels labels.json
$ medh5 migrate old/*.medh5 -o new/ --label-set labels.json --report migration.json
The one-way conversion from the 0.x layout (spec Appendix B): mint one label set for the whole cohort, review it, then convert against it. Box corners shift by half a voxel, the encoding is chosen by measurement, and grouping by subject is opt-in because a 0.x file carries no subject key — each reported per file. The procedure is Migrate from 0.x.
MEDS and FHIR R4¶
$ medh5 clinical augment case.medh5 --from-meds meds/ --meds-subject 1001 --clock-id ehr --clock utc --timezone UTC
$ medh5 clinical augment case.medh5 --from-fhir bundle.json --fhir-subject Patient/p1
from medh5.io.fhir import augment_from_fhir, read_fhir
from medh5.io.meds import augment_from_meds, read_meds
One subject's clinical history, added to a sample's clinical profile (format
1.1): MEDS rows (pip install "medh5[meds]") or FHIR R4 Observations and
DiagnosticReports. No zone, origin, unit, code or availability is invented,
so the report holds no guess; free text and birth instants are imported only
when asked. The mapping tables and refusals are in
Import from MEDS or FHIR.
medh5.io.meds.COLUMNS names the MEDS columns the importer reads,
medh5.io.meds.BOTH_VALUES the choices for a row holding both values, and
medh5.io.meds.BIRTH the birth code left out unless asked;
medh5.io.fhir.STATUSES maps a FHIR status to an event's,
medh5.io.fhir.COMPARATORS a quantity's comparator to an event's, and
medh5.io.fhir.UCUM is the system a quantity's unit code is read from.
COCO¶
Not supported, deliberately — why.
Related¶
- Import from DICOM — a directory of studies to one file per patient.
- Import from NIfTI and nnU-Net — volumes and masks as separate files.
- Export to other formats — getting data back out.
- Migrate from 0.x — the one-way door.
- Import from MEDS or FHIR — a clinical history beside the images.
- What the converters refuse, and why — every refusal, with the reasoning.