Skip to content

Commit

Permalink
add methods and tests for handling json files
Browse files Browse the repository at this point in the history
Currently returning the slicer_version as "unknown" as the version is
not currently specified in the .json file outputted from the slicer
markup json.
  • Loading branch information
kaitj committed Sep 8, 2023
1 parent e9720aa commit 916cbc3
Show file tree
Hide file tree
Showing 5 changed files with 363 additions and 52 deletions.
14 changes: 10 additions & 4 deletions afids_utils/afids.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,12 @@ def load(cls, afids_fpath: PathLike[str] | str) -> AfidSet:
afids_fpath
)
# Loading json
# if afids_fpath_ext = ".json":
# load_json(afids_path)
elif afids_fpath_ext == ".json":
from afids_utils.ext.json import load_json

slicer_version, coord_system, afids_positions = load_json(
afids_fpath
)
else:
raise ValueError("Unsupported file extension")

Expand Down Expand Up @@ -229,8 +233,10 @@ def save(self, out_fpath: PathLike[str] | str) -> None:

save_fcsv(self, out_fpath)
# Saving json
# if out_fpath_ext = ".json":
# save_json(afids_coords, out_fpath)
elif out_fpath_ext == ".json":
from afids_utils.ext.json import save_json

save_json(self, out_fpath)
else:
raise ValueError("Unsupported file extension")

Expand Down
5 changes: 0 additions & 5 deletions afids_utils/ext/fcsv.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,6 @@ def save_fcsv(
out_fcsv
Path of fcsv file to save AFIDs to
Raises
------
TypeError
If number of fiducials to write does not match expected number
"""
# Read in fcsv template
with resources.open_text(
Expand Down
154 changes: 154 additions & 0 deletions afids_utils/ext/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Methods for handling .json files associated with AFIDS"""
from __future__ import annotations

import json
from importlib import resources
from os import PathLike

from afids_utils.afids import AfidPosition, AfidSet
from afids_utils.exceptions import InvalidFileError


class ControlPoint:
id: str
label: str
description: str
associatedNodeID: str
position: list[float]
orientation: list[float]
selected: bool
locked: bool
visibility: bool
positionStatus: str


def _get_metadata(
in_json: dict[str, bool | float | str | list[float]]
) -> tuple[str, str]:
"""Internal function to extract metadata from json files
Note: Slicer version is not currently included in the json file
Parameters:
-----------
in_json
Data from provided json file to parse metadata from
Returns
-------
parsed_version
Slicer version associated with fiducial file
parsed_coord
Coordinate system of fiducials
Raises
------
InvalidFileError
If header is invalid from .json file
"""

# Update if future json versions include Slicer version
parsed_version = "Unknown"
parsed_coord = in_json["coordinateSystem"]

# Transform coordinate system so human-understandable
if parsed_coord == "0":
parsed_coord = "LPS"
elif parsed_coord == "1":
parsed_coord = "RAS"

if parsed_coord not in ["LPS", "RAS"]:
raise InvalidFileError("Invalid coordinate system")

return parsed_version, parsed_coord


def _get_afids(
in_json: dict[str, bool | float | str | list[float]]
) -> list[AfidPosition]:
afids = in_json["controlPoints"]

afids_positions = []
for afid in afids:
afids_positions.append(
AfidPosition(
label=int(afid["label"]),
x=float(afid["position"][0]),
y=float(afid["position"][1]),
z=float(afid["position"][2]),
desc=afid["description"],
)
)

return afids_positions


def load_json(
json_path: PathLike[str] | str,
) -> tuple[str, str, list[AfidPosition]]:
"""Read in json and extract relevant information for an AfidSet
Parameters
----------
json_path
Path to .json file containing AFIDs coordinates
Returns
-------
slicer_version
Slicer version associated with fiducial file
coord_system
Coordinate system of fiducials
afids_positions
List containing spatial position of afids
"""
with open(json_path) as json_file:
afids_json = json.load(json_file)

# Grab metadata
slicer_version, coord_system = _get_metadata(afids_json["markups"][0])
# Grab afids
afids_positions = _get_afids(afids_json["markups"][0])

return slicer_version, coord_system, afids_positions


def save_json(
afid_set: AfidSet,
out_json: PathLike[str] | str,
) -> None:
"""Save fiducials to output json file
Parameters
----------
afid_set
A complete AfidSet containing metadata and positions of AFIDs
out_json
Path of json file to save AFIDs to
"""
# Read in json template
with resources.open_text(
"afids_utils.resources", "template.json"
) as template_json_file:
template_content = json.load(template_json_file)

# Update header
template_content["markups"][0][
"coordinateSystem"
] = afid_set.coord_system

# Loop and update with fiducial coordinates
for idx in range(len(template_content["markups"][0]["controlPoints"])):
template_content["markups"][0]["controlPoints"][idx]["position"] = [
afid_set.afids[idx].x,
afid_set.afids[idx].y,
afid_set.afids[idx].z,
]

# Write output json
with open(out_json, "w") as out_json_file:
json.dump(template_content, out_json_file, indent=4)
66 changes: 35 additions & 31 deletions afids_utils/tests/test_afids.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


@pytest.fixture
def valid_fcsv_file() -> PathLike[str]:
def valid_file() -> PathLike[str]:
return (
Path(__file__).parent / "data" / "tpl-MNI152NLin2009cAsym_afids.fcsv"
)
Expand Down Expand Up @@ -158,17 +158,16 @@ def test_repeated_incomplete_afid_set(


class TestAfidsIO:
@given(label=st.integers(min_value=0, max_value=31))
@given(
label=st.integers(min_value=0, max_value=31),
ext=st.sampled_from(["fcsv", "json"]),
)
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_valid_load(
self,
valid_fcsv_file: PathLike[str],
label: int,
):
def test_valid_load(self, valid_file: PathLike[str], label: int, ext: str):
# Load valid file to check internal types
afids_set = AfidSet.load(valid_fcsv_file)
afids_set = AfidSet.load(valid_file.with_suffix(f".{ext}"))

# Check correct type created after loading
assert isinstance(afids_set, AfidSet)
Expand All @@ -195,7 +194,7 @@ def test_invalid_fpath(self):
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_invalid_ext(self, valid_fcsv_file: PathLike[str], ext: str):
def test_invalid_ext(self, valid_file: PathLike[str], ext: str):
assume(not ext == "fcsv" or not ext == "json")

with tempfile.NamedTemporaryFile(
Expand All @@ -206,9 +205,9 @@ def test_invalid_ext(self, valid_fcsv_file: PathLike[str], ext: str):
with pytest.raises(ValueError, match="Unsupported .* extension"):
AfidSet.load(invalid_file_ext.name)

def test_invalid_label_range(self, valid_fcsv_file: PathLike[str]):
def test_invalid_label_range(self, valid_file: PathLike[str]):
# Create additional line of fiducials
with open(valid_fcsv_file) as valid_fcsv:
with open(valid_file) as valid_fcsv:
fcsv_data = valid_fcsv.readlines()
fcsv_data.append(fcsv_data[-1])

Expand Down Expand Up @@ -239,7 +238,7 @@ def test_invalid_label_range(self, valid_fcsv_file: PathLike[str]):
)
def test_invalid_desc(
self,
valid_fcsv_file: PathLike[str],
valid_file: PathLike[str],
human_mappings: list[list[str] | str],
label: int,
desc: str,
Expand All @@ -253,7 +252,7 @@ def test_invalid_desc(
)

# Replace valid description with a mismatch
with open(valid_fcsv_file) as valid_fcsv:
with open(valid_file) as valid_fcsv:
fcsv_data = valid_fcsv.readlines()
fcsv_data[label + 3] = fcsv_data[label + 3].replace(
human_mappings[label]["desc"], desc
Expand All @@ -274,46 +273,53 @@ def test_invalid_desc(
):
AfidSet.load(out_fcsv_file.name)

def test_valid_save(self, valid_fcsv_file: PathLike[str]):
@given(ext=st.sampled_from(["fcsv", "json"]))
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_valid_save(self, valid_file: PathLike[str], ext: str):
with tempfile.NamedTemporaryFile(
mode="w", prefix="sub-test_desc-", suffix="_afids.fcsv"
mode="w", prefix="sub-test_desc-", suffix=f"_afids.{ext}"
) as out_fcsv_file:
afids_set = AfidSet.load(valid_fcsv_file)
afids_set = AfidSet.load(valid_file.with_suffix(f".{ext}"))
afids_set.save(out_fcsv_file.name)

assert Path(out_fcsv_file.name).exists()

@given(
ext=st.text(
ext=st.sampled_from(["fcsv", "json"]),
invalid_ext=st.text(
min_size=2,
max_size=5,
alphabet=st.characters(
min_codepoint=ord("A"), max_codepoint=ord("z")
),
)
),
)
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_invalid_ext_save(self, valid_fcsv_file: PathLike[str], ext: str):
assume(not ext == "fcsv" or not ext == "json")
def test_invalid_ext_save(
self, valid_file: PathLike[str], ext: str, invalid_ext: str
):
assume(not invalid_ext == "fcsv" or not invalid_ext == "json")

with tempfile.NamedTemporaryFile(
mode="w", prefix="sub-test_desc-", suffix=f"_afids.{ext}"
mode="w", prefix="sub-test_desc-", suffix=f"_afids.{invalid_ext}"
) as out_file:
afid_set = AfidSet.load(valid_fcsv_file)
afid_set = AfidSet.load(valid_file.with_suffix(f".{ext}"))
with pytest.raises(ValueError, match="Unsupported file extension"):
afid_set.save(out_file.name)

@given(afid_set=af_st.afid_sets())
@given(afid_set=af_st.afid_sets(), ext=st.sampled_from(["fcsv", "json"]))
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_save_invalid_coord_system(self, afid_set: AfidSet):
def test_save_invalid_coord_system(self, afid_set: AfidSet, ext: str):
afid_set.coord_system = "invalid"

with tempfile.NamedTemporaryFile(
mode="w", prefix="sub-test_desc-", suffix="_afids.fcsv"
mode="w", prefix="sub-test_desc-", suffix=f"_afids.{ext}"
) as out_file:
with pytest.raises(
ValueError, match=".*invalid coordinate system"
Expand Down Expand Up @@ -352,8 +358,8 @@ class TestAfidsCore:
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_valid_get_afid(self, valid_fcsv_file: PathLike[str], label: int):
afid_set = AfidSet.load(valid_fcsv_file)
def test_valid_get_afid(self, valid_file: PathLike[str], label: int):
afid_set = AfidSet.load(valid_file)
afid_pos = afid_set.get_afid(label)

# Check array type
Expand All @@ -363,10 +369,8 @@ def test_valid_get_afid(self, valid_fcsv_file: PathLike[str], label: int):
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_invalid_get_afid(
self, valid_fcsv_file: PathLike[str], label: int
):
afid_set = AfidSet.load(valid_fcsv_file)
def test_invalid_get_afid(self, valid_file: PathLike[str], label: int):
afid_set = AfidSet.load(valid_file)
assume(not 1 <= label <= len(afid_set.afids))

with pytest.raises(InvalidFiducialError, match=".*not valid"):
Expand Down
Loading

0 comments on commit 916cbc3

Please sign in to comment.