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
-[](https://github.com/alisterburt/imodmodel/raw/main/LICENSE)
+[](https://github.com/teamtomo/imodmodel/raw/main/LICENSE)
[](https://pypi.org/project/imodmodel)
[](https://python.org)
-[](https://github.com/alisterburt/imodmodel/actions/workflows/test_and_deploy.yml)
+[](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
-[](https://github.com/alisterburt/imodmodel/raw/main/LICENSE)
+[](https://github.com/teamtomo/imodmodel/raw/main/LICENSE)
[](https://pypi.org/project/imodmodel)
[](https://python.org)
-[](https://github.com/alisterburt/imodmodel/actions/workflows/ci.yml)
+[](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:
+
+
+ { width="600" }
+ The resulting position of points in XY slices
+
+
+
+ { 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)