diff --git a/docs/index.md b/docs/index.md index 76aae6e..fd04a9f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/src/imodmodel/dataframe.py b/src/imodmodel/dataframe.py index 2f2ac52..e9e591b 100644 --- a/src/imodmodel/dataframe.py +++ b/src/imodmodel/dataframe.py @@ -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) @@ -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) diff --git a/src/imodmodel/functions.py b/src/imodmodel/functions.py index 4a70840..67541fe 100644 --- a/src/imodmodel/functions.py +++ b/src/imodmodel/functions.py @@ -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) diff --git a/src/imodmodel/models.py b/src/imodmodel/models.py index c7fdaa5..d49c2d3 100644 --- a/src/imodmodel/models.py +++ b/src/imodmodel/models.py @@ -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): @@ -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 diff --git a/src/imodmodel/parsers.py b/src/imodmodel/parsers.py index f45e0f8..e38aa82 100644 --- a/src/imodmodel/parsers.py +++ b/src/imodmodel/parsers.py @@ -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) @@ -158,7 +158,7 @@ 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 = [] @@ -166,7 +166,7 @@ def parse_model(file: BinaryIO) -> ImodModel: 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": @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 50eaf95..c4ee430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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') diff --git a/tests/test_data/multiple_objects_example.mod b/tests/test_data/multiple_objects_example.mod new file mode 100644 index 0000000..4195a89 Binary files /dev/null and b/tests/test_data/multiple_objects_example.mod differ diff --git a/tests/test_data/slan_example.mod b/tests/test_data/slicer_angle_example.mod similarity index 100% rename from tests/test_data/slan_example.mod rename to tests/test_data/slicer_angle_example.mod diff --git a/tests/test_functional_api.py b/tests/test_functional_api.py index 42fe4e2..6c062e3 100644 --- a/tests/test_functional_api.py +++ b/tests/test_functional_api.py @@ -4,13 +4,13 @@ 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: @@ -18,18 +18,19 @@ def test_read(two_contour_model_file, meshed_contour_model_file, slan_model_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') diff --git a/tests/test_model_api.py b/tests/test_model_api.py index cd77687..e5f3245 100644 --- a/tests/test_model_api.py +++ b/tests/test_model_api.py @@ -4,6 +4,7 @@ Contour, ContourHeader, Mesh, + IMAT, SLAN, MeshHeader, Object, @@ -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) + + diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 2a00ced..f9e8311 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -12,7 +12,7 @@ _parse_object_header, _parse_contour, _parse_imat, - _parse_slan, + _parse_slicer_angle, _parse_chunk_size, _parse_from_type_flags, _parse_general_storage, @@ -229,21 +229,21 @@ def test_parse_imat(two_contour_model_file_handle): ) ] ) -def test_parse_slan( - slan_model_file_handle, +def test_parse_slicer_angles( + slicer_angle_model_file_handle, position: int, expected_time: int, expected_angles: np.ndarray, expected_center: np.ndarray, expected_label: str, ): - slan_model_file_handle.seek(position) - slan = _parse_slan(slan_model_file_handle) - assert slan.time == expected_time - assert np.allclose(slan.angles,expected_angles) - assert np.allclose(slan.center,expected_center) - assert slan.label == expected_label - slan_model_file_handle.close() + slicer_angle_model_file_handle.seek(position) + slicer_angle = _parse_slicer_angle(slicer_angle_model_file_handle) + assert slicer_angle.time == expected_time + assert np.allclose(slicer_angle.angles,expected_angles) + assert np.allclose(slicer_angle.center,expected_center) + assert slicer_angle.label == expected_label + slicer_angle_model_file_handle.close() @pytest.mark.parametrize( "bytes, flag, index_expected, value_expected",