Skip to content
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

Support NIFTI-2, resolves #89 #90

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open

Conversation

benkay86
Copy link

@benkay86 benkay86 commented Jan 13, 2022

Adds support for the NIFTI-2 header format. The PR is just enough to compile and run tests. If approved, documentation and additional test cases will be needed in a subsequent PR. These changes are not backwards compatible.

  • Types and methods outside the header have been changed to default to the larger NIFTI-2 types. For example, nifti::volume::shape::Dim is now based off u64 instead of u16.

  • NiftiHeader is changed to an enum that contains either a Nifti1Header or Nifti2Header structure. NiftiHeader implements getter/setter methods to modify the underlying header without having to worry about which version it is. The underlying structure can also be accessed directly.

let mut hdr = NiftiHeader::default();
hdr.slice_duration = 1.0; // no longer works
hdr.set_slice_duration(1.0); // OK
let slice_duration: f32 = hdr.get_slice_duration(); // no longer works
let slice_duration: f64 = hdr.get_slice_duration(); // OK
// Conversion to larger NIFTI-2 types always succeeds.
let mut hdr2: Nifti2Header = hdr.into_nifti2();
hdr2.slice_duration = 1.0; // OK
let slice_duration: f64 = hdr2.slice_duration; // OK
// Conversion to smaller NIFTI-1 types is fallible.
let mut hdr1: Nifti1Header = hdr2.into_nifti().into_nifti1().unwrap();
hdr1.slice_duration = 1.0; // OK
let slice_duration: f32 = hdr1.slice_duration; // OK

Migration should otherwise be relatively painless.

  • NiftiHeader::from_file() and from_reader() automatically detect the header version in the given file or input stream.
  • WriterOptions::write_nifti() automatically writes out whichever header version is referenced by the WriterOptions.
  • NiftiHeader::into_nifti1() and into_nifti2() conveniently convert between header versions.

@benkay86 benkay86 mentioned this pull request Jan 13, 2022
Copy link
Collaborator

@nilgoyette nilgoyette left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't review anything thoroughly now, but here are some questions and comments.

Btw, good job. This is a lot of work. Thank you.

/// Magic code for full NIFTI-1 files (extention ".nii[.gz]").
pub const MAGIC_CODE_NIP2: &[u8; 8] = b"n+2\0\r\n\x1A\n";

/// Abstraction over NIFTI version 1 and 2 headers. This is the high-level type
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May I ask you why you use double space after all sentences? I never seen that before.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I heard rumors that typewriters had issues which were resolved with the double spacing, and with that some people grew into the aesthetics of separating sentences like this. 🤔

Nowadays though, I practice a form of semantic linefeeds or semantic line breaks, which recommends introducing a new line at the end of each sentence.

In any case, this isn't a critical enough concern to be a blocker.

/// Header size, must be 348 for a NIFTI-1 header and 540 for NIFTI-2.
/// There is no corresponding `set_sizeof_hdr()` because you should never
/// need to set this field manually.
pub fn get_sizeof_hdr(&self) -> u32 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @Enet4 once told me to not use get_ in Rust. I don't have a clear opinion on this matter, but it seems it's not idiomatic. Maybe he can confirm.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, it's the C-GETTER guideline. Getters in Rust usually usually aren't prefixed with get_ by convention.

Suggested change
pub fn get_sizeof_hdr(&self) -> u32 {
pub fn sizeof_hdr(&self) -> u32 {


// All done, return header with populated fields.
Ok(h)
}
Copy link
Collaborator

@nilgoyette nilgoyette Jan 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you use an automatic formatter? (Don't search what's not formatted here ^^)

let len = header.vox_offset as usize;
let len = if len < 352 { 0 } else { len - 352 };
let len: usize = header.get_vox_offset()?.try_into()?;
let len = if len == 0 { 0 } else { len - TryInto::<usize>::try_into(header.get_sizeof_hdr())? }; // TODO!
Copy link
Collaborator

@nilgoyette nilgoyette Jan 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain more what's left TODO? We need to know if we ever need to do it.

@@ -256,23 +258,10 @@ mod tests {
assert_eq!(new_data, data);
}

#[test]
fn write_descrip_panic() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not tested anymore?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is no longer possible because descrip was turned back into an 80-byte array. It was kind of what I originally had in mind, but large arrays were not as nice to work with in Rust. That changed in recent versions, so unless we witness a significant performance regression, we can save the description in a [u8; 80] here instead of Vec<u8>.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Enet4 Enet4 self-requested a review January 18, 2022 18:05
Copy link
Owner

@Enet4 Enet4 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this major contribution, @benkay86! I went through the code and made a quick review. What I feel are the two major concerns are:

  • getter naming convention (as already mentioned, they should not use get_)
  • linear transformations for volume conversion will use f64 instead of f32 in many cases, which I suspect could affect cases where the original data is all in f32. If all voxel values have to go through f64, this could translate to a bigger memory footprint and maybe less performance. It might help to try a stress test with f32 volumes and see if performance and memory usage changed significantly.

/// Invalid header size. Header size must be 540 for NIfTI-2 or 348 for
/// NIfTI-1.
InvalidHeaderSize(sizeof_hdr: i32) {
display("Invalid header size {} must eb 540 for NIfTI-2 or 348 for NIfTI-1.", sizeof_hdr)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
display("Invalid header size {} must eb 540 for NIfTI-2 or 348 for NIfTI-1.", sizeof_hdr)
display("Invalid header size {}: must be 540 for NIfTI-2 or 348 for NIfTI-1.", sizeof_hdr)

/// Header size, must be 348 for a NIFTI-1 header and 540 for NIFTI-2.
/// There is no corresponding `set_sizeof_hdr()` because you should never
/// need to set this field manually.
pub fn get_sizeof_hdr(&self) -> u32 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, it's the C-GETTER guideline. Getters in Rust usually usually aren't prefixed with get_ by convention.

Suggested change
pub fn get_sizeof_hdr(&self) -> u32 {
pub fn sizeof_hdr(&self) -> u32 {

(self.pixdim[0].abs() - 1.).abs() < 1e-11
impl Nifti2Header {
/// Place this `Nifti2Header` into a version-agnostic [`NiftiHeader`] enum.
pub fn into_nifti(self) -> NiftiHeader {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be idiomatic to also have implementations of From<Nifti1Header> and From<Nifti2Header> for NiftiHeader. This way, a conversion would be as simple as header.into().

@@ -379,11 +1477,11 @@ impl NiftiHeader {
pub fn affine<T>(&self) -> Matrix4<T>
where
T: RealField,
f32: SubsetOf<T>,
f64: SubsetOf<T>,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to suggest that all affine transformations will from now on use f64 for calculations, regardless of the nature of the original data. Volume data value transformations also seem to have changed accordingly. Not sure whether this breaks any expectations to existing consumers (@nilgoyette?).

@@ -42,17 +42,17 @@ pub trait LinearTransform<T: 'static + Copy> {
/// affine transformation, then converted back to the original type. Ideal for
/// small, low precision types such as `u8` and `i16`.
#[derive(Debug)]
pub struct LinearTransformViaF32;
pub struct LinearTransformViaF32; // TODO remove this for NIFTI-2?
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were these changes merely a consequence of the wider nifti header? Perhaps by letting the API consumer tap into nifti-1, they could keep linear transformations in f32.

@@ -256,23 +258,10 @@ mod tests {
assert_eq!(new_data, data);
}

#[test]
fn write_descrip_panic() {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is no longer possible because descrip was turned back into an 80-byte array. It was kind of what I originally had in mind, but large arrays were not as nice to work with in Rust. That changed in recent versions, so unless we witness a significant performance regression, we can save the description in a [u8; 80] here instead of Vec<u8>.

@liningpan
Copy link

Hi, NIFTI2 support would be very helpful!

Can we add additional intent codes from the HCP pipeline?

https://github.com/Washington-University/workbench/blob/master/src/FilesBase/nifti2.h#L33-L49

const int32_t NIFTI_INTENT_CONNECTIVITY_UNKNOWN=3000;
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE=3001;
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE_TIME=3002;//CIFTI-1 name
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE_SERIES=3002;//CIFTI-2 name
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED=3003;
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED_TIME=3004;//ditto
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SERIES=3004;
const int32_t NIFTI_INTENT_CONNECTIVITY_CONNECTIVITY_TRAJECTORY=3005;//ditto
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE_TRAJECTORY=3005;
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE_SCALARS=3006;
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE_LABELS=3007;
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED_SCALAR=3008;
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED_DENSE=3009;
const int32_t NIFTI_INTENT_CONNECTIVITY_DENSE_PARCELLATED=3010;
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SERIES=3011;
const int32_t NIFTI_INTENT_CONNECTIVITY_PARCELLATED_PARCELLATED_SCALAR=3012;

However, several variables map to the same number which is not possible for rust enum.

@benkay86
Copy link
Author

Apologies, I had a baby and kind of lost track of this! It looks like there is still broad interest in NIFTI-2 support, and aside from some formatting issues and the need for more test cases, the biggest obstacle is what to name getter methods.

@nilgoyette is right that Rust eschews getter methods in general, preferring the simplicity and explicitness of accessing a struct's field directly. The current PR allows this by resolving the NiftiHeader enum to a Nifti1Header or Nifti2Header struct that can be manipulated directly. In addition to this, convenient getter and setter methods which are agnostic to the underlying header version are provided for automatic conversion between field types.

In such cases where getter/setter methods are needed, the Rust convention is to name the setter for the field foo set_foo() and simply name the getter foo(). However, there is a problem for nifti-rs because a few fields already have getter-like functions with the same name. For example, NiftiHeader::dim is a [u16;8] and NiftiHeader::dim() returns a validated slice of this field. What should we name a getter that returns the raw field?

Possibilities:

  • Name raw getters get_foo(). While this is verbose and goes against Rust convention, it is easily understood and avoids name conflicts with existing methods.
  • Rename the existing methods something else like foo_validated() and name the raw getter foo(). This is more idiomatic but breaks the API. (Maybe not such a big deal since the PR contains other breaking changes in corner cases.)
  • Don't provide a raw getter for fields that require some kind of validation or parsing. Make the user cast the enum down to a Nifti1Header or Nifti2Header if raw access to the field is desired.

Thoughts?

@liningpan
Copy link

I also tried to implement nifti-2 support myself. I took a more aggressive approach and cast nifti-1 and nifti-2 header to a common type derived from nifti-2. The common type also exposes Enum types directly instead of through a getter. I believe connectome workbench took a similar approach. However, this will complete nuke the current interface.

I also took a few test cases from nibabel, for nifti-2 unit tests.

You can take a look my experimental branch here if you are interested. https://github.com/liningpan/nifti-rs/tree/nifti2

@Enet4
Copy link
Owner

Enet4 commented May 19, 2022

However, there is a problem for nifti-rs because a few fields already have getter-like functions with the same name. For example, NiftiHeader::dim is a [u16;8] and NiftiHeader::dim() returns a validated slice of this field. What should we name a getter that returns the raw field?

I don't take issue in introducing a breaking change to the API, especially for introducing such a big feature to the crate. raw_dim would make a decent method name for the slice as saved in the header (thus only available behind specific version headers).

Aside from this, I would also like to ensure that 1) it stays possible to work with a specific version explicitly, and that 2) calculations on NIfTI-1 can still use f32 as common ground, as mentioned in this comment.

@liningpan liningpan mentioned this pull request Jul 10, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants