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

Add IO functions #3

Merged
merged 6 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 5 additions & 11 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
**Proposed changes**
Describe the changes implemented in this pull request. If the changes fix a
bug or resolves a feature request, be sure to link the issue. Please explain
the issue and how the changes address the issue.
Describe the changes implemented in this pull request. If the changes fix a bug or resolves a feature request, be sure to link the issue. Please explain the issue and how the changes address the issue.

**Types of changes**
What types of changes does your code introduce? Put an `x` in the boxes that
apply
What types of changes does your code introduce? Put an `x` in the boxes that apply

- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality
to not work as expected)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Other (if none of the other choices apply)

**Checklist**
Put an `x` in the boxes that apply. You can also fill these out after creating
the PR. If you are unsure about any of the choices, don't hesitate to ask!
Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you are unsure about any of the choices, don't hesitate to ask!

- [ ] Changes have been tested to ensure that fix is effective or that a
feature works.
- [ ] Changes have been tested to ensure that fix is effective or that a feature works.
- [ ] Changes pass the unit tests
- [ ] Code has been run through the `poe quality` task
- [ ] I have included necessary documentation or comments (as necessary)
Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ jobs:
}}
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root --with dev
# Example of test
# - name: Test minimum inputs
# run: poetry run poe test_base
run: poetry install --no-interaction --no-root --with dev
- name: Install library
run: poetry install --no-interaction --with dev
- name: Perform unit testing
run: poetry run poe test_cov
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ../cov.xml
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Directories
__pycache__/

# Testing
.hypothesis
.ruff_cache
.pytest_cache
1 change: 1 addition & 0 deletions afids_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Top-level for afids-utils."""
8 changes: 8 additions & 0 deletions afids_utils/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Custom exceptions"""


class InvalidFiducialNumberError(Exception):
"""Exception for invalid fiducial number"""

def __init__(self, fid_num: int) -> None:
super().__init__(f"Provided fiducial {fid_num} is not valid.")
70 changes: 70 additions & 0 deletions afids_utils/io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Methods for loading and saving nifti files"""
from __future__ import annotations

import csv
from importlib import resources
from os import PathLike

import numpy as np
import pandas as pd
from numpy.typing import NDArray

from afids_utils.exceptions import InvalidFiducialNumberError

FCSV_FIELDNAMES = [
"# columns = id",
"x",
"y",
"z",
"ow",
"ox",
"oy",
"oz",
"vis",
"sel",
"lock",
"label",
"desc",
"associatedNodeID",
]


def get_afid(
fcsv_path: PathLike[str] | str, fid_num: int
) -> NDArray[np.single]:
"""Extract specific fiducial's spatial coordinates"""
if fid_num < 1 or fid_num > 32:
raise InvalidFiducialNumberError(fid_num)
fcsv_df = pd.read_csv(
fcsv_path, sep=",", header=2, usecols=FCSV_FIELDNAMES
)

return fcsv_df.loc[fid_num - 1, ["x", "y", "z"]].to_numpy(dtype="single")


def afids_to_fcsv(
afid_coords: NDArray[np.single],
fcsv_output: PathLike[str] | str,
) -> None:
"""AFIDS to Slicer-compatible .fcsv file"""
# Read in fcsv template
with resources.open_text(
"afids_utils.resources", "template.fcsv"
) as template_fcsv_file:
header = [template_fcsv_file.readline() for _ in range(3)]
reader = csv.DictReader(template_fcsv_file, fieldnames=FCSV_FIELDNAMES)
fcsv = list(reader)

# Loop over fiducials and update with fiducial spatial coordinates
for idx, row in enumerate(fcsv):
row["x"] = afid_coords[idx][0]
row["y"] = afid_coords[idx][1]
row["z"] = afid_coords[idx][2]

# Write output fcsv
with open(fcsv_output, "w", encoding="utf-8", newline="") as out_fcsv_file:
for line in header:
out_fcsv_file.write(line)
writer = csv.DictWriter(out_fcsv_file, fieldnames=FCSV_FIELDNAMES)
for row in fcsv:
writer.writerow(row)
Empty file added afids_utils/py.typed
Empty file.
Empty file.
35 changes: 35 additions & 0 deletions afids_utils/resources/template.fcsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Markups fiducial file version = 4.6
# CoordinateSystem = 0
# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID
vtkMRMLMarkupsFiducialNode_1,0,0,0,0,0,0,1,1,1,1,1,AC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_2,0,0,0,0,0,0,1,1,1,1,2,PC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_3,0,0,0,0,0,0,1,1,1,1,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_4,0,0,0,0,0,0,1,1,1,1,4,PMJ,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_5,0,0,0,0,0,0,1,1,1,1,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_6,0,0,0,0,0,0,1,1,1,1,6,R superior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_7,0,0,0,0,0,0,1,1,1,1,7,L superior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_8,0,0,0,0,0,0,1,1,1,1,8,R inferior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_9,0,0,0,0,0,0,1,1,1,1,9,L inferior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_10,0,0,0,0,0,0,1,1,1,1,10,culmen,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_11,0,0,0,0,0,0,1,1,1,1,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_12,0,0,0,0,0,0,1,1,1,1,12,R MB,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_13,0,0,0,0,0,0,1,1,1,1,13,L MB,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_14,0,0,0,0,0,0,1,1,1,1,14,pineal gland,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_15,0,0,0,0,0,0,1,1,1,1,15,R LV at AC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_16,0,0,0,0,0,0,1,1,1,1,16,L LV at AC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_17,0,0,0,0,0,0,1,1,1,1,17,R LV at PC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_18,0,0,0,0,0,0,1,1,1,1,18,L LV at PC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_19,0,0,0,0,0,0,1,1,1,1,19,genu of CC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_20,0,0,0,0,0,0,1,1,1,1,20,splenium,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_21,0,0,0,0,0,0,1,1,1,1,21,R AL temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_22,0,0,0,0,0,0,1,1,1,1,22,L AL temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_23,0,0,0,0,0,0,1,1,1,1,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_24,0,0,0,0,0,0,1,1,1,1,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_25,0,0,0,0,0,0,1,1,1,1,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_26,0,0,0,0,0,0,1,1,1,1,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_27,0,0,0,0,0,0,1,1,1,1,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_28,0,0,0,0,0,0,1,1,1,1,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_29,0,0,0,0,0,0,1,1,1,1,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_30,0,0,0,0,0,0,1,1,1,1,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_31,0,0,0,0,0,0,1,1,1,1,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_32,0,0,0,0,0,0,1,1,1,1,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1
35 changes: 35 additions & 0 deletions afids_utils/tests/data/tpl-MNI152NLin2009cAsym_afids.fcsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Markups fiducial file version = 4.6
# CoordinateSystem = 0
# columns = id,x,y,z,ow,ox,oy,oz,vis,sel,lock,label,desc,associatedNodeID
vtkMRMLMarkupsFiducialNode_1,-0.23099999999999998,2.93275,-4.899,0,0,0,1,1,1,0,1,AC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_2,-0.24125000000000002,-25.074749999999998,-2.169,0,0,0,1,1,1,0,2,PC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_3,0.19949999999999998,-37.268249999999995,-10.9115,0,0,0,1,1,1,0,3,infracollicular sulcus,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_4,-0.12725000000000003,-23.14825,-21.522,0,0,0,1,1,1,0,4,PMJ,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_5,-0.06675,-14.81275,-11.32025,0,0,0,1,1,1,0,5,superior interpeduncular fossa,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_6,12.67275,-26.960749999999997,-10.38225,0,0,0,1,1,1,0,6,R superior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_7,-13.004999999999999,-27.190749999999998,-10.32375,0,0,0,1,1,1,0,7,L superior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_8,10.873,-30.96475,-21.533,0,0,0,1,1,1,0,8,R inferior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_9,-11.18,-30.437000000000005,-21.537,0,0,0,1,1,1,0,9,L inferior LMS,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_10,-0.004750000000000004,-52.3695,2.06825,0,0,0,1,1,1,0,10,culmen,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_11,-0.0665,-8.131499999999999,-14.7755,0,0,0,1,1,1,0,11,intermammillary sulcus,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_12,2.0255,-8.143500000000001,-14.752749999999999,0,0,0,1,1,1,0,12,R MB,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_13,-2.3954999999999997,-8.0565,-14.8675,0,0,0,1,1,1,0,13,L MB,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_14,0.04425,-31.73675,0.76675,0,0,0,1,1,1,0,14,pineal gland,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_15,15.141750000000002,5.3445,24.358249999999998,0,0,0,1,1,1,0,15,R LV at AC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_16,-15.6095,5.22575,24.63125,0,0,0,1,1,1,0,16,L LV at AC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_17,18.4955,-22.061999999999998,27.66,0,0,0,1,1,1,0,17,R LV at PC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_18,-18.999000000000002,-22.046,27.284,0,0,0,1,1,1,0,18,L LV at PC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_19,0.17799999999999996,33.423500000000004,2.6755,0,0,0,1,1,1,0,19,genu of CC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_20,0.037000000000000005,-37.6845,6.14675,0,0,0,1,1,1,0,20,splenium of CC,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_21,34.312250000000006,-4.982,-26.87875,0,0,0,1,1,1,0,21,R AL temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_22,-34.72725,-7.2475,-25.203,0,0,0,1,1,1,0,22,L AL temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_23,18.69625,-10.56775,-17.7725,0,0,0,1,1,1,0,23,R superior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_24,-20.12075,-11.62125,-17.1495,0,0,0,1,1,1,0,24,L superior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_25,21.07325,-4.56975,-28.90925,0,0,0,1,1,1,0,25,R inferior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_26,-21.966749999999998,-5.28875,-27.895,0,0,0,1,1,1,0,26,L inferior AM temporal horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_27,14.40775,-39.697250000000004,3.8390000000000004,0,0,0,1,1,1,0,27,R indusium griseum origin,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_28,-15.48075,-42.28375,3.9659999999999997,0,0,0,1,1,1,0,28,L indusium griseum origin,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_29,20.730249999999998,-79.47475,4.85125,0,0,0,1,1,1,0,29,R ventral occipital horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_30,-19.41825,-81.46925,3.785,0,0,0,1,1,1,0,30,L ventral occipital horn,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_31,12.108500000000001,17.02725,-12.94,0,0,0,1,1,1,0,31,R olfactory sulcal fundus,vtkMRMLScalarVolumeNode1
vtkMRMLMarkupsFiducialNode_32,-13.34325,17.0885,-13.35225,0,0,0,1,1,1,0,32,L olfactory sulcal fundus,vtkMRMLScalarVolumeNode1
122 changes: 122 additions & 0 deletions afids_utils/tests/test_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from __future__ import annotations

import csv
import tempfile
from os import PathLike, remove
from pathlib import Path

import numpy as np
import pytest
from hypothesis import HealthCheck, assume, given, settings
from hypothesis import strategies as st
from hypothesis.extra.numpy import arrays
from numpy.typing import NDArray

from afids_utils.exceptions import InvalidFiducialNumberError
from afids_utils.io import FCSV_FIELDNAMES, afids_to_fcsv, get_afid


@pytest.fixture
def valid_fcsv_file() -> PathLike[str]:
return (
Path(__file__).parent / "data" / "tpl-MNI152NLin2009cAsym_afids.fcsv"
)


class TestGetAfid:
@given(afid_num=st.integers(min_value=1, max_value=32))
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_valid_num_get_afid(
self, valid_fcsv_file: PathLike[str], afid_num: int
):
afid = get_afid(valid_fcsv_file, afid_num)

# Check array type
assert isinstance(afid, np.ndarray)
# Check array values
assert afid.dtype == np.single

@given(afid_num=st.integers(min_value=-1000, max_value=1000))
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_invalid_num_get_afid(
self,
valid_fcsv_file: PathLike[str],
afid_num: int,
):
assume(afid_num < 1 or afid_num > 32)

with pytest.raises(
InvalidFiducialNumberError, match=".*is not valid."
):
get_afid(valid_fcsv_file, afid_num)


@st.composite
def afid_coords(
draw: st.DrawFn,
min_value: float = -50.0,
max_value: float = 50.0,
width: int = 16,
) -> NDArray[np.single]:
coords = draw(
arrays(
shape=(32, 3),
dtype=np.single,
elements=st.floats(
min_value=min_value, max_value=max_value, width=width
),
)
)

return coords


class TestAfidsToFcsv:
@given(afids_coords=afid_coords())
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_write_fcsv(
self, afids_coords: NDArray[np.single], valid_fcsv_file: PathLike[str]
) -> None:
out_fcsv_file = tempfile.NamedTemporaryFile(
mode="w", delete=False, prefix="sub-test_afids.fcsv"
)
out_fcsv_path = Path(out_fcsv_file.name)

afids_to_fcsv(afids_coords, out_fcsv_path)

# Check file was created
assert out_fcsv_path.exists()

# Load files
with open(
valid_fcsv_file, "r", encoding="utf-8", newline=""
) as template_fcsv_file:
template_header = [template_fcsv_file.readline() for _ in range(3)]

with open(
out_fcsv_path, "r", encoding="utf-8", newline=""
) as output_fcsv_file:
output_header = [output_fcsv_file.readline() for _ in range(3)]
reader = csv.DictReader(
output_fcsv_file, fieldnames=FCSV_FIELDNAMES
)
output_fcsv = list(reader)

# Check header
assert output_header == template_header
# Check contents
for idx, row in enumerate(output_fcsv):
assert (row["x"], row["y"], row["z"]) == (
str(afids_coords[idx][0]),
str(afids_coords[idx][1]),
str(afids_coords[idx][2]),
)

# Delete temporary file
remove(out_fcsv_path)
16 changes: 16 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
comment:
layout: "header, diff, flags, components"

coverage:
status:
project:
default:
target: 100%
threshold: 5%

component_management:
individual_components:
- component_id: afids-utils_io
name: afids-utils_io
paths:
- afids_utils/io/
Loading