diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 75d2b5e..8076079 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -21,10 +21,11 @@ jobs: matrix: platform: [ ubuntu-latest ] python-version: [ - "3.8", "3.9", "3.10", "3.11", + "3.12", + "3.13", ] steps: diff --git a/README.md b/README.md index d713d18..5102329 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # imodmodel -[![License](https://img.shields.io/pypi/l/imodmodel.svg?color=green)](https://github.com/alisterburt/imodmodel/raw/main/LICENSE) +[![License](https://img.shields.io/pypi/l/imodmodel.svg?color=green)](https://github.com/teamtomo/imodmodel/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/imodmodel.svg?color=green)](https://pypi.org/project/imodmodel) [![Python Version](https://img.shields.io/pypi/pyversions/imodmodel.svg?color=green)](https://python.org) -[![CI](https://github.com/alisterburt/imodmodel/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/alisterburt/imodmodel/actions/workflows/test_and_deploy.yml) +[![CI](https://github.com/teamtomo/imodmodel/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/teamtomo/imodmodel/actions/workflows/test_and_deploy.yml) -Read [IMOD model files](https://bio3d.colorado.edu/imod/doc/binspec.html) +Read and write [IMOD model files](https://bio3d.colorado.edu/imod/doc/binspec.html) as [pandas dataframes](https://pandas.pydata.org/) in Python. ## Usage -### As pandas DataFrame +### Read IMOD models as pandas DataFrame ```python import imodmodel @@ -32,87 +32,14 @@ Out[3]: ``` - -### As ImodModel object +### Write IMOD models from a pandas DataFrame ```python -from imodmodel import ImodModel -model = ImodModel.from_file("my_model_file.mod") +imodmodel.write(df, 'my_new_modelfile.mod') ``` -```ipython -In [3]: model.objects[0].contours[0].points -Out[3]: -array([[ 6.875, 62.875, 124. ], ...]) - -In [4]: model.objects[0].meshes[0].vertices -Out[4]: -array([[ 6.87500000e+00, 6.28750000e+01, 1.24000000e+02], ...]) - -In [5]: model.objects[0].meshes[0].indices -Out[5]: -array([[156, 18, 152], ...]) - -In [6]: model.objects[0].meshes[0].face_values -Out[6]: -array([0., 0., 35.22094345, ...]) -``` - -That's it! - -### Create and save model files - -```python -model = ImodModel( - objects=[ - Object( - color=(0.0,1.0,0.0), - header = ObjectHeader( - flags=ObjectFlags( - scattered=True - ) - ), - contours = [ - Contour( - points=np.array([ - [4.5,8.0,0.0], - [9.0,8.0,0.0] - ]) - - ) - ] - ), - Object( - color=(0.0,0.0,1.0), - header=ObjectHeader( - pdrawsize=0 - ), - contours=[ - Contour( - points = np.column_stack((6.75 + 6.75 * np.cos(np.linspace(0, 2 * np.pi, 200, endpoint=False)), 6.75 + 6.75 * np.sin(np.linspace(0, 2 * np.pi, 200, endpoint=False)), np.zeros(200))) - ) - ] - ), - Object( - color=(1.0,0.0,0.0), - header=ObjectHeader( - pdrawsize=0, - flags=ObjectFlags( - open=True - ) - ), - - contours=[ - Contour( - points = np.column_stack((6.75 + 4.75 * np.cos(np.linspace(1 * np.pi, 2 * np.pi, 100, endpoint=False)), 6.75 + 4.75 * np.sin(np.linspace( 1 * np.pi, 2 * np.pi, 100, endpoint=False)), np.zeros(100))) - ) - ] - ) - ] -) - -model.to_file("smiley.mod") -``` +For more advanced use cases we also provide an object-based API. +Please consult our [Documentation](https://teamtomo.org/imodmodel/). ## Installation `imodmodel` can be installed from the [Python Package Index](https://pypi.org/) (PyPI) diff --git a/docs/assets/coordinates_multiz.png b/docs/assets/coordinates_multiz.png new file mode 100644 index 0000000..d165e0e Binary files /dev/null and b/docs/assets/coordinates_multiz.png differ diff --git a/docs/assets/coordinates_xyz.png b/docs/assets/coordinates_xyz.png new file mode 100644 index 0000000..4bfc4ff Binary files /dev/null and b/docs/assets/coordinates_xyz.png differ diff --git a/docs/index.md b/docs/index.md index fd04a9f..bc218d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,16 +1,18 @@ # Overview -[![License](https://img.shields.io/pypi/l/imodmodel.svg?color=green)](https://github.com/alisterburt/imodmodel/raw/main/LICENSE) +[![License](https://img.shields.io/pypi/l/imodmodel.svg?color=green)](https://github.com/teamtomo/imodmodel/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/imodmodel.svg?color=green)](https://pypi.org/project/imodmodel) [![Python Version](https://img.shields.io/pypi/pyversions/imodmodel.svg?color=green)](https://python.org) -[![CI](https://github.com/alisterburt/imodmodel/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/alisterburt/imodmodel/actions/workflows/ci.yml) +[![CI](https://github.com/teamtomo/imodmodel/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/teamtomo/imodmodel/actions/workflows/ci.yml) -Read [IMOD model files](https://bio3d.colorado.edu/imod/doc/binspec.html) +Read and write [IMOD model files](https://bio3d.colorado.edu/imod/doc/binspec.html) as [pandas dataframes](https://pandas.pydata.org/) in Python. ## Usage +### Read IMOD models as pandas DataFrame + ```python import imodmodel @@ -29,119 +31,10 @@ Out[3]: ``` - -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='slicer_angle'` when calling `imodmodel.read()` - -```python -import imodmodel - -df = imodmodel.read('file_with_slicer_angles.mod', annotation='slicer_angles') -``` - -```ipython -In [3]: df.head() -Out[3]: - 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` +### Write IMOD models from a pandas DataFrame ```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') +imodmodel.write(df, 'my_new_modelfile.mod') ``` That's it! diff --git a/docs/object_api.md b/docs/object_api.md new file mode 100644 index 0000000..4c3a500 --- /dev/null +++ b/docs/object_api.md @@ -0,0 +1,94 @@ + +# Object-based API + +`imodmodel.read()` and `imodmodel.write()` are convenient APIs for accessing information from contours or slicer angles from an IMOD model file. + +`ImodModel` is a [pydantic model](https://docs.pydantic.dev/latest/) for the data in a model file. A more complete set of the information in an IMOD model files can be accessed using the `ImodModel.from_file()` method. + +```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') +``` diff --git a/docs/slicer_angles.md b/docs/slicer_angles.md new file mode 100644 index 0000000..f8907d8 --- /dev/null +++ b/docs/slicer_angles.md @@ -0,0 +1,22 @@ +# Slicer 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='slicer_angle'` when calling `imodmodel.read()` + +```python +import imodmodel + +df = imodmodel.read('file_with_slicer_angles.mod', annotation='slicer_angles') +``` + +```ipython +In [3]: df.head() +Out[3]: + 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 +``` diff --git a/docs/z_coordinate_system_note.md b/docs/z_coordinate_system_note.md new file mode 100644 index 0000000..cbd7a4d --- /dev/null +++ b/docs/z_coordinate_system_note.md @@ -0,0 +1,46 @@ +# A note about 3dmod's coordinate system + +Please note that 3dmod places (0,0,0) at the bottom left corner in XY planes, +but in the center of the first Z-plane. + +For example, this code: + +```python +import imodmodel +import pandas as pd +import numpy as np +import mrcfile + +df = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 1, 2, 3], + "z": [0, 1, 2, 3], + } +) + +volume = np.zeros((4, 4, 4), dtype=np.float32) +for i in range(4): + for j in range(4): + for k in range(4): + if (i + j + k) % 2 == 0: + volume[i, j, k] = 1.0 + +mrcfile.write("co.mrc", volume, overwrite=True) +imodmodel.write(df, "co.mod") +``` + +results in the following: + +
+ ![Multi-Z window](assets/coordinates_multiz.png){ width="600" } +
The resulting position of points in XY slices
+
+ +
+ ![XYZ window](assets/coordinates_xyz.png){ width="450" } +
The resulting position of (0,0,0) in XYZ slices
+
+ +Since many other programs place (0,0,0) in the center of the first voxel, +one might consider adding (0.5,0.5,0) to the coordinates before saving. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a25ee4b..8007e0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" name = "imodmodel" description = "IMOD model files as pandas DataFrames in Python." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = {text = "BSD 3-Clause License"} authors = [ {email = "alisterburt@gmail.com"}, @@ -73,7 +73,7 @@ source = "vcs" # https://github.com/charliermarsh/ruff [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py39" extend-select = [ "E", # style errors "F", # flakes diff --git a/src/imodmodel/__init__.py b/src/imodmodel/__init__.py index df5e407..7abf5ae 100644 --- a/src/imodmodel/__init__.py +++ b/src/imodmodel/__init__.py @@ -1,4 +1,4 @@ -from .functions import read +from .functions import read, write from .models import ImodModel -__all__ = ["read", "ImodModel"] +__all__ = ["read", "write", "ImodModel"] diff --git a/src/imodmodel/functions.py b/src/imodmodel/functions.py index 67541fe..aa500f9 100644 --- a/src/imodmodel/functions.py +++ b/src/imodmodel/functions.py @@ -1,6 +1,6 @@ import os import pandas as pd -from .models import ImodModel +from .models import ImodModel, ContourType from .dataframe import model_to_dataframe @@ -14,3 +14,14 @@ def read(filename: os.PathLike, annotation: str = 'contour') -> pd.DataFrame: """ model = ImodModel.from_file(filename) return model_to_dataframe(model,annotation) + +def write(dataframe: pd.DataFrame, filename: os.PathLike, type: ContourType=ContourType.SCATTERED) -> None: + """Write a pandas DataFrame to an IMOD model file. + + Parameters + ---------- + dataframe : pandas DataFrame to write + filename : filename to write + """ + model = ImodModel.from_dataframe(dataframe, type=type) + model.to_file(filename) diff --git a/src/imodmodel/models.py b/src/imodmodel/models.py index c2fbe7c..958097c 100644 --- a/src/imodmodel/models.py +++ b/src/imodmodel/models.py @@ -1,8 +1,10 @@ +from enum import Enum import os import warnings from typing import List, Optional, Tuple, Union import numpy as np +import pandas as pd from pydantic import BaseModel, ConfigDict, field_validator, model_validator class ID(BaseModel): @@ -69,6 +71,12 @@ class ContourFlags(IntFlagModel): connect_invert: bool = False +class ContourType(Enum): + OPEN = "open" + CLOSED = "closed" + SCATTERED = "scattered" + + class ContourHeader(BaseModel): """https://bio3d.colorado.edu/imod/doc/binspec.html""" psize: int = 0 @@ -486,7 +494,43 @@ def from_file(cls, filename: os.PathLike): from .parsers import parse_model with open(filename, 'rb') as file: return parse_model(file) - + + @classmethod + def from_dataframe(cls, dataframe: pd.DataFrame, type: ContourType = ContourType.SCATTERED): + """Construct an ImodModel instance from a pandas DataFrame.""" + + # Ensure the DataFrame has the required columns + required_columns = ['x', 'y', 'z'] + for col in required_columns: + if col not in dataframe.columns: + raise ValueError(f"DataFrame must contain the column '{col}'") + if "object_id" not in dataframe.columns: + dataframe["object_id"] = 0 + if "contour_id" not in dataframe.columns: + dataframe["contour_id"] = 0 + + model = ImodModel() + for object_id, object_group in dataframe.groupby("object_id"): + obj = Object() + for contour_id, contour_group in object_group.groupby("contour_id"): + contour = Contour( + points=contour_group[['x', 'y', 'z']].values, + ) + obj.contours.append(contour) + if type == ContourType.SCATTERED: + obj.header.flags.scattered = True + obj.header.flags.open = False + elif type == ContourType.OPEN: + obj.header.flags.scattered = False + obj.header.flags.open = True + elif type == ContourType.CLOSED: + obj.header.flags.scattered = False + obj.header.flags.open = False + obj.update_sizes() + model.objects.append(obj) + model.update_sizes() + return model + def to_file(self, filename: os.PathLike): """Write an IMOD model to disk.""" from .writers import write_model diff --git a/tests/test_functional_api.py b/tests/test_functional_api.py index 6c062e3..5799baa 100644 --- a/tests/test_functional_api.py +++ b/tests/test_functional_api.py @@ -34,3 +34,14 @@ 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"): df = imodmodel.read(two_contour_model_file, annotation='unknown') + +def test_write(two_contour_model_file,tmp_path): + """Check that a model can be written to a file.""" + # Read the original model + original_model = imodmodel.read(two_contour_model_file) + # Write the model to a new file + imodmodel.write(original_model, tmp_path / "test_model.imod") + # Read the new model + new_model = imodmodel.read(tmp_path / "test_model.imod") + # Check that the new model matches the original + assert original_model.equals(new_model)