Skip to content

ENH: Add MZ3 format #1066

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions nibabel/arrayproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@
See :mod:`nibabel.minc1`, :mod:`nibabel.ecat` and :mod:`nibabel.parrec` for
examples.
"""

_default_order = 'F'

def __init__(self, file_like, spec, *, mmap=True, order=None, keep_file_open=None):
def __init__(self, file_like, spec, *,
mmap=True, keep_file_open=None, compression=None):
"""Initialize array proxy instance

Parameters
Expand Down Expand Up @@ -166,6 +166,7 @@
If ``file_like`` is an open file handle, this setting has no
effect. The default value (``None``) will result in the value of
``KEEP_FILE_OPEN_DEFAULT`` being used.
compression : { None, "gz", "bz2", "zst" }, optional, keyword only
"""
if mmap not in (True, False, 'c', 'r'):
raise ValueError("mmap should be one of {True, False, 'c', 'r'}")
Expand Down Expand Up @@ -209,9 +210,11 @@
if order is None:
order = self._default_order
self.order = order
self._compression = compression

Check warning on line 213 in nibabel/arrayproxy.py

View check run for this annotation

Codecov / codecov/patch

nibabel/arrayproxy.py#L213

Added line #L213 was not covered by tests
# Flags to keep track of whether a single ImageOpener is created, and
# whether a single underlying file handle is created.
self._keep_file_open, self._persist_opener = self._should_keep_file_open(keep_file_open)
self._keep_file_open, self._persist_opener = \

Check warning on line 216 in nibabel/arrayproxy.py

View check run for this annotation

Codecov / codecov/patch

nibabel/arrayproxy.py#L216

Added line #L216 was not covered by tests
self._should_keep_file_open(file_like, keep_file_open, compression)
self._lock = RLock()

def _has_fh(self) -> bool:
Expand Down Expand Up @@ -254,7 +257,7 @@
self.__dict__.update(state)
self._lock = RLock()

def _should_keep_file_open(self, keep_file_open):
def _should_keep_file_open(self, keep_file_open, compression):
"""Called by ``__init__``.

This method determines how to manage ``ImageOpener`` instances,
Expand Down Expand Up @@ -330,7 +333,8 @@
if self._has_fh():
return False, False
# if the file is a gzip file, and we have_indexed_gzip,
have_igzip = openers.HAVE_INDEXED_GZIP and self.file_like.endswith('.gz')
have_igzip = openers.HAVE_INDEXED_GZIP and (compression in ("gz", ".gz") or

Check warning on line 336 in nibabel/arrayproxy.py

View check run for this annotation

Codecov / codecov/patch

nibabel/arrayproxy.py#L336

Added line #L336 was not covered by tests
file_like.endswith('.gz'))

persist_opener = keep_file_open or have_igzip
return keep_file_open, persist_opener
Expand Down Expand Up @@ -378,10 +382,18 @@
"""
if self._persist_opener:
if not hasattr(self, '_opener'):
self._opener = openers.ImageOpener(self.file_like, keep_open=self._keep_file_open)
self._opener = openers.ImageOpener(

Check warning on line 385 in nibabel/arrayproxy.py

View check run for this annotation

Codecov / codecov/patch

nibabel/arrayproxy.py#L385

Added line #L385 was not covered by tests
self.file_like,
keep_open=self._keep_file_open,
compression=self._compression,
)
yield self._opener
else:
with openers.ImageOpener(self.file_like, keep_open=False) as opener:
with openers.ImageOpener(

Check warning on line 392 in nibabel/arrayproxy.py

View check run for this annotation

Codecov / codecov/patch

nibabel/arrayproxy.py#L392

Added line #L392 was not covered by tests
self.file_like,
keep_open=False,
compression=self._compression,
) as opener:
yield opener

def _get_unscaled(self, slicer):
Expand Down
11 changes: 8 additions & 3 deletions nibabel/openers.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
specified, is `rb`. ``compresslevel``, if relevant, and not specified,
is set from class variable ``default_compresslevel``. ``keep_open``, if
relevant, and not specified, is ``False``.
compression : { None, "gz", "bz2", "zst" }, optional, keyworld only
\*\*kwargs : keyword arguments
passed to opening method when `fileish` is str. Change of defaults as
for \*args
Expand Down Expand Up @@ -153,13 +154,13 @@

fobj: io.IOBase

def __init__(self, fileish: str | io.IOBase, *args, **kwargs):
def __init__(self, fileish: str | io.IOBase, *args, compression: str | None, **kwargs):
if isinstance(fileish, (io.IOBase, Fileish)):
self.fobj = fileish
self.me_opened = False
self._name = getattr(fileish, 'name', None)
return
opener, arg_names = self._get_opener_argnames(fileish)
opener, arg_names = self._get_opener_argnames(fileish, compression)

Check warning on line 163 in nibabel/openers.py

View check run for this annotation

Codecov / codecov/patch

nibabel/openers.py#L163

Added line #L163 was not covered by tests
# Get full arguments to check for mode and compresslevel
full_kwargs = {**kwargs, **dict(zip(arg_names, args))}
# Set default mode
Expand All @@ -183,7 +184,11 @@
self._name = fileish
self.me_opened = True

def _get_opener_argnames(self, fileish: str) -> OpenerDef:
def _get_opener_argnames(self, fileish: str, compression: str | None) -> OpenerDef:
if compression is not None:
if compression[0] != '.':
compression = f'.{compression}'
return self.compress_ext_map[compression]

Check warning on line 191 in nibabel/openers.py

View check run for this annotation

Codecov / codecov/patch

nibabel/openers.py#L190-L191

Added lines #L190 - L191 were not covered by tests
_, ext = splitext(fileish)
if self.compress_ext_icase:
ext = ext.lower()
Expand Down
177 changes: 177 additions & 0 deletions nibabel/surfice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import io
import struct
import gzip
import numpy as np
from .wrapstruct import LabeledWrapStruct
from .dataobj_images import DataobjImage
from .arrayproxy import ArrayProxy


header_dtd = [
('magic', 'S2'), # 0; 0x5a4d (little endian) == "MZ"
('attr', 'u2'), # 2; Attributes bitfield reporting stored data
('nface', 'u4'), # 4; Number of faces
('nvert', 'u4'), # 8; Number of vertices
('nskip', 'u4'), # 12; Number of bytes to skip (for future header extensions)
]
header_dtype = np.dtype(header_dtd)


class MZ3Header(LabeledWrapStruct):
template_dtype = header_dtype
compression = False

@classmethod
def from_header(klass, header=None, check=True):
if type(header) == klass:
obj = header.copy()

Check warning on line 27 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L27

Added line #L27 was not covered by tests
if check:
obj.check_fix()
return obj

Check warning on line 30 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L29-L30

Added lines #L29 - L30 were not covered by tests

def copy(self):
ret = super().copy()
ret.compression = self.compression
ret._nscalar = self._nscalar
return ret

Check warning on line 36 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L33-L36

Added lines #L33 - L36 were not covered by tests

@classmethod
def from_fileobj(klass, fileobj, endianness=None, check=True):
raw_str = fileobj.read(klass.template_dtype.itemsize)
compression = raw_str[:2] == b'\x1f\x8b'

Check warning on line 41 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L40-L41

Added lines #L40 - L41 were not covered by tests
if compression:
fileobj.seek(0)
with gzip.open(fileobj, 'rb') as fobj:
raw_str = fobj.read(klass.template_dtype.itemsize)

Check warning on line 45 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L43-L45

Added lines #L43 - L45 were not covered by tests

hdr = klass(raw_str, endianness, check)
hdr.compression = compression
hdr._nscalar = hdr._calculate_nscalar(fileobj)
return hdr

Check warning on line 50 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L47-L50

Added lines #L47 - L50 were not covered by tests

def get_data_offset(self):
_, attr, nface, nvert, nskip = self._structarr.tolist()

Check warning on line 53 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L53

Added line #L53 was not covered by tests

isface = attr & 1 != 0
isvert = attr & 2 != 0
isrgba = attr & 4 != 0
return 16 + nskip + isface * nface * 12 + isvert * nvert * 12 + isrgba * nvert * 12

Check warning on line 58 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L55-L58

Added lines #L55 - L58 were not covered by tests

def _calculate_nscalar(self, fileobj):
_, attr, nface, nvert, nskip = self._structarr.tolist()

Check warning on line 61 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L61

Added line #L61 was not covered by tests

isscalar = attr & 8 != 0
isdouble = attr & 16 != 0
base_size = self.get_data_offset()

Check warning on line 65 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L63-L65

Added lines #L63 - L65 were not covered by tests

nscalar = 0

Check warning on line 67 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L67

Added line #L67 was not covered by tests
if isscalar or isdouble:
factor = nvert * (4 if isscalar else 8)
ret = fileobj.tell()

Check warning on line 70 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L69-L70

Added lines #L69 - L70 were not covered by tests
if self.compression:
fileobj.seek(-4, 2)
full_size_mod_4gb = struct.unpack('I', fileobj.read(4))[0]
full_size = full_size_mod_4gb
nscalar, remainder = divmod(full_size - base_size, factor)

Check warning on line 75 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L72-L75

Added lines #L72 - L75 were not covered by tests
for _ in range(5):
full_size += (1 << 32)
nscalar, remainder = divmod(full_size - base_size, factor)

Check warning on line 78 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L77-L78

Added lines #L77 - L78 were not covered by tests
if remainder == 0:
break

Check warning on line 80 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L80

Added line #L80 was not covered by tests
else:
fileobj.seek(0)
with gzip.open(fileobj, 'rb') as fobj:
fobj.seek(0, 2)
full_size = fobj.tell()
nscalar, remainder = divmod(full_size - base_size, factor)

Check warning on line 86 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L82-L86

Added lines #L82 - L86 were not covered by tests
if remainder:
raise ValueError("Apparent file size failure")

Check warning on line 88 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L88

Added line #L88 was not covered by tests
else:
fileobj.seek(0, 2)
full_size = fileobj.tell()
nscalar, remainder = divmod(full_size - base_size, factor)

Check warning on line 92 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L90-L92

Added lines #L90 - L92 were not covered by tests
if remainder:
raise ValueError("Apparent file size failure")
fileobj.seek(ret)
return nscalar

Check warning on line 96 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L94-L96

Added lines #L94 - L96 were not covered by tests

@classmethod
def guessed_endian(klass, mapping):
return '<'

Check warning on line 100 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L100

Added line #L100 was not covered by tests

@classmethod
def default_structarr(klass, endianness=None):
if endianness is not None and endian_codes[endianness] != '<':
raise ValueError('MZ3Header must always be little endian')
structarr = super().default_structarr(endianness=endianness)
structarr['magic'] = b"MZ"
return structarr

Check warning on line 108 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L105-L108

Added lines #L105 - L108 were not covered by tests

@classmethod
def may_contain_header(klass, binaryblock):
if len(binaryblock) < 16:
return False

Check warning on line 113 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L113

Added line #L113 was not covered by tests

# May be gzipped without changing extension
if binaryblock[:2] == b'\x1f\x8b':
with gzip.open(io.BytesIO(binaryblock), 'rb') as fobj:
binaryblock = fobj.read(16)

Check warning on line 118 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L117-L118

Added lines #L117 - L118 were not covered by tests

hdr_struct = np.ndarray(shape=(), dtype=klass.template_dtype, buffer=binaryblock[:16])
return hdr_struct['magic'] == b'MZ'

Check warning on line 121 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L120-L121

Added lines #L120 - L121 were not covered by tests

def get_data_dtype(self):
if self._structarr['attr'] & 8:
return np.dtype('<f4')

Check warning on line 125 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L125

Added line #L125 was not covered by tests
elif self._structarr['attr'] & 16:
return np.dtype('<f8')

Check warning on line 127 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L127

Added line #L127 was not covered by tests

def set_data_dtype(self, datatype):
if np.dtype(datatype).byteorder == ">":
raise ValueError("Cannot set type to big-endian")
dt = np.dtype(datatype).newbyteorder("<")

Check warning on line 132 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L131-L132

Added lines #L131 - L132 were not covered by tests

if dt == np.dtype('<f8'):
self._structarr['attr'] |= 0b00010000

Check warning on line 135 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L135

Added line #L135 was not covered by tests
elif dt == np.dtype('<f4'):
self._structarr['attr'] &= 0b11101111

Check warning on line 137 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L137

Added line #L137 was not covered by tests
else:
raise ValueError(f"Cannot set dtype: {datatype}")

Check warning on line 139 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L139

Added line #L139 was not covered by tests

def get_data_shape(self):
base_shape = (int(self._structarr['nvert']),)

Check warning on line 142 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L142

Added line #L142 was not covered by tests
if self._nscalar == 0:
return ()

Check warning on line 144 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L144

Added line #L144 was not covered by tests
elif self._nscalar == 1:
return base_shape

Check warning on line 146 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L146

Added line #L146 was not covered by tests
else:
return base_shape + (self._nscalar,)

Check warning on line 148 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L148

Added line #L148 was not covered by tests


class MZ3Image(DataobjImage):
header_class = MZ3Header
valid_exts = ('.mz3',)
files_types = (('image', '.mz3'),)

ImageArrayProxy = ArrayProxy

@classmethod
def from_file_map(klass, file_map, *, mmap=True, keep_file_open=None):
if mmap not in (True, False, 'c', 'r'):
raise ValueError("mmap should be one of {True, False, 'c', 'r'}")
fh = file_map['image']
with fh.get_prepare_fileobj(mode='rb') as fileobj:
header = klass.header_class.from_fileobj(fileobj)
print(header)

Check warning on line 165 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L161-L165

Added lines #L161 - L165 were not covered by tests

data_dtype = header.get_data_dtype()

Check warning on line 167 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L167

Added line #L167 was not covered by tests
if data_dtype:
spec = (header.get_data_shape(), data_dtype, header.get_data_offset())
dataobj = klass.ImageArrayProxy(fh.filename, spec, mmap=mmap,

Check warning on line 170 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L169-L170

Added lines #L169 - L170 were not covered by tests
keep_file_open=keep_file_open,
compression="gz" if header.compression else None)
else:
dataobj = np.array((), dtype="<f4")

Check warning on line 174 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L174

Added line #L174 was not covered by tests

img = klass(dataobj, header=header)
return img

Check warning on line 177 in nibabel/surfice.py

View check run for this annotation

Codecov / codecov/patch

nibabel/surfice.py#L176-L177

Added lines #L176 - L177 were not covered by tests
Loading