Skip to content

Commit

Permalink
partially tkkuehn comments
Browse files Browse the repository at this point in the history
- Remove `io.py`, moving methods to the AfidSet class. `load` is now a ClassMethod while `save` is a method of the instance.
- Added an `AfidPosition` class, which is now used to store AFIDs
- Drops polars from use, favouring `List[AfidPosition]` instead
- Update mismatched desc in template.fcsv
- Update strategies.py to generate coords using `AfidPosition`
- Update all tests to correspond with changes made
  • Loading branch information
kaitj committed Aug 23, 2023
1 parent 8c51097 commit 488abd5
Show file tree
Hide file tree
Showing 8 changed files with 411 additions and 440 deletions.
155 changes: 125 additions & 30 deletions afids_utils/afids.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,133 @@
"""Anatomical fiducial classes"""
from __future__ import annotations

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

import attrs
import numpy as np
import polars as pl
from numpy.typing import NDArray

from afids_utils.exceptions import InvalidFiducialError
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(dict):
"""Base class for a set of fiducials"""
class AfidSet:
"""Base class for a set of AFIDs"""

slicer_version: str = attrs.field()
coord_system: str = attrs.field()
afids_df: pl.DataFrame = 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
def __attrs_post_init__(self):
self["metadata"] = {
"slicer_version": self.slicer_version,
"coord_system": self.coord_system,
}
self["afids"] = self.afids_df
Returns
-------
AfidSet
Set of anatomical fiducials containing coordinates and metadata
Raises
------
IOError
If extension to fiducial file is not supported
def get_afid(self, label: int) -> NDArray[np.single]:
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
Expand All @@ -35,25 +138,17 @@ def get_afid(self, label: int) -> NDArray[np.single]:
Returns
-------
numpy.ndarray[shape=(3,), dtype=numpy.single]
NumPy array containing spatial coordinates (x, y, z) of single AFID
coordinate
afid_position
Spatial position of Afid (as class AfidPosition)
Raises
------
InvalidFiducialError
If none or more than expected number of fiducials exist
If AFID label given out of valid range
"""

# Filter based off of integer type
if isinstance(label, int):
# 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"]
.filter(pl.col("label") == str(label))
.select("x_mm", "y_mm", "z_mm")
.to_numpy()[0]
)
# 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]
117 changes: 58 additions & 59 deletions afids_utils/ext/fcsv.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,17 @@
from __future__ import annotations

import csv
import io
import re
from importlib import resources
from itertools import islice
from os import PathLike
from typing import Dict

import numpy as np
import polars as pl
from numpy.typing import NDArray

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

HEADER_ROWS: int = 2
FCSV_FIELDNAMES = (
FCSV_FIELDNAMES: tuple[str] = (
"# columns = id",
"x",
"y",
Expand All @@ -32,23 +28,16 @@
"desc",
"associatedNodeID",
)
FCSV_COLS: Dict[str] = {
"x": pl.Float32,
"y": pl.Float32,
"z": pl.Float32,
"label": pl.Utf8,
"desc": pl.Utf8,
}


def _get_metadata(fcsv_path: PathLike[str] | str) -> tuple[str, str]:
def _get_metadata(in_fcsv: io.TextIO) -> tuple[str, str]:
"""
Internal function to extract metadata from header of fcsv files
Parameters
----------
fcsv_path
Path to .fcsv file containing AFIDs coordinates
in_fcsv
Data from provided fcsv file to parse metadata from
Returns
-------
Expand All @@ -64,15 +53,16 @@ def _get_metadata(fcsv_path: PathLike[str] | str) -> tuple[str, str]:
If header is missing or invalid from .fcsv file
"""
try:
with open(fcsv_path, "r") as fcsv:
header = list(islice(fcsv, HEADER_ROWS))
header = list(islice(in_fcsv, HEADER_ROWS))

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

# Set to human-understandable coordinate system
# Transform coordinate system so human-understandable
if parsed_coord == "0":
parsed_coord = "LPS"
elif parsed_coord == "1":
Expand All @@ -84,35 +74,43 @@ def _get_metadata(fcsv_path: PathLike[str] | str) -> tuple[str, str]:
return parsed_version, parsed_coord


def _get_afids(fcsv_path: PathLike[str] | str) -> pl.DataFrame:
def _get_afids(in_fcsv: io.TextIO) -> list[AfidPosition]:
"""
Internal function for converting .fcsv file to a pl.DataFrame
Parameters
----------
fcsv_path
Path to .fcsv file containing AFID coordinates
in_fcsv
Data from provided fcsv file to parse metadata from
Returns
-------
pl.DataFrame
Dataframe containing afids ids, descriptions, and coordinates
afid_positions
List containing spatial position of afids
"""
# Read in fiducials to dataframe, shortening id header
afids_df = pl.read_csv(
fcsv_path,
skip_rows=HEADER_ROWS,
columns=list(FCSV_COLS.keys()),
new_columns=["x_mm", "y_mm", "z_mm"],
dtypes=FCSV_COLS,
)
# Read in AFIDs from fcsv (set to start from 1 to skip header fields)
afids = list(islice(in_fcsv, 1, None))

# 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_df
return afids_positions


def load_fcsv(
fcsv_path: PathLike[str] | str,
) -> AfidSet:
) -> tuple[str, str, list[AfidPosition]]:
"""
Read in fcsv to an AfidSet
Expand All @@ -123,24 +121,26 @@ def load_fcsv(
Returns
-------
AfidSet
Set of anatomical fiducials containing spatial coordinates and metadata
"""
# Grab metadata
slicer_version, coord_system = _get_metadata(fcsv_path)
slicer_version
Slicer version associated with fiducial file
# Grab afids
afids_set = AfidSet(
slicer_version=slicer_version,
coord_system=coord_system,
afids_df=_get_afids(fcsv_path),
)
coord_system
Coordinate system of fiducials
afids_positions
List containing spatial position of afids
"""
with open(fcsv_path) as in_fcsv:
# Grab metadata
slicer_version, coord_system = _get_metadata(in_fcsv)
# Grab afids
afids_positions = _get_afids(in_fcsv)

return afids_set
return slicer_version, coord_system, afids_positions


def save_fcsv(
afid_coords: NDArray[np.single],
afid_coords: list[AfidPosition],
out_fcsv: PathLike[str] | str,
) -> None:
"""
Expand All @@ -149,11 +149,15 @@ def save_fcsv(
Parameters
----------
afid_coords
Floating-point NumPy array containing spatial coordinates (x, y, z)
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(
Expand All @@ -166,21 +170,16 @@ def save_fcsv(
fcsv = list(reader)

# Check to make sure shape of AFIDs array matches expected template
if afid_coords.shape[0] != len(fcsv):
raise TypeError(
f"Expected {len(fcsv)} AFIDs, but received {afid_coords.shape[0]}"
)
if afid_coords.shape[1] != 3:
if len(afid_coords) != len(fcsv):
raise TypeError(
"Expected 3 spatial dimensions (x, y, z),"
f"but received {afid_coords.shape[1]}"
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][0]
row["y"] = afid_coords[idx][1]
row["z"] = afid_coords[idx][2]
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:
Expand Down
Loading

0 comments on commit 488abd5

Please sign in to comment.