From 746cfd6bb23c824a69ac6f967b9640297b5fb0c1 Mon Sep 17 00:00:00 2001 From: WillForan Date: Tue, 25 Nov 2025 17:01:30 -0500 Subject: [PATCH 1/2] feat: tests * `single-shot.sh` runs a server just long enough for a single shot client to run * tests/helpers has functions to * generates a 4x4x4 checker board and * read data matrix from mrd h5 files * `tests/test_invert.py` inverts that using single-shot.sh * `Makefile` has test and format runner not running black nor isort on existing files * `pyproject.toml` is useful for `pip install -e .[dev]` --- .gitignore | 8 ++++- Makefile | 31 ++++++++++++++++++ pyproject.toml | 29 +++++++++++++++++ single-shot.sh | 25 +++++++++++++++ tests/helpers/__init__.py | 66 +++++++++++++++++++++++++++++++++++++++ tests/test_invert.py | 21 +++++++++++++ 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100755 single-shot.sh create mode 100644 tests/helpers/__init__.py create mode 100644 tests/test_invert.py diff --git a/.gitignore b/.gitignore index c4f15c1..813917a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,10 @@ .virtualenv *.tar *.zip -data/ \ No newline at end of file +data/ +.venv/ +uv.lock +*egg-info/ +# make sentinel files +.test +.format diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ae57876 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.SUFFIXES: +# phony runners, but actual sentinel files so only rerun when needed +.PHONY: test format +test: .test +format: .format + +SHELL := /usr/bin/bash +PYTHON ?= python3 +PYTHON_FILES=$(wildcard *.py tests/*py tests/helpers/*.py) + +.ONESHELL: # source in same shell as pytest +.test: $(PYTHON_FILES) single-shot.sh | .venv/ + source .venv/bin/activate + $(PYTHON) -m pytest tests | tee $@ + +# don't format all. Would be a big git revision +.format: $(wildcard test/*py tests/helpers/*.py) #$(PYTHON_FILES) + isort $? | tee $@ + black $? | tee -a $@ + +# if we don't have .venv, make it and install this package +# use 'uv' if we have it +.ONESHELL: +.venv/: + if command -v uv; then + uv venv .venv; + uv pip install -e .[dev]; + else + $(PYTHON) -m venv .venv/; + $(PYTHON) -m pip install -e .[dev]; + fi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7cf6e21 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "python-ismrmrd-server" +version = "0.1.0" +description = "MRD client/server pair and MRD format convertion utilities" +readme = "readme.md" +requires-python = ">=3.12" +dependencies = [ + "ismrmrd>=1.14.2", + "matplotlib>=3.10.7", + "numpy>=2.3.4", + "pydicom>=3.0.1", +] +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "isort", +] +[tool.setuptools.packages.find] +# Include all packages found under "src" +include = ["*py"] +# Exclude tests and docs directories +exclude = ["docker", "*sh", "tests/"] + +[pytest] +tmp_path_retention_count = 1 + +[tool.pytest.ini_options] +norecursedirs = "tests/helpers" diff --git a/single-shot.sh b/single-shot.sh new file mode 100755 index 0000000..9d24f62 --- /dev/null +++ b/single-shot.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# fork the server for a single shot client call +# use enviornment to adjust parameters +# + + +PORT=${PORT:-9002} +INFILE=${INFILE:-in/example.h5} +OUTFILE=${OUTFILE:-/tmp/examle.h5} +CONFIG=${CONFIG:-invertcontrast} +OUTGROUP=${OUTGROUP:-dataset} # ismrmr's default + +scriptdir="$(cd $(dirname "$0"); pwd)" + +# Make output directory if needed +mkdir -p "$(dirname "$OUTFILE")" + +python $scriptdir/main.py -p $PORT & +pid_server=$! +python $scriptdir/client.py "$INFILE" -p $PORT -o "$OUTFILE" -c $CONFIG -G "$OUTGROUP" + +kill $pid_server +wait $pid_server + diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..3a293c8 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,66 @@ +import sys + +import ismrmrd +import numpy as np +import pydicom +import pydicom.data + +import dicom2mrd + +MAX = 4095 # 12-bit data + + +def write_example(outdir): + mrd_h5 = outdir / "checker.h5" + + # 1. create a 4x4x4 dicom image with siemens header (copied from pydicom.data) + ds = pydicom.dcmread(pydicom.data.get_testdata_file("MR_small.dcm")) + + # 2. make a checkerboard pattern of min / max values in the 3D image + checker_data = np.zeros((4, 4, 4), dtype=np.uint16) + min_val = 0 + max_val = MAX + ds.Rows = 4 + ds.Columns = 4 + ds.NumberOfFrames = 4 + ds.SeriesDescription = "Test Checkerboard" + ds.MagneticFieldStrength = 1.5 + ds.AcquisitionTime = "120000.000000" + ds.SeriesNumber = 1 + + for z in range(ds.NumberOfFrames): + for y in range(ds.Columns): + for x in range(ds.Rows): + if (x + y + z) % 2 == 0: + checker_data[z, y, x] = max_val + else: + checker_data[z, y, x] = min_val + + # Update DICOM dataset with checkerboard data and add missing fields + ds.PixelData = checker_data.tobytes() + + # Add missing required fields + + # Create temporary DICOM folder and file + temp_dicom_dir = outdir / "temp_dicoms" + temp_dicom_dir.mkdir() + temp_dicom = temp_dicom_dir / "temp.dcm" + ds.save_as(temp_dicom) + + # 3. use dicom2mrd to make a mrd h5 file + args = dicom2mrd.argparse.Namespace( + folder=temp_dicom_dir, outFile=mrd_h5, outGroup="dataset" + ) + dicom2mrd.main(args) + + return mrd_h5 + + +def mrd_data(filename, group="dataset"): + """Read MRD image data from file""" + dataset = ismrmrd.Dataset(filename, group, False) + + # Check what image groups are available + dataset_list = dataset.list() + image_groups = [name for name in dataset_list if name.startswith("image")] + return np.squeeze(dataset.read_image(image_groups[0], 0).data) diff --git a/tests/test_invert.py b/tests/test_invert.py new file mode 100644 index 0000000..91f4dbc --- /dev/null +++ b/tests/test_invert.py @@ -0,0 +1,21 @@ +import os + +import numpy as np +from helpers import MAX, mrd_data, write_example + + +def test_invert(tmp_path): + in_file = write_example(tmp_path) + out_file = tmp_path / "out.h5" + + os.environ["INFILE"] = str(in_file) + os.environ["OUTFILE"] = str(out_file) + os.system("./single-shot.sh") + + # Read mrd files and extract numpy matrix + in_data = mrd_data(in_file) + out_data = mrd_data(out_file) + abs_diff = np.abs(out_data - in_data) + assert in_data[0, 0, 0] == MAX, "input as expected is 0" + assert out_data[0, 0, 0] == 0, "output is inverted" + assert np.all(abs_diff == MAX), "all voxels inverted" From 6273cb88d660c25aedc0e0c960f79e6cdf549c1e Mon Sep 17 00:00:00 2001 From: WillForan Date: Tue, 25 Nov 2025 21:17:54 -0500 Subject: [PATCH 2/2] refactor(tests/helpers): separate data gen, return mrd and dicom paths --- tests/helpers/__init__.py | 48 +++++++++++++++++++-------------------- tests/test_invert.py | 6 +++-- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 3a293c8..9fdf53b 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -10,36 +10,36 @@ MAX = 4095 # 12-bit data -def write_example(outdir): +def checkers(nz, ny, nx): + checker_data = np.zeros((nz,ny, nx), dtype=np.uint16) + for z in range(nz): + for y in range(ny): + for x in range(nx): + if (x + y + z) % 2 == 0: + checker_data[z, y, x] = MAX + else: + checker_data[z, y, x] = 0 + return checker_data + + +def write_example(data, outdir, series_number=1): + """ + Write example data files in mdr.h5 and dicom/ formats + :param outdir: where to save, likely `tmp_path` from pytest + :param series_number: dicom header information + :returns: dict with 'mrd' and 'dcmdir' keys + """ mrd_h5 = outdir / "checker.h5" - # 1. create a 4x4x4 dicom image with siemens header (copied from pydicom.data) ds = pydicom.dcmread(pydicom.data.get_testdata_file("MR_small.dcm")) - # 2. make a checkerboard pattern of min / max values in the 3D image - checker_data = np.zeros((4, 4, 4), dtype=np.uint16) - min_val = 0 - max_val = MAX - ds.Rows = 4 - ds.Columns = 4 - ds.NumberOfFrames = 4 + #ds.PixelData = checkers(ds.NumberOfFrames,ds.Columns,ds.Rows).tobytes() + ds.PixelData = data.tobytes() + (ds.NumberOfFrames, ds.Columns, ds.Rows) = data.shape ds.SeriesDescription = "Test Checkerboard" ds.MagneticFieldStrength = 1.5 ds.AcquisitionTime = "120000.000000" - ds.SeriesNumber = 1 - - for z in range(ds.NumberOfFrames): - for y in range(ds.Columns): - for x in range(ds.Rows): - if (x + y + z) % 2 == 0: - checker_data[z, y, x] = max_val - else: - checker_data[z, y, x] = min_val - - # Update DICOM dataset with checkerboard data and add missing fields - ds.PixelData = checker_data.tobytes() - - # Add missing required fields + ds.SeriesNumber = series_number # Create temporary DICOM folder and file temp_dicom_dir = outdir / "temp_dicoms" @@ -53,7 +53,7 @@ def write_example(outdir): ) dicom2mrd.main(args) - return mrd_h5 + return {'mrd': mrd_h5, 'dcmdir': temp_dicom_dir} def mrd_data(filename, group="dataset"): diff --git a/tests/test_invert.py b/tests/test_invert.py index 91f4dbc..957c922 100644 --- a/tests/test_invert.py +++ b/tests/test_invert.py @@ -1,11 +1,13 @@ import os import numpy as np -from helpers import MAX, mrd_data, write_example +from helpers import MAX, mrd_data, checkers, write_example def test_invert(tmp_path): - in_file = write_example(tmp_path) + data = checkers(4,4,4) + examples = write_example(data, tmp_path) + in_file = examples["mrd"] out_file = tmp_path / "out.h5" os.environ["INFILE"] = str(in_file)