Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Documentation for starfiles #470

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions docs/build_node_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@
folder = pathlib.Path(__file__).resolve().parent
file_output_qmd = os.path.join(folder, "nodes/index.qmd")

# open the data file
bpy.ops.wm.open_mainfile(filepath=mn.blender.nodes.MN_DATA_FILE)


def col_to_rgb_str(colors):
values = [int(val * 255) for val in list(colors)]
return "rgb({}, {}, {})".format(*values[0:3])
Expand Down Expand Up @@ -77,7 +73,8 @@ def get_values(sockets):
entry_list = []
desc = entry.get('description')
urls = entry.get('video_url')


mn.blender.nodes.append(name)
inputs = params(get_values(
mn.blender.nodes.inputs(bpy.data.node_groups[name])))
outputs = params(get_values(
Expand Down
68 changes: 68 additions & 0 deletions docs/tutorials/05_spa_annotations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
title: Cryo-EM particles
author: Johannes Elferich
bibliography: references.bib
---

::: {#fig-starfile-example-render}
![](https://i.imgur.com/Hoa9TRz.png){width=80%}

The protein beta-galactosidase as reconstructed by RELION-4.0 floating above the micrograph.
The image was rendered with Blender and the MolecularNodes plugin (Brady Johnston).
The data files are available via [RELION tutorial](https://relion.readthedocs.io/en/release-4.0/SPA_tutorial/Introduction.html) Copyright CC-BY 2024 Johannes Elferich.
:::

An essential step during cryo-EM data processing is to determine the location and orientation of molecules in cryo-EM micrographs.
This information is often saved in starfiles, which can be important to use this information.
By instancing structures or densities using the location and orientation information a mesoscale model of the cryo-EM sample can be generated and visualized.

## Example data

This tutorial uses single particle analysis tutorial data from Relion 4.0.
Download instruction are [here](https://relion.readthedocs.io/en/release-4.0/SPA_tutorial/Introduction.html).
You will only need the precalculated results.

## Annotation panel

In the Import panel of Molecular Nodes change the type to `Annotation`.

![](https://i.imgur.com/aj7wCXP.png)

Then you can select a starfile, optionally assign it a name, and click the `Load` button.
For the puproses of this tutorial you can choose the data file of iteration 16 of the Refine3D job 29 `Refine3D/job029/run_it016_data.star`.
The initial expected result is the creation of a new object in the MolecularNodes collection, which initially will show the position and rotation of particles as RGB-colored axes.
You will want to switch the shading mode to `rendered` and disable the background prop in the default scene.

![](https://i.imgur.com/9THusDv.png)



## The starfile node

To get a better view of the instances, press the `Z` in the axis gizmo of the 3D viewport.
Further control of the instance display is possibly in the `Starfile Instances` node.
For example, enabling `Show Micrograph` will display the micrograph the instances were derived from.
You will want to adjust the Brightness/Constrast setting under `Micrograph options`.
For this micrograph 0.2 and 0.3 work reasonably well.
Another important control is the `Image` input, which will allow you to "scroll" through the micrographs of this dataset.
And, of course, it would be way more visually appealing to see the actual structure of beta-gal instead of the axes.
For this purpose switch the `Method` of the `Import` panel to `Density` and select a mrc file.
In this case I have chosen the masked density of PostProcessing Job 30.
Very importantly, **enable the `Center Density` option.**
Otherwise the maps will not line up correctly with the micrograph.

![](https://i.imgur.com/c3OVFwz.png)

After you click `Load`, a new object will be created.
Select it in the outliner and in the 3D vieport `View` menu click `Frame selected` to inspect it more closely.
Adjust the `Threshold` and `Hide dust` inputs of the `Style density` node to desired values and at this point decide on a good material and color.
Note how the map is precicely centered around the origin of the blender coordinate system, which will allow for accurate placement on the micrograph.

![](https://i.imgur.com/ILA37AV.png)

After you have adjusted the display of the map to your lining, reselect the starfile object and again use `Frame selected` to readjust the display.
Now you will be able to choose the `bGal map` in teh `Molecule` input of the `Starfile Instances` node.
Disable the display of the `bGal map` in the outliner for both the viewpoer and render to only show the starfile instances.


![](https://i.imgur.com/LiJtqdD.png)
Binary file not shown.
Binary file removed molecularnodes/assets/MN_data_file_starfile.blend
Binary file not shown.
22 changes: 11 additions & 11 deletions molecularnodes/blender/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,19 @@ def get_style_node(object):
return style_node(group)


def star_node(group):
def annotation_instances_node(group):
prev = previous_node(get_output(group))
is_star_node = ("MN_starfile_instances" in prev.name)
while not is_star_node:
is_annotation_instances_node = ("MN_annotation_instances" in prev.name)
while not is_annotation_instances_node:
prev = previous_node(prev)
is_star_node = ("MN_starfile_instances" in prev.name)
is_annotation_instances_node = ("MN_annotation_instances" in prev.name)
return prev


def get_star_node(object):
def get_annotation_instances_node(object):
"Walk back through the primary node connections until you find the first style node"
group = object.modifiers['MolecularNodes'].node_group
return star_node(group)
return annotation_instances_node(group)


def get_color_node(object):
Expand Down Expand Up @@ -410,11 +410,11 @@ def change_style_node(object, style):
pass


def create_starting_nodes_starfile(object, n_images=1):
def create_starting_nodes_annotation_instances(object, n_images=1):
# ensure there is a geometry nodes modifier called 'MolecularNodes' that is created and applied to the object
node_mod = get_mod(object)

node_name = f"MN_starfile_{object.name}"
node_name = f"MN_annotation_instances_{object.name}"

# Make sure the aotmic material is loaded
material_default()
Expand All @@ -428,9 +428,9 @@ def create_starting_nodes_starfile(object, n_images=1):
node_output = get_output(group)
node_input.location = [0, 0]
node_output.location = [700, 0]
node_star_instances = add_custom(group, 'MN_starfile_instances', [450, 0])
link(node_star_instances.outputs[0], node_output.inputs[0])
link(node_input.outputs[0], node_star_instances.inputs[0])
node_annotation_instances = add_custom(group, 'MN_annotation_instances', [450, 0])
link(node_annotation_instances.outputs[0], node_output.inputs[0])
link(node_input.outputs[0], node_annotation_instances.inputs[0])

# Need to manually set Image input to 1, otherwise it will be 0 (even though default is 1)
node_mod['Input_3'] = 1
Expand Down
40 changes: 24 additions & 16 deletions molecularnodes/io/parse/star.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ def _rehydrate_ensembles(scene):
if hasattr(obj, 'mn') and 'molecule_type' in obj.mn.keys():
if obj.mn['molecule_type'] == 'star':
ensemble = StarFile.from_blender_object(obj)
if not hasattr(bpy.types.Scene, 'MN_starfile_ensembles'):
bpy.types.Scene.MN_starfile_ensembles = []
bpy.types.Scene.MN_starfile_ensembles.append(ensemble)
if not hasattr(bpy.types.Scene, 'MN_annotation_ensembles'):
bpy.types.Scene.MN_annotation_ensembles = []
bpy.types.Scene.MN_annotation_ensembles.append(ensemble)

class StarFile(Ensemble):
def __init__(self, file_path):
Expand All @@ -34,7 +34,7 @@ def from_blender_object(cls, blender_object):
import bpy
self = cls(blender_object["starfile_path"])
self.object = blender_object
self.star_node = bl.nodes.get_star_node(self.object)
self.annotation_instances_node = bl.nodes.get_annotation_instances_node(self.object)
self.micrograph_material = bl.nodes.MN_micrograph_material()
self.data = self._read()
self.star_type = None
Expand Down Expand Up @@ -75,11 +75,19 @@ def _create_mn_columns(self):
# Standard cryoEM starfile don't have rlnCoordinateZ. If this column is not present
# Set it to "0"
if "rlnCoordinateZ" not in df:
df['rlnCoordinateZ'] = 0
if "rlnDefocusU" in df:
df['rlnCoordinateZ'] = (df['rlnDefocusU'] + df['rlnDefocusV']) / 2
df['rlnCoordinateZ'] = df['rlnCoordinateZ'] - df['rlnCoordinateZ'].median()
else:
df['rlnCoordinateZ'] = 0

self.positions = df[['rlnCoordinateX', 'rlnCoordinateY',
'rlnCoordinateZ']].to_numpy()
pixel_size = df['rlnImagePixelSize'].to_numpy().reshape((-1, 1))
if 'rlnMicrographOriginalPixelSize' in df:
df['MNPixelSize'] = df['rlnMicrographOriginalPixelSize']
else:
df['MNPixelSize'] = df['rlnImagePixelSize']
pixel_size = df['MNPixelSize'].to_numpy().reshape((-1, 1))
self.positions = self.positions * pixel_size
shift_column_names = ['rlnOriginXAngst',
'rlnOriginYAngst', 'rlnOriginZAngst']
Expand All @@ -89,7 +97,7 @@ def _create_mn_columns(self):
df['MNAnglePhi'] = df['rlnAngleRot']
df['MNAngleTheta'] = df['rlnAngleTilt']
df['MNAnglePsi'] = df['rlnAnglePsi']
df['MNPixelSize'] = df['rlnImagePixelSize']

try:
df['MNImageId'] = df['rlnMicrographName'].astype(
'category').cat.codes.to_numpy()
Expand Down Expand Up @@ -121,9 +129,9 @@ def _convert_mrc_to_tiff(self):
import mrcfile
from pathlib import Path
if self.star_type == 'relion':
micrograph_path = self.object['rlnMicrographName_categories'][self.star_node.inputs['Image'].default_value - 1]
micrograph_path = self.object['rlnMicrographName_categories'][self.annotation_instances_node.inputs['Image'].default_value - 1]
elif self.star_type == 'cistem':
micrograph_path = self.object['cisTEMOriginalImageFilename_categories'][self.star_node.inputs['Image'].default_value - 1].strip("'")
micrograph_path = self.object['cisTEMOriginalImageFilename_categories'][self.annotation_instances_node.inputs['Image'].default_value - 1].strip("'")
else:
return False

Expand Down Expand Up @@ -159,15 +167,15 @@ def _convert_mrc_to_tiff(self):

def _update_micrograph_texture(self, *_):
try:
show_micrograph = self.star_node.inputs['Show Micrograph']
show_micrograph = self.annotation_instances_node.inputs['Show Micrograph']
_ = self.object['mn']
except ReferenceError:
bpy.app.handlers.depsgraph_update_post.remove(self._update_micrograph_texture)
return
if self.star_node.inputs['Image'].default_value == self.current_image:
if self.annotation_instances_node.inputs['Image'].default_value == self.current_image:
return
else:
self.current_image = self.star_node.inputs['Image'].default_value
self.current_image = self.annotation_instances_node.inputs['Image'].default_value
if not show_micrograph:
return
tiff_path = self._convert_mrc_to_tiff()
Expand All @@ -178,12 +186,12 @@ def _update_micrograph_texture(self, *_):
image_obj = bpy.data.images.load(str(tiff_path))
image_obj.colorspace_settings.name = 'Non-Color'
self.micrograph_material.node_tree.nodes['Image Texture'].image = image_obj
self.star_node.inputs['Micrograph'].default_value = image_obj
self.annotation_instances_node.inputs['Micrograph'].default_value = image_obj



def create_model(self, name='StarFileObject', node_setup=True, world_scale=0.01):
from molecularnodes.blender.nodes import get_star_node, MN_micrograph_material
from molecularnodes.blender.nodes import get_annotation_instances_node, MN_micrograph_material
blender_object = bl.obj.create_object(
self.positions * world_scale, collection=bl.coll.mn(), name=name)

Expand All @@ -207,13 +215,13 @@ def create_model(self, name='StarFileObject', node_setup=True, world_scale=0.01)
self.data[col].astype('category').cat.categories)

if node_setup:
bl.nodes.create_starting_nodes_starfile(
bl.nodes.create_starting_nodes_annotation_instances(
blender_object, n_images=self.n_images)
self.node_group = blender_object.modifiers['MolecularNodes'].node_group

blender_object["starfile_path"] = str(self.file_path)
self.object = blender_object
self.star_node = get_star_node(self.object)
self.annotation_instances_node = get_annotation_instances_node(self.object)
self.micrograph_material = MN_micrograph_material()
bpy.app.handlers.depsgraph_update_post.append(self._update_micrograph_texture)
return blender_object
5 changes: 3 additions & 2 deletions molecularnodes/io/star.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,22 @@
bpy.types.Scene.MN_import_star_file_name = bpy.props.StringProperty(
name='Name',
description='Name of the created object.',
default='NewStarInstances',
default='NewAnnotationInstances',
maxlen=0
)


def load(
file_path,
name='NewStarInstances',
name='NewAnnotationInstances',
node_setup=True,
world_scale=0.01
):

ensemble = parse.StarFile.from_starfile(file_path)
ensemble.create_model(name=name, node_setup=node_setup,
world_scale=world_scale)
bpy.context.view_layer.objects.active = ensemble.object

return ensemble

Expand Down
7 changes: 7 additions & 0 deletions molecularnodes/ui/node_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,13 @@
'name': 'MN_assembly_center',
"description": "Move an instanced assembly to the world origin. Some structures are not centred on the world origin, so this node can reset them to the world origin for convenient rotation and translation and animation.",
"video_url": "https://imgur.com/pgFTmgC"
},
"break",
{
'label': 'Annotation Instances',
'name': 'MN_annotation_instances',
'description': 'Generates Instances from an imported annotation file',
'video_url': 'https://imgur.com/hoNbHww'
}
],

Expand Down
4 changes: 2 additions & 2 deletions molecularnodes/ui/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
('local', "Local", "Open a local file", 1),
('md', "MD", "Import a molecular dynamics trajectory", 2),
('density', "Density", "Import an EM Density Map", 3),
('star', 'Starfile', "Import a .starfile mapback file", 4),
('star', 'Annotation', "Import an annotation file", 4),
('cellpack', 'CellPack', "Import a CellPack .cif/.bcif file", 5),
('dna', 'oxDNA', 'Import an oxDNA file', 6)
)
Expand Down Expand Up @@ -128,7 +128,7 @@ def panel_object(layout, context):
if mol_type == "star":
layout.label(text=f"Ensemble")
box = layout.box()
ui_from_node(box, nodes.get_star_node(object))
ui_from_node(box, nodes.get_annotation_instances_node(object))
return

row = layout.row(align=True)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_star.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_starfile_attributes(type):
).inv()

# Activate the rotation debug mode in the nodetreee and get the quaternion attribute
debugnode = mn.blender.nodes.star_node(
debugnode = mn.blender.nodes.annotation_instances_node(
ensemble.node_group).node_tree.nodes['Switch.001']
debugnode.inputs['Switch'].default_value = True
quat_attribute = ensemble.get_attribute('MNDEBUGEuler', evaluate=True)
Expand Down Expand Up @@ -74,14 +74,14 @@ def test_micrograph_loading():

ensemble = mn.io.star.load(file)
assert not tiff_path.exists()
ensemble.star_node.inputs['Show Micrograph'].default_value = True
ensemble.annotation_instances_node.inputs['Show Micrograph'].default_value = True
bpy.context.evaluated_depsgraph_get().update()
assert tiff_path.exists()
# Ensure montage get only loaded once
assert sum(1 for image in bpy.data.images.keys()
if 'montage' in image) == 1
assert ensemble.micrograph_material.node_tree.nodes['Image Texture'].image.name == 'montage.tiff'
assert ensemble.star_node.inputs['Micrograph'].default_value.name == 'montage.tiff'
assert ensemble.annotation_instances_node.inputs['Micrograph'].default_value.name == 'montage.tiff'


@pytest.mark.skipif(SKIP, reason='Test may segfault on GHA')
Expand All @@ -94,6 +94,6 @@ def test_rehydration(tmp_path):
bpy.ops.wm.read_homefile()
assert ensemble._update_micrograph_texture not in bpy.app.handlers.depsgraph_update_post
bpy.ops.wm.open_mainfile(filepath=str(tmp_path / "test.blend"))
new_ensemble = bpy.types.Scene.MN_starfile_ensembles[0]
new_ensemble = bpy.types.Scene.MN_annotation_ensembles[0]
assert new_ensemble._update_micrograph_texture in bpy.app.handlers.depsgraph_update_post
assert new_ensemble.data.equals(ensemble.data)
Loading