Skip to content

Commit d6adb42

Browse files
authored
Add ProtocolSerializer API for streaming (#82)
* Add ProtocolSerializer implementation and tests * Add stream recon example and better end-to-end test * Bump version to match ISMRMRD 1.14.2
1 parent a29ddb4 commit d6adb42

File tree

17 files changed

+895
-88
lines changed

17 files changed

+895
-88
lines changed

.github/workflows/python-publish.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ jobs:
2222
run: python -m pip install -r requirements.txt --user
2323
- name: Perform editable installation to generate the schema subpackage
2424
run: python -m pip install -e .
25-
- name: Run all tests
25+
- name: Run library tests
2626
run: python -m pytest
27+
- name: Run end-to-end tests
28+
run: bash tests/end-to-end/test-reconstruction.sh
2729
- name: Install pypa/build
2830
run: python -m pip install build --user
2931
- name: Build a binary wheel and a source tarball

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
*~
22
*.h5
3+
*.ismrmrd
34
*.pyc
45
MANIFEST
56
build/

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Python implementation of the ISMRMRD
1+
Python implementation of [ISMRMRD](https://github.com/ismrmrd/ismrmrd)

examples/demo.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

examples/stream_recon.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import sys
2+
import argparse
3+
import numpy as np
4+
from typing import BinaryIO, Iterable, Union
5+
6+
from ismrmrd import Acquisition, Image, ImageHeader, ProtocolDeserializer, ProtocolSerializer
7+
from ismrmrd.xsd import ismrmrdHeader
8+
from ismrmrd.constants import ACQ_IS_NOISE_MEASUREMENT, IMTYPE_MAGNITUDE
9+
from ismrmrd.serialization import SerializableObject
10+
11+
from numpy.fft import fftshift, ifftshift, fftn, ifftn
12+
13+
14+
def kspace_to_image(k: np.ndarray, dim=None, img_shape=None) -> np.ndarray:
15+
""" Computes the Fourier transform from k-space to image space
16+
along a given or all dimensions
17+
18+
:param k: k-space data
19+
:param dim: vector of dimensions to transform
20+
:param img_shape: desired shape of output image
21+
:returns: data in image space (along transformed dimensions)
22+
"""
23+
if not dim:
24+
dim = range(k.ndim)
25+
img = fftshift(ifftn(ifftshift(k, axes=dim), s=img_shape, axes=dim), axes=dim)
26+
img *= np.sqrt(np.prod(np.take(img.shape, dim)))
27+
return img
28+
29+
30+
def image_to_kspace(img: np.ndarray, dim=None, k_shape=None) -> np.ndarray:
31+
""" Computes the Fourier transform from image space to k-space space
32+
along a given or all dimensions
33+
34+
:param img: image space data
35+
:param dim: vector of dimensions to transform
36+
:param k_shape: desired shape of output k-space data
37+
:returns: data in k-space (along transformed dimensions)
38+
"""
39+
if not dim:
40+
dim = range(img.ndim)
41+
k = fftshift(fftn(ifftshift(img, axes=dim), s=k_shape, axes=dim), axes=dim)
42+
k /= np.sqrt(np.prod(np.take(img.shape, dim)))
43+
return k
44+
45+
46+
def acquisition_reader(input: Iterable[SerializableObject]) -> Iterable[Acquisition]:
47+
for item in input:
48+
if not isinstance(item, Acquisition):
49+
# Skip non-acquisition items
50+
continue
51+
if item.flags & ACQ_IS_NOISE_MEASUREMENT:
52+
# Currently ignoring noise scans
53+
continue
54+
yield item
55+
56+
def stream_item_sink(input: Iterable[Union[Acquisition, Image]]) -> Iterable[SerializableObject]:
57+
for item in input:
58+
if isinstance(item, Acquisition):
59+
yield item
60+
elif isinstance(item, Image) and item.data.dtype == np.float32:
61+
yield item
62+
else:
63+
raise ValueError("Unknown item type")
64+
65+
def remove_oversampling(head: ismrmrdHeader, input: Iterable[Acquisition]) -> Iterable[Acquisition]:
66+
enc = head.encoding[0]
67+
68+
if enc.encodedSpace and enc.encodedSpace.matrixSize and enc.reconSpace and enc.reconSpace.matrixSize:
69+
eNx = enc.encodedSpace.matrixSize.x
70+
rNx = enc.reconSpace.matrixSize.x
71+
else:
72+
raise Exception('Encoding information missing from header')
73+
74+
for acq in input:
75+
if eNx != rNx and acq.number_of_samples == eNx:
76+
xline = kspace_to_image(acq.data, [1])
77+
x0 = (eNx - rNx) // 2
78+
x1 = x0 + rNx
79+
xline = xline[:, x0:x1]
80+
head = acq.getHead()
81+
head.center_sample = rNx // 2
82+
data = image_to_kspace(xline, [1])
83+
acq = Acquisition(head, data)
84+
yield acq
85+
86+
def accumulate_fft(head: ismrmrdHeader, input: Iterable[Acquisition]) -> Iterable[Image]:
87+
enc = head.encoding[0]
88+
89+
# Matrix size
90+
if enc.encodedSpace and enc.reconSpace and enc.encodedSpace.matrixSize and enc.reconSpace.matrixSize:
91+
eNx = enc.encodedSpace.matrixSize.x
92+
eNy = enc.encodedSpace.matrixSize.y
93+
eNz = enc.encodedSpace.matrixSize.z
94+
rNx = enc.reconSpace.matrixSize.x
95+
rNy = enc.reconSpace.matrixSize.y
96+
rNz = enc.reconSpace.matrixSize.z
97+
else:
98+
raise Exception('Required encoding information not found in header')
99+
100+
# Field of view
101+
if enc.reconSpace and enc.reconSpace.fieldOfView_mm:
102+
rFOVx = enc.reconSpace.fieldOfView_mm.x
103+
rFOVy = enc.reconSpace.fieldOfView_mm.y
104+
rFOVz = enc.reconSpace.fieldOfView_mm.z if enc.reconSpace.fieldOfView_mm.z else 1
105+
else:
106+
raise Exception('Required field of view information not found in header')
107+
108+
# Number of Slices, Reps, Contrasts, etc.
109+
ncoils = 1
110+
if head.acquisitionSystemInformation and head.acquisitionSystemInformation.receiverChannels:
111+
ncoils = head.acquisitionSystemInformation.receiverChannels
112+
113+
nslices = 1
114+
if enc.encodingLimits and enc.encodingLimits.slice != None:
115+
nslices = enc.encodingLimits.slice.maximum + 1
116+
117+
ncontrasts = 1
118+
if enc.encodingLimits and enc.encodingLimits.contrast != None:
119+
ncontrasts = enc.encodingLimits.contrast.maximum + 1
120+
121+
ky_offset = 0
122+
if enc.encodingLimits and enc.encodingLimits.kspace_encoding_step_1 != None:
123+
ky_offset = int((eNy+1)/2) - enc.encodingLimits.kspace_encoding_step_1.center
124+
125+
current_rep = -1
126+
reference_acquisition = None
127+
buffer = None
128+
image_index = 0
129+
130+
def produce_image(buffer: np.ndarray, ref_acq: Acquisition) -> Iterable[Image]:
131+
nonlocal image_index
132+
133+
if buffer.shape[-3] > 1:
134+
img = kspace_to_image(buffer, dim=[-1, -2, -3])
135+
else:
136+
img = kspace_to_image(buffer, dim=[-1, -2])
137+
138+
for contrast in range(img.shape[0]):
139+
for islice in range(img.shape[1]):
140+
slice = img[contrast, islice]
141+
combined = np.squeeze(np.sqrt(np.abs(np.sum(slice * np.conj(slice), axis=0)).astype('float32')))
142+
143+
xoffset = (combined.shape[-1] + 1) // 2 - (rNx+1) // 2
144+
yoffset = (combined.shape[-2] + 1) // 2 - (rNy+1) // 2
145+
if len(combined.shape) == 3:
146+
zoffset = (combined.shape[-3] + 1) // 2 - (rNz+1) // 2
147+
combined = combined[zoffset:(zoffset+rNz), yoffset:(yoffset+rNy), xoffset:(xoffset+rNx)]
148+
combined = np.reshape(combined, (1, combined.shape[-3], combined.shape[-2], combined.shape[-1]))
149+
elif len(combined.shape) == 2:
150+
combined = combined[yoffset:(yoffset+rNy), xoffset:(xoffset+rNx)]
151+
combined = np.reshape(combined, (1, 1, combined.shape[-2], combined.shape[-1]))
152+
else:
153+
raise Exception('Array img_combined should have 2 or 3 dimensions')
154+
155+
imghdr = ImageHeader(image_type=IMTYPE_MAGNITUDE)
156+
imghdr.version = 1
157+
imghdr.measurement_uid = ref_acq.measurement_uid
158+
imghdr.field_of_view[0] = rFOVx
159+
imghdr.field_of_view[1] = rFOVy
160+
imghdr.field_of_view[2] = rFOVz/rNz
161+
imghdr.position = ref_acq.position
162+
imghdr.read_dir = ref_acq.read_dir
163+
imghdr.phase_dir = ref_acq.phase_dir
164+
imghdr.slice_dir = ref_acq.slice_dir
165+
imghdr.patient_table_position = ref_acq.patient_table_position
166+
imghdr.average = ref_acq.idx.average
167+
imghdr.slice = ref_acq.idx.slice
168+
imghdr.contrast = contrast
169+
imghdr.phase = ref_acq.idx.phase
170+
imghdr.repetition = ref_acq.idx.repetition
171+
imghdr.set = ref_acq.idx.set
172+
imghdr.acquisition_time_stamp = ref_acq.acquisition_time_stamp
173+
imghdr.physiology_time_stamp = ref_acq.physiology_time_stamp
174+
imghdr.image_index = image_index
175+
image_index += 1
176+
177+
mrd_image = Image(head=imghdr, data=combined)
178+
yield mrd_image
179+
180+
for acq in input:
181+
if acq.idx.repetition != current_rep:
182+
# If we have a current buffer pass it on
183+
if buffer is not None and reference_acquisition is not None:
184+
yield from produce_image(buffer, reference_acquisition)
185+
186+
# Reset buffer
187+
if acq.data.shape[-1] == eNx:
188+
readout_length = eNx
189+
else:
190+
readout_length = rNx # Readout oversampling has been removed upstream
191+
192+
buffer = np.zeros((ncontrasts, nslices, ncoils, eNz, eNy, readout_length), dtype=np.complex64)
193+
current_rep = acq.idx.repetition
194+
reference_acquisition = acq
195+
196+
# Stuff into the buffer
197+
if buffer is not None:
198+
contrast = acq.idx.contrast if acq.idx.contrast is not None else 0
199+
slice = acq.idx.slice if acq.idx.slice is not None else 0
200+
k1 = acq.idx.kspace_encode_step_1 if acq.idx.kspace_encode_step_1 is not None else 0
201+
k2 = acq.idx.kspace_encode_step_2 if acq.idx.kspace_encode_step_2 is not None else 0
202+
buffer[contrast, slice, :, k2, k1 + ky_offset, :] = acq.data
203+
204+
if buffer is not None and reference_acquisition is not None:
205+
yield from produce_image(buffer, reference_acquisition)
206+
buffer = None
207+
reference_acquisition = None
208+
209+
def reconstruct_ismrmrd_stream(input: BinaryIO, output: BinaryIO):
210+
with ProtocolDeserializer(input) as reader, ProtocolSerializer(output) as writer:
211+
stream = reader.deserialize()
212+
head = next(stream, None)
213+
if head is None:
214+
raise Exception("Could not read ISMRMRD header")
215+
if not isinstance(head, ismrmrdHeader):
216+
raise Exception("First item in stream is not an ISMRMRD header")
217+
writer.serialize(head)
218+
for item in stream_item_sink(
219+
accumulate_fft(head,
220+
remove_oversampling(head,
221+
acquisition_reader(stream)))):
222+
writer.serialize(item)
223+
224+
if __name__ == "__main__":
225+
parser = argparse.ArgumentParser(description="Reconstructs an ISMRMRD stream")
226+
parser.add_argument('-i', '--input', type=str, required=False, help="Input stream, defaults to stdin")
227+
parser.add_argument('-o', '--output', type=str, required=False, help="Output stream, defaults to stdout")
228+
args = parser.parse_args()
229+
230+
input = args.input if args.input is not None else sys.stdin.buffer
231+
output = args.output if args.output is not None else sys.stdout.buffer
232+
233+
reconstruct_ismrmrd_stream(input, output)

ismrmrd/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from .constants import *
2-
from .acquisition import *
3-
from .image import *
2+
from .acquisition import AcquisitionHeader, Acquisition, EncodingCounters
3+
from .image import ImageHeader, Image
44
from .hdf5 import Dataset
55
from .meta import Meta
6-
from .waveform import *
6+
from .waveform import WaveformHeader, Waveform
77
from .file import File
8+
from .serialization import ProtocolSerializer, ProtocolDeserializer
89

910
from . import xsd
1011

ismrmrd/file.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import h5py
2-
import numpy
2+
import numpy as np
33

4-
from .hdf5 import *
5-
from .acquisition import *
6-
from .waveform import *
7-
from .image import *
8-
from .xsd import ToXML
4+
from .hdf5 import acquisition_header_dtype, acquisition_dtype, waveform_header_dtype, waveform_dtype, image_header_dtype
5+
from .acquisition import Acquisition
6+
from .waveform import Waveform
7+
from .image import Image
8+
from .xsd import ToXML, CreateFromDocument
99

1010

1111
class DataWrapper:
@@ -32,7 +32,7 @@ def __setitem__(self, key, value):
3232
except TypeError:
3333
iterable = [self.to_numpy(value)]
3434

35-
self.data[key] = numpy.array(iterable, dtype=self.datatype)
35+
self.data[key] = np.array(iterable, dtype=self.datatype)
3636

3737
def __repr__(self):
3838
return type(self).__name__ + " containing " + self.data.__repr__()
@@ -98,7 +98,7 @@ def from_numpy(cls, raw):
9898
# of padding and alignment. Nu guarantees are given, so we need create a structured array
9999
# with a header to have the contents filled in correctly. We start with an array of
100100
# zeroes to avoid garbage in the padding bytes.
101-
header_array = numpy.zeros((1,), dtype=waveform_header_dtype)
101+
header_array = np.zeros((1,), dtype=waveform_header_dtype)
102102
header_array[0] = raw['head']
103103

104104
waveform = Waveform(header_array)
@@ -149,9 +149,9 @@ def __setitem__(self, key, value):
149149
except TypeError:
150150
iterable = [self.to_numpy(value)]
151151

152-
self.headers[key] = numpy.stack([header for header, _, __ in iterable])
153-
self.data[key] = numpy.stack([data for _, data, __ in iterable])
154-
self.attributes[key] = numpy.stack([attributes for _, __, attributes in iterable])
152+
self.headers[key] = np.stack([header for header, _, __ in iterable])
153+
self.data[key] = np.stack([data for _, data, __ in iterable])
154+
self.attributes[key] = np.stack([attributes for _, __, attributes in iterable])
155155

156156
@classmethod
157157
def from_numpy(cls, header, data, attributes):
@@ -252,7 +252,7 @@ def __set_acquisitions(self, acquisitions):
252252
if self.has_images():
253253
raise TypeError("Cannot add acquisitions when images are present.")
254254

255-
buffer = numpy.array([Acquisitions.to_numpy(a) for a in acquisitions], dtype=acquisition_dtype)
255+
buffer = np.array([Acquisitions.to_numpy(a) for a in acquisitions], dtype=acquisition_dtype)
256256

257257
self.__del_acquisitions()
258258
self._contents.create_dataset('data',data=buffer,maxshape=(None,),chunks=True)
@@ -275,7 +275,7 @@ def __set_waveforms(self, waveforms):
275275
raise TypeError("Cannot add waveforms when images are present.")
276276

277277
converter = Waveforms(None)
278-
buffer = numpy.array([converter.to_numpy(w) for w in waveforms], dtype=waveform_dtype)
278+
buffer = np.array([converter.to_numpy(w) for w in waveforms], dtype=waveform_dtype)
279279

280280
self.__del_waveforms()
281281
self._contents.create_dataset('waveforms', data=buffer, maxshape=(None,),chunks=True)
@@ -303,9 +303,9 @@ def __set_images(self, images):
303303

304304
images = list(images)
305305

306-
data = numpy.stack([image.data for image in images])
307-
headers = numpy.stack([np.frombuffer(image.getHead(), dtype=image_header_dtype) for image in images])
308-
attributes = numpy.stack([image.attribute_string for image in images])
306+
data = np.stack([image.data for image in images])
307+
headers = np.stack([np.frombuffer(image.getHead(), dtype=image_header_dtype) for image in images])
308+
attributes = np.stack([image.attribute_string for image in images])
309309

310310
self.__del_images()
311311
self._contents.create_dataset('data', data=data)
@@ -323,7 +323,7 @@ def __del_images(self):
323323
def __get_header(self):
324324
if not self.has_header():
325325
return None
326-
return ismrmrd.xsd.CreateFromDocument(self._contents['xml'][0])
326+
return CreateFromDocument(self._contents['xml'][0])
327327

328328
def __set_header(self, header):
329329
self.__del_header()

ismrmrd/image.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,14 +293,10 @@ def meta(self, val):
293293
raise RuntimeError("meta must be of type Meta or dict")
294294

295295
@property
296-
def matrix_size(self, warn=True):
297-
if warn:
298-
warnings.warn(
299-
"This function currently returns a result that is inconsistent (transposed) " +
300-
"compared to the matrix_size in the ImageHeader and from .getHead().matrix_size. " +
301-
"This function will be made consistent in a future version and this message " +
302-
"will be removed."
303-
)
296+
def matrix_size(self):
297+
"""This function currently returns a result that is inconsistent (transposed)
298+
compared to the matrix_size in the ImageHeader and from .getHead().matrix_size.
299+
This function will be made consistent in a future version and this message will be removed."""
304300
return self.__data.shape[1:4]
305301

306302
@property

0 commit comments

Comments
 (0)