Python API¶
The core of the package is reachable from the top-level medh5 namespace;
where a name lives in a sub-package, the example imports it from there.
Opening¶
import medh5
with medh5.open("case.medh5") as s: # -> Sample
...
with medh5.open_collection("shard.medh5c") as c: # -> Collection (Mapping[str, Sample])
for key, sample in c.items():
...
from medh5.collection import open_any
with open_any(path, key=None) as opened: # a Sample or a Collection, whichever it is
...
medh5.open (also exported as medh5.open_sample) is lazy: it parses /meta
and opens no arrays. Use it as a context manager, or call .close(). It is
read-only; every edit goes through medh5.amend, which is copy-on-write.
Sample¶
Identity and structure¶
s.identity # Identity: sample_id, subject_id, sex, laterality, bodypart
s.cohort # Cohort: dataset_id, site_id, scanner_id, group_id,
# acquisition_protocol
s.label_set # LabelSet, or None
s.document # SampleDocument — the whole /meta document
s.profiles # frozenset of conformance profiles
s.version # "1.0"
s.kind # "sample" or "collection"
s.content_id # "sha256:..." or None
s.path # where it was opened from
s.summary() # JSON-safe description; what `medh5 info --json` prints
Timepoints¶
s.timepoints # Timeline — indexable by position or id
s.timepoints[0].label # "baseline"
s.timepoints["tp1"].days_from_baseline
s.timepoints.ids # ("tp0", "tp1"), in acquisition order
s.timepoints.interval_days("tp0", "tp1")
s.is_longitudinal # len(timepoints) > 1
view = s.at("tp1") # a timepoint-scoped view of the whole sample
view.images # only tp1's images
view.annotations # only tp1's annotations
Time is stated once, on the grid, and inherited by everything on it. A
single-timepoint sample may leave timepoint off its grids (spec §3.7); the
reader resolves those to the one declared timepoint, so s.at("tp0"),
Image.timepoint, Annotation.timepoints and s.tracks() answer the same way
whether or not the writer wrote the attribute.
Geometry¶
s.grids # {grid_id: Grid}
s.reference_grid # the sample's principal grid
g = s.grids["ct_tp0"]
g.shape, g.spacing, g.origin, g.direction
g.affine # (n+1, n+1) index -> world
g.spatial_shape # spatial axes only, for a 4-D grid
g.coord_system # "LPS"
g.timepoint # "tp0"
g.frame_uid # frame of reference
g.physical_size # extent in mm
Images¶
img = s.images["CT_tp0"]
img.shape, img.dtype, img.chunks, img.nbytes
img.modality # "CT"
img.value_type # "quantitative"
img.value_units # "HU"
img.rescale # (slope, intercept)
img.levels # multiscale pyramid levels
img.read() # whole array, stored values
img.read(physical=True) # rescale applied
img.read((slice(0, 16), slice(0, 64), slice(0, 64))) # one block
img.dataset # the underlying h5py dataset
physical=True applies slope and intercept. When an image declares
neither, the stored values are already physical and the flag changes nothing.
Annotations¶
Every annotation, whatever its kind:
ann = s.annotations["organs_tp0"]
ann.kind # "layers" | "labelmap" | "bitmask" | "instances" |
# "probmap" | "mask" | "boxes" | "obb" |
# "keypoints" | "points" | "contours" | "mesh" |
# "classification"
ann.task # "segmentation" | "detection" | "classification" | ...
ann.grid_id, ann.grid
ann.timepoints # which visits it describes
ann.class_ids # classes it declares
ann.annotated_class_ids # classes that were examined (§11.3)
ann.is_annotated("spleen") # was this class looked for?
ann.is_fully_covered
ann.classes # LabelClass objects
ann.quality_key, ann.prov
Voxel annotations add:
ann.contains(class_id, (z, y, x))
ann.dense(["liver", "lesion"]) # (C, *spatial) bool
ann.dense(["liver"], roi=(slice(0,16),)*3)
ann.labelmap() # (*spatial) of class ids
ann.voxel_counts() # {class_id: count}
ann.class_bboxes() # {class_id: (S, 2) or None}
ann.instances() # needs instance identity — see below
Two reads span objects, so they are on the sample:
s.ignore_region("organs_tp0") # (*spatial) bool: the §7.7 ignore region,
# in band or in its sibling mask
s.valid_region("CT_tp0", roi=(slice(0, 8),) * 3) # the image's valid_mask (§4.4),
# or all True where it declares none
These are what the loaders put in item["ignore"] and item["valid"]; see
PyTorch and MONAI.
See Annotation kinds for the per-kind API.
Transforms¶
s.transforms # {transform_id: Transform}
t = s.transform_between("tp0", "tp1") # resolved through the frame graph
s.resolve_frames(frame_a, frame_b) # the same, between two frame uids; memoised per handle
t.kind # "identity" | "affine" | "displacement" |
# "bspline" | "composite"
t.from_frame, t.to_frame
t.is_invertible # the mapping is invertible
t.inverse() # the *stored* inverse, when the file has one
t.transform_points(points) # world -> world, in mm
from medh5.transforms.apply import jacobian_determinant, target_registration_error
target_registration_error(t, fixed_points, moving_points) # {"mean", "max", ...}
jacobian_determinant(field, grid) # for a displacement field
transform_between accepts a timepoint id, a grid id or a frame uid at either
end, matched in that order. It searches the frame graph, composing chains and
traversing a link backwards only where its inverse can be evaluated — an
affine's or identity's analytic inverse, or a stored inverse_id — not merely where
invertible=True is declared. It returns None when no path exists — it does
not invent one — and raises KeyError for a key that is not a timepoint, a grid
or a frame of reference in the sample, so a mistyped "TP1" is not mistaken for
"no registration exists". Registration between visits
covers what None means and when to store an inverse.
Tracking¶
tracking = s.tracks("lesion")
tracking.timepoints # ("tp0", "tp1")
tracking.states(instance_id) # {timepoint: present|resolved|unexamined}
tracking.state_at(instance_id, "tp1")
tracking.is_new(instance_id)
tracking.is_resolved(instance_id)
tracking.is_persistent(instance_id)
tracking.class_conflicts() # objects whose class changed between visits
tracking.unexamined() # {timepoint: instance ids nobody looked for}
tracking.coverage # {timepoint: class ids examined there}
for instance_id, track in tracking.items():
track.volumes # {timepoint: mm^3}
track.relative_change("tp0", "tp1")
track.at("tp1") # the Observation, or None
See Longitudinal.
Integrity¶
s.verify() # VerifyResult
s.verify(partial=["images/CT_tp0"])
s.verify().ok
s.verify().unattested # undigested datasets inside objects content_id covers
s.compute_content_id() # recompute rather than read the stored one
ok is False when a digest mismatches, when the root does, or — in a file
that declares a content_id — when a dataset inside a grid, image, annotation
or transform carries no digest at all (unattested).
Sampling index¶
s.index # {ann_id: SamplingIndex} — every entry in the file
s.fresh_indices # the ids whose source_digest still matches (§13.3)
A stale entry is ignored by the samplers and the statistics, never trusted; see Storage.
Writing¶
create and amend both return a SampleWriter. It builds a temporary file
and os.replaces it into position on a clean exit; an exception aborts and
leaves nothing behind. Without a with block, call w.commit() to finish or
w.abort() to discard.
Document¶
w.identity(sex="F", bodypart="abdomen")
w.cohort(dataset_id="d", site_id="site-A", group_id="family-7")
w.add_timepoint("tp1", index=1, label="fu1", days_from_baseline=92,
date="2026-05-04", study_uid="pseudo:...")
w.label_set(label_set)
w.extra("mytool", {"anything": "json-serialisable"})
w.acquisition("CT_tp0", kvp=120, exposure_mas=180) # imaging physics only
w.deidentification(method="dicom-psi-profile", date_shift_days=-117)
w.split(set_id="cv5", partition="train", fold=1) # replaces the same set_id
Provenance and quality¶
tool = w.software("nnU-Net", "2.4.2")
rad = w.person("RAD-07")
org = w.organization("Site A")
act = w.activity("predict", agent=tool, tool="nnUNetv2_predict",
params={"fold": "all"}, inputs=["images/CT_tp0"])
w.set_quality("organs_tp0", status="reviewed", reviewed_by=[rad.id])
Activity types: import, annotate, review, predict, resample,
register, derive, deidentify, transcode, other.
Grids and images¶
w.add_grid("ct_tp0", shape=(192, 256, 256), spacing=(1.5, 0.8, 0.8),
origin=(-144.0, -102.4, -102.4), direction=np.eye(3),
coord_system="LPS", timepoint="tp0",
frame_uid="pseudo:frame-a", patch_hint=(96, 96, 96))
w.add_image("CT_tp0", array, grid="ct_tp0", modality="CT",
value_type="quantitative", value_units="HU",
rescale_slope=1.0, rescale_intercept=-1024.0, prov=act)
w.add_pyramid("WSI", [level0, level1, level2],
grid_levels=["l0", "l1", "l2"], modality="SM")
patch_hint tells the chunk optimiser what shape you will read.
add_image also takes channel_names for a channel axis, window_center /
window_width display presets, and valid_mask — the id of a mask
annotation delimiting the acquired field of view (§4.4):
w.add_mask("fov", fov, grid="ct_tp0") # a bool volume, no classes
w.add_image("CT_tp0", array, grid="ct_tp0", modality="CT", valid_mask="fov")
Annotations¶
kind, stats = w.add_segmentation(
"organs_tp0", grid="ct_tp0",
masks={"liver": liver, "lesion": lesion}, # or probabilities= or instances=
encoding="auto", # or an explicit kind
# threshold=0.3, # with probabilities=: the contains() cut (§7.5)
annotated_classes=["liver", "spleen", "lesion"],
ignore=uncertain_mask, # voxels nobody examined (§7.7)
prov=act, quality={"status": "approved"},
)
w.add_boxes("lesions", boxes, class_ids=["lesion"], grid="ct_tp0",
space="index", scores=[0.91], instance_ids=[7])
w.add_obb("nodules", centers, sizes, rotations, class_ids=["nodule"], grid="ct")
w.add_keypoints("landmarks", points, keypoint_classes, class_ids, grid="ct")
w.add_points("fiducials_tp0", points, grid="ct",
correspondence="fiducials_tp1") # the paired point set (§10.6)
w.add_contours("rtstruct", polygons, grid="ct", space="world")
w.add_mesh("surface", vertices, faces, space="world")
w.add_classification("response", {"progressive": 1.0}, scope="sample",
timepoints=["tp0", "tp1"]) # the interval, not one visit
ignore= is stored wherever the chosen encoding can hold it. labelmap and
layers carry it in band; under bitmask, instances or probmap — and under
every encoding when the region overlaps a class — the writer stores it as a
sibling mask annotation named <ann_id>_ignore and sets ignore_mask on the
header, so encoding="auto" never decides whether the region survives, and it
reads back equal to what you gave. ignore_mask= names a mask you wrote
yourself instead; passing both is refused.
Pass exactly one of masks=, probabilities= and instances=, with
encoding="auto" or the encoding the argument implies; anything else is
refused rather than half-honoured. instances=[] with annotated_classes=
records "examined, none found" (§7.4).
Every commit() runs the validator's structural and semantic error rules over
the finished file and refuses to write one it would reject, so a file this
writer produces passes medh5 validate at the default level. Grid units must
be one of mm, um, m, px (§3.2).
Transforms¶
w.add_transform("tp0_to_tp1", kind="affine",
from_frame="pseudo:frame-a", to_frame="pseudo:frame-b",
matrix=matrix4x4, invertible=True)
w.add_transform("warp", kind="displacement",
from_frame="a", to_frame="b",
field=field, field_grid="ct_tp0", vector_space="world")
Derived data¶
w.build_index() # sampling indices for every voxel annotation
w.build_index(["organs_tp0"], max_coords=8192)
w.transcode_annotation("organs_tp0", "bitmask")
w.remove_annotation("old_seg") # takes its index with it
w.remap_frame_uids({"old-frame": "new-frame"}) # grids, transforms, world-space
# annotations — all at once
w.infer_profiles() # the profiles the content satisfies
Amending¶
Copy-on-write: a new file is built from the old and replaced atomically.
Objects this reader does not understand — including ones written by a future
minor version — are copied through untouched, so amending never silently drops
what it cannot read. Anything holding the file open across an amend keeps
reading the old version.
Collections¶
medh5.pack(["case.medh5", "case2.medh5"], "pair.medh5c") # keys default to file stems
medh5.unpack("pair.medh5c", "restored/", keys=["case"]) # -> restored/case.medh5
Packing moves chunks as stored bytes, so every member keeps its content_id;
see Storage.
Validation¶
from medh5.validate import validate_file, validate_paths
report = validate_file("case.medh5", level="strict", profiles=["seg"])
report.ok
report.errors # [Diagnostic]
report.warnings
report.diagnostics[0].code # "E102"
report.diagnostics[0].location
print(report.format(verbose=True))
Levels: structural → semantic → integrity → strict. Codes are stable
API; see spec §15.2 and medh5.CODES. validate_paths takes several files and
returns one report per file, which is what medh5 validate prints.
Exceptions¶
MEDH5Error
├── MEDH5FileError (also OSError)
├── MEDH5VersionError
├── MEDH5SchemaError
├── MEDH5ValidationError (also ValueError) — carries .code
└── MEDH5IntegrityError
MEDH5ValidationError.code is the §15.2 diagnostic code where one applies,
so a caller can branch on the defect rather than on the message text.
Sub-packages¶
medh5.torch |
Datasets and samplers |
medh5.monai |
MetaTensor adapter |
medh5.io |
Converters |
medh5.dataset |
Cohort manifests, splits, statistics |
medh5.curation |
Provenance, agreement, tracking, de-identification |
medh5.conformance |
The conformance suite |
medh5.storage |
Codecs, chunking, recompression |
Related¶
- Write and read your first sample — this API end to end.
- How-to guides — the same calls, arranged by task.
- Sample document schema — the fields the writer writes.
- Specification — the normative model behind it.