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

Refactor to create a AfidSet class #14

Merged
merged 18 commits into from
Aug 24, 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
154 changes: 154 additions & 0 deletions afids_utils/afids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Anatomical fiducial classes"""
from __future__ import annotations

import json
from importlib import resources
from os import PathLike
from pathlib import Path

import attrs

from afids_utils.exceptions import InvalidFiducialError, InvalidFileError


@attrs.define
class AfidPosition:
"""Base class for a single AFID position"""

label: int = attrs.field()
x: float = attrs.field()
y: float = attrs.field()
z: float = attrs.field()
desc: str = attrs.field()


@attrs.define
class AfidSet:
"""Base class for a set of AFIDs"""

slicer_version: str = attrs.field()
coord_system: str = attrs.field()
afids: list[AfidPosition] = attrs.field()

@classmethod
def load(cls, afids_fpath: PathLike[str] | str) -> AfidSet:
"""
Load an AFIDs file

Parameters
----------
afids_fpath
Path to .fcsv or .json file containing AFIDs information

Returns
-------
AfidSet
Set of anatomical fiducials containing coordinates and metadata

Raises
------
IOError
If extension to fiducial file is not supported

InvalidFileError
If fiducial file has none or more than expected number of fiducials

InvalidFiducialError
If description in fiducial file does not match expected
"""
# Check if file exists
afids_fpath = Path(afids_fpath)
if not afids_fpath.exists():
raise FileNotFoundError("Provided AFID file does not exist")

afids_fpath_ext = afids_fpath.suffix

# Loading fcsv
if afids_fpath_ext == ".fcsv":
from afids_utils.ext.fcsv import load_fcsv

slicer_version, coord_system, afids_positions = load_fcsv(
afids_fpath
)
# Loading json
# if afids_fpath_ext = ".json":
# load_json(afids_path)
else:
raise ValueError("Unsupported file extension")

# Perform validation of loaded file
# Load expected mappings
with resources.open_text(
"afids_utils.resources", "afids_descs.json"
) as json_fpath:
mappings = json.load(json_fpath)
# Check expected number of fiducials exist
if len(afids_positions) != len(mappings["human"]):
raise InvalidFileError("Unexpected number of fiducials")

# Validate descriptions, before dropping
for label in range(len(afids_positions)):
if afids_positions[label].desc not in mappings["human"][label]:
raise InvalidFiducialError(
f"Description for label {label+1} does not match expected"
)

return cls(
slicer_version=slicer_version,
coord_system=coord_system,
afids=afids_positions,
)

# TODO: Handle the metadata - specifically setting the coordinate system
def save(self, out_fpath: PathLike[str] | str) -> None:
"""Save AFIDs to Slicer-compatible file

Parameters
----------
out_fpath
Path of file (including filename and extension) to save AFIDs to

Raises
------
ValueError
If file extension is not supported
"""

out_fpath_ext = Path(out_fpath).suffix

# Saving fcsv
if out_fpath_ext == ".fcsv":
from afids_utils.ext.fcsv import save_fcsv

save_fcsv(self.afids, out_fpath)
# Saving json
# if out_fpath_ext = ".json":
# save_json(afids_coords, out_fpath)
else:
raise ValueError("Unsupported file extension")

def get_afid(self, label: int) -> AfidPosition:
"""
Extract a specific AFID's spatial coordinates

Parameters
----------
label
Unique AFID label to extract from

Returns
-------
afid_position
Spatial position of Afid (as class AfidPosition)

Raises
------
InvalidFiducialError
If AFID label given out of valid range
"""

# Fiducial selection out of bounds
if label < 1 or label > len(self.afids):
raise InvalidFiducialError(f"AFID label {label} is not valid")

return self.afids[label - 1]
15 changes: 11 additions & 4 deletions afids_utils/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"""Custom exceptions"""


class InvalidFiducialNumberError(Exception):
"""Exception for invalid fiducial number"""
class InvalidFileError(Exception):
"""Exception raised when file to be parsed is invalid"""

def __init__(self, fid_num: int) -> None:
super().__init__(f"Provided fiducial {fid_num} is not valid.")
def __init__(self, message):
super().__init__(message)


class InvalidFiducialError(Exception):
"""Exception for invalid fiducial selection"""

def __init__(self, message) -> None:
super().__init__(message)
Empty file added afids_utils/ext/__init__.py
Empty file.
191 changes: 191 additions & 0 deletions afids_utils/ext/fcsv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Methods for handling .fcsv files associated with AFIDs"""
from __future__ import annotations

import csv
import re
from importlib import resources
from os import PathLike

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

HEADER_ROWS: int = 2
FCSV_FIELDNAMES: tuple[str] = (
"# columns = id",
"x",
"y",
"z",
"ow",
"ox",
"oy",
"oz",
"vis",
"sel",
"lock",
"label",
"desc",
"associatedNodeID",
)


def _get_metadata(in_fcsv: list[str]) -> tuple[str, str]:
"""
Internal function to extract metadata from header of fcsv files

Parameters
----------
in_fcsv
Data from provided fcsv 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 missing or invalid from .fcsv file
"""
try:
header = in_fcsv[: HEADER_ROWS + 1]

# Parse version and coordinate system
parsed_version = re.findall(r"\d+\.\d+", header[0])[0]
parsed_coord = re.split(r"\s", header[1])[-2]

except IndexError:
raise InvalidFileError("Missing or invalid header in .fcsv file")

# 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 in header")

return parsed_version, parsed_coord


def _get_afids(in_fcsv: list[str]) -> list[AfidPosition]:
"""
Internal function for converting .fcsv file to a pl.DataFrame

Parameters
----------
in_fcsv
Data from provided fcsv file to parse metadata from

Returns
-------
afid_positions
List containing spatial position of afids
"""
# Read in AFIDs from fcsv (set to start from 1 to skip header fields)
afids = in_fcsv[HEADER_ROWS + 1 :]

# Add to list of AfidPosition
afids_positions = []
for afid in afids:
afid = afid.split(",")
afids_positions.append(
AfidPosition(
label=int(afid[-3]),
x=float(afid[1]),
y=float(afid[2]),
z=float(afid[3]),
desc=afid[-2],
)
)

return afids_positions


def load_fcsv(
fcsv_path: PathLike[str] | str,
) -> tuple[str, str, list[AfidPosition]]:
"""
Read in fcsv to an AfidSet

Parameters
----------
fcsv_path
Path to .fcsv 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(fcsv_path) as in_fcsv_fpath:
in_fcsv = in_fcsv_fpath.readlines()

# Grab metadata
slicer_version, coord_system = _get_metadata(in_fcsv)
# Grab afids
afids_positions = _get_afids(in_fcsv)

return slicer_version, coord_system, afids_positions


def save_fcsv(
afid_coords: list[AfidPosition],
out_fcsv: PathLike[str] | str,
) -> None:
"""
Save fiducials to output fcsv file

Parameters
----------
afid_coords
List of AFID spatial positions

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(
"afids_utils.resources", "template.fcsv"
) as template_fcsv_file:
header = [
template_fcsv_file.readline() for _ in range(HEADER_ROWS + 1)
]
reader = csv.DictReader(template_fcsv_file, fieldnames=FCSV_FIELDNAMES)
fcsv = list(reader)

# Check to make sure shape of AFIDs array matches expected template
if len(afid_coords) != len(fcsv):
raise TypeError(
f"Expected {len(fcsv)} AFIDs, but received {len(afid_coords)}"
)

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

# Write output fcsv
with open(out_fcsv, "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)
Loading