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..9fdf53b --- /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 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" + + ds = pydicom.dcmread(pydicom.data.get_testdata_file("MR_small.dcm")) + + #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 = series_number + + # 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': mrd_h5, 'dcmdir': temp_dicom_dir} + + +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..957c922 --- /dev/null +++ b/tests/test_invert.py @@ -0,0 +1,23 @@ +import os + +import numpy as np +from helpers import MAX, mrd_data, checkers, write_example + + +def test_invert(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) + 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"