Skip to content

Commit

Permalink
Slan to ImodModel attribute and IMAT to ImodModel.object attribute + …
Browse files Browse the repository at this point in the history
…testing & documentation (#20)

* slan example

* Update docs/index.md

* Update docs/index.md

* 1. Made slans a model attribute and IMAT an object attribute.
2. Added an example file with multiple objects
3. Added testing for the file with multiple objects.

* updated docs with examples of using ImodModel

* Changed slan to slicer_angle and slans to slicer_angles. Kept instances of SLAN.

* Update docs/index.md

* Update src/imodmodel/dataframe.py

* Update docs/index.md

* Update tests/conftest.py

* Update tests/test_functional_api.py

* Update tests/test_parsers.py

* slicer_angle -> slicer_angles

---------

Co-authored-by: alisterburt <[email protected]>
  • Loading branch information
braxtonowens and alisterburt authored Jun 13, 2024
1 parent 6e7d464 commit 5908efc
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 73 deletions.
102 changes: 98 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,120 @@ Out[3]:
```

Annotations made in the [slicer window](https://bio3d.colorado.edu/imod/doc/3dmodHelp/slicer.html) are stored in the IMOD binary file with both centerpoints and angles.
Slicer angles saved in the [slicer window](https://bio3d.colorado.edu/imod/doc/3dmodHelp/slicer.html)
are stored in the IMOD binary file with both centerpoints and angles.

These annotations can be read in by setting `annotation='slan'` when calling `imodmodel.read()`
These annotations can be read in by setting `annotation='slicer_angle'` when calling `imodmodel.read()`

```python
import imodmodel

df = imodmodel.read('file_with_slicer_angles.mod', annotation='slan')
df = imodmodel.read('file_with_slicer_angles.mod', annotation='slicer_angles')
```

```ipython
In [3]: df.head()
Out[3]:
object_id slan_id time x_rot y_rot z_rot center_x center_y center_z label
object_id slicer_angle_id time x_rot y_rot z_rot center_x center_y center_z label
0 0 0 1 13.100000 0.0 -30.200001 235.519577 682.744141 302.0
0 0 1 1 -41.400002 0.0 -47.700001 221.942444 661.193237 327.0
0 0 2 1 -41.400002 0.0 -41.799999 232.790726 671.332031 327.0
0 0 3 1 -35.500000 0.0 -36.000000 240.129181 679.927795 324.0
```

## ImodModel

The resulting dataframe from `imodmodel.read()` contains only information about the contours or slicer angles.
The full set of information from the imod model file can be parsed using `ImodModel`

```python
from imodmodel import ImodModel

my_model = ImodModel.from_file("my_model_file.mod")
```

```ipython
in [3]: my_model.model_field_set
out[3]:
{'id', 'extra', 'objects', 'slicer_angles', 'header'}
```

### my_model.id

`my_model.id` contains the IMOD file id and the version id

```ipython
in [4]: my_model.id
out[4]:
ID(IMOD_file_id='IMOD', version_id='V1.2')
```

### my_model.header

`my_model.header` is contains the model structure data mainly used by IMOD.

```ipython
in [5]: my_model.header
out[5]:
ModelHeader(name='IMOD-NewModel', xmax=956, ymax=924, zmax=300, objsize=3, flags=62976, drawmode=1,
mousemode=1, blacklevel=145, whitelevel=173, xoffset=0.0, yoffset=0.0, zoffset=0.0, xscale=1.0, yscale=10,
zscale=1.0, object=2, contour=-1, point=-1, res=3, thresh=128, pixelsize=1.9733333587646484, units=-9,
csum=704518946, alpha=0.0, beta=0.0, gamma=0.0)
```

### my_model.objects

`my_model.objects` is a `list` IMOD objects.

```ipython
in [6]: my_model.objects[0].header
out[6]:
ObjectHeader(name='', extra_data=[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], contsize=0,
flags=402653184, axis=0, drawmode=1, red=0.0, green=1.0, blue=0.0, pdrawsize=0, symbol=1, symsize=3,
linewidth2=1, linewidth=1, linesty=0, symflags=0, sympad=0, trans=0, meshsize=0, surfsize=0)
```

This is where object values like contours, meshes, and IMAT information are located.

```ipython
in [7]: my_model.objects[1].meshes[0].indices
out[7]:
array([[38, 40, 52],
[38, 52, 50],
[50, 52, 64],
[50, 64, 60],
...,
[ 4, 10, 26],
[ 4, 26, 20],
[20, 26, 38],
[20, 38, 32]])
```

```ipython
in [8]: my_model.objects[1].imat
out[8]:
IMAT(ambient=102, diffuse=255, specular=127, shininess=4, fillred=0, fillgreen=0, fillblue=0,
quality=0, mat2=0, valblack=0, valwhite=255, matflags2=0, mat3b3=0)
```

```ipython
in [9]: my_model.objects[1].contours[0].points
out[9]:
array([[367.00006104, 661.83343506, 134. ],
[415.66674805, 667.83343506, 134. ],
[474.33340454, 662.50012207, 134. ]])
```

### my_model.slicer_angles

`my_model.slicer_angles` is a `list` of slicer angles.

```ipython
in [10]: my_model.slicer_angles[0]
out[10]:
SLAN(time=1, angles=(0.0, 0.0, 0.0), center=(533.5, 717.0, 126.0), label='\x00')
```

That's it!

## Installation
Expand Down
53 changes: 26 additions & 27 deletions src/imodmodel/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@
def model_to_dataframe(model: ImodModel, annotation: str = 'contour') -> pd.DataFrame:
"""Convert ImodModel model into a pandas DataFrame."""
object_dfs: List[pd.DataFrame] = []
for object_idx, object in enumerate(model.objects):
if annotation == 'slan':
if len(object.slans) == 0:
raise ValueError("Model has no SLANs.")
for slan_idx, slan in enumerate(object.slans):
slan_df = slan_to_dataframe(slan, object_idx, slan_idx)
object_dfs.append(slan_df)
elif annotation == 'contour':
for contour_idx, contour in enumerate(object.contours):
contour_df = contour_to_dataframe(contour, object_idx, contour_idx)
object_dfs.append(contour_df)
else:
raise ValueError(f"Unknown annotation type: {annotation}")
if annotation == 'slicer_angles':
if len(model.slicer_angles) == 0:
raise ValueError("Model has no slicer angles.")
for slicer_angle_idx, slicer_angle in enumerate(model.slicer_angles):
slicer_angle_df = slicer_angle_to_dataframe(slicer_angle, slicer_angle_idx)
object_dfs.append(slicer_angle_df)
elif annotation == 'contour':
for object_idx, object in enumerate(model.objects):
for contour_idx, contour in enumerate(object.contours):
contour_df = contour_to_dataframe(contour, object_idx, contour_idx)
object_dfs.append(contour_df)
else:
raise ValueError(f"Unknown annotation type: {annotation}")
return pd.concat(object_dfs)


Expand All @@ -39,18 +39,17 @@ def contour_to_dataframe(
return pd.DataFrame(contour_data)


def slan_to_dataframe(slan: SLAN, object_id: int, slan_id: int) -> pd.DataFrame:
"""Convert SLAN model into a pandas DataFrame."""
slan_data = {
"object_id": [object_id],
"slan_id": [slan_id],
"time": [slan.time],
"x_rot": [slan.angles[0]],
"y_rot": [slan.angles[1]],
"z_rot": [slan.angles[2]],
"center_x": [slan.center[0]],
"center_y": [slan.center[1]],
"center_z": [slan.center[2]],
"label": [slan.label],
def slicer_angle_to_dataframe(slicer_angle: SLAN, slicer_angle_id: int) -> pd.DataFrame:
"""Convert slicer angle model into a pandas DataFrame."""
slicer_angle_data = {
"slicer_angle_id": [slicer_angle_id],
"time": [slicer_angle.time],
"x_rot": [slicer_angle.angles[0]],
"y_rot": [slicer_angle.angles[1]],
"z_rot": [slicer_angle.angles[2]],
"center_x": [slicer_angle.center[0]],
"center_y": [slicer_angle.center[1]],
"center_z": [slicer_angle.center[2]],
"label": [slicer_angle.label],
}
return pd.DataFrame(slan_data)
return pd.DataFrame(slicer_angle_data)
2 changes: 1 addition & 1 deletion src/imodmodel/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def read(filename: os.PathLike, annotation: str = 'contour') -> pd.DataFrame:
Parameters
----------
filename : filename to read
annotation: which annotation of the model to return ['contour', 'slan'] (default: 'contour')
annotation: which annotation of the model to return ['contour', 'slicer_angles'] (default: 'contour')
"""
model = ImodModel.from_file(filename)
return model_to_dataframe(model,annotation)
4 changes: 2 additions & 2 deletions src/imodmodel/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,8 @@ class Object(BaseModel):
header: ObjectHeader
contours: List[Contour] = []
meshes: List[Mesh] = []
slans: List[SLAN] = []
extra: List[GeneralStorage] = []
imat: Optional[IMAT] = None


class ImodModel(BaseModel):
Expand All @@ -245,7 +245,7 @@ class ImodModel(BaseModel):
id: ID
header: ModelHeader
objects: List[Object]
imat: Optional[IMAT] = None
slicer_angles: List[SLAN] = []
extra: List[GeneralStorage] = []

@classmethod
Expand Down
10 changes: 5 additions & 5 deletions src/imodmodel/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def _parse_general_storage(file: BinaryIO) -> List[GeneralStorage]:
storages.append(GeneralStorage(type=type, flags=flags, index=index, value=value))
return storages

def _parse_slan(file: BinaryIO) -> SLAN:
def _parse_slicer_angle(file: BinaryIO) -> SLAN:
_parse_chunk_size(file)
data = _parse_from_specification(file, ModFileSpecification.SLAN)
return SLAN(**data)
Expand All @@ -158,15 +158,15 @@ def parse_model(file: BinaryIO) -> ImodModel:
id = _parse_id(file)
header = _parse_model_header(file)
control_sequence = _parse_control_sequence(file)
imat = None
slicer_angles = []
extra = list()

objects = []
while control_sequence != "IEOF":
if control_sequence == "OBJT":
objects.append(_parse_object(file))
elif control_sequence == "IMAT":
imat = _parse_imat(file)
objects[-1].imat = _parse_imat(file)
elif control_sequence == "CONT":
objects[-1].contours.append(_parse_contour(file))
elif control_sequence == "MESH":
Expand All @@ -180,8 +180,8 @@ def parse_model(file: BinaryIO) -> ImodModel:
elif control_sequence == "MEST":
objects[-1].meshes[-1].extra += _parse_general_storage(file)
elif control_sequence == "SLAN":
objects[-1].slans.append(_parse_slan(file))
slicer_angles.append(_parse_slicer_angle(file))
else:
_parse_unknown(file)
control_sequence = _parse_control_sequence(file)
return ImodModel(id=id, header=header, objects=objects, imat=imat, extra=extra)
return ImodModel(id=id, header=header, objects=objects, slicer_angles=slicer_angles, extra=extra)
18 changes: 14 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@ def meshed_curvature_model_file_handle(meshed_curvature_model_file):
return open(meshed_curvature_model_file, mode='rb')

@pytest.fixture
def slan_model_file() -> Path:
def slicer_angle_model_file() -> Path:
"""A model file from Mohammed's slicerangle measurements."""
return TEST_DATA_DIRECTORY / 'slan_example.mod'
return TEST_DATA_DIRECTORY / 'slicer_angle_example.mod'

@pytest.fixture
def slan_model_file_handle(slan_model_file):
def slicer_angle_model_file_handle(slicer_angle_model_file):
"""A file handle with Mohammed's slicerangle measurements."""
return open(slan_model_file, mode='rb')
return open(slicer_angle_model_file, mode='rb')

@pytest.fixture
def multiple_objects_model_file() -> Path:
"""A model file containing multiple object data."""
return TEST_DATA_DIRECTORY / 'multiple_objects_example.mod'

@pytest.fixture
def multiple_objects_model_file_handle(slicer_angle_model_file):
"""A file handle with multiple object data."""
return open(slicer_angle_model_file, mode='rb')
Binary file added tests/test_data/multiple_objects_example.mod
Binary file not shown.
File renamed without changes.
29 changes: 15 additions & 14 deletions tests/test_functional_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,33 @@
import imodmodel


def test_read(two_contour_model_file, meshed_contour_model_file, slan_model_file):
def test_read(two_contour_model_file, meshed_contour_model_file, slicer_angle_model_file):
"""Check that model files can be read."""
example_contour_files = [two_contour_model_file, meshed_contour_model_file, slan_model_file]
example_slan_files = [slan_model_file]
example_contour_files = [two_contour_model_file, meshed_contour_model_file, slicer_angle_model_file]
example_slicer_angle_files = [slicer_angle_model_file]
expected_contour_columns = ['object_id', 'contour_id', 'x', 'y', 'z']
expected_slan_columns = [
'object_id', 'slan_id', 'time', 'x_rot', 'y_rot', 'z_rot', 'center_x',
expected_slicer_angle_columns = [
'object_id', 'slicer_angle_id', 'time', 'x_rot', 'y_rot', 'z_rot', 'center_x',
'center_y', 'center_z', 'label']

for file in example_contour_files:
contour_df = imodmodel.read(file)
assert isinstance(contour_df, pd.DataFrame)
assert all(col in expected_contour_columns for col in contour_df.columns)

for file in example_slicer_angle_files:
slicer_angle_df = imodmodel.read(file, annotation='slicer_angles')
assert isinstance(slicer_angle_df, pd.DataFrame)
assert all(col in expected_slicer_angle_columns for col in slicer_angle_df.columns)

for file in example_slan_files:
slan_df = imodmodel.read(file,annotation='slan')
assert isinstance(slan_df, pd.DataFrame)
assert all(col in expected_slan_columns for col in slan_df.columns)

def test_no_slan(two_contour_model_file):
"""Check that an error is raised if a model with no SLANs is read with the 'slan' annotation."""
with pytest.raises(ValueError,match="Model has no SLANs."):
df = imodmodel.read(two_contour_model_file, annotation='slan')
def test_no_slicer_angles(two_contour_model_file):
"""Check that an error is raised if a model with no slicer_angles is read with the 'slicer_angle' annotation."""
with pytest.raises(ValueError, match="Model has no slicer angles."):
df = imodmodel.read(two_contour_model_file, annotation='slicer_angles')


def test_unknown_annotation(two_contour_model_file):
"""Check that an error is raised if an unknown annotation is requested."""
with pytest.raises(ValueError,match="Unknown annotation type: unknown"):
with pytest.raises(ValueError, match="Unknown annotation type: unknown"):
df = imodmodel.read(two_contour_model_file, annotation='unknown')
33 changes: 27 additions & 6 deletions tests/test_model_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Contour,
ContourHeader,
Mesh,
IMAT,
SLAN,
MeshHeader,
Object,
Expand Down Expand Up @@ -35,16 +36,36 @@ def test_read_contour(file_fixture_contour, meshes_expected, request):
assert isinstance(model.objects[0].meshes[0].header, MeshHeader)

@pytest.mark.parametrize(
"file_fixture_slan",
"file_fixture_slicer_angle",
[
('slan_model_file'),
('slicer_angle_model_file'),
]
)
def test_read_slan(file_fixture_slan, request):
def test_read_slicer_angle(file_fixture_slicer_angle, request):
"""Check the model based API"""
file = request.getfixturevalue(file_fixture_slan)
file = request.getfixturevalue(file_fixture_slicer_angle)
model = ImodModel.from_file(file)
assert isinstance(model, ImodModel)
assert len(model.objects) == 1
assert len(model.slicer_angles) == 4
assert isinstance(model.slicer_angles, list)
assert isinstance(model.slicer_angles[0], SLAN)

@pytest.mark.parametrize(
"file_fixture_multiple_objects, objects_expected",
[
('multiple_objects_model_file', 3),
]
)
def test_multiple_objects(file_fixture_multiple_objects, objects_expected, request):
file = request.getfixturevalue(file_fixture_multiple_objects)
model = ImodModel.from_file(file)
assert isinstance(model, ImodModel)
assert len(model.objects) == objects_expected
assert isinstance(model.objects[0], Object)
assert isinstance(model.objects[0].slans[0], SLAN)
assert isinstance(model.objects[0].imat, IMAT)
assert isinstance(model.objects[1].contours[0], Contour)
assert isinstance(model.objects[1], Object)
assert isinstance(model.objects[1].imat, IMAT)
assert isinstance(model.objects[2].contours[0], Contour)


Loading

0 comments on commit 5908efc

Please sign in to comment.