diff --git a/docs/build_node_docs.py b/docs/build_node_docs.py index cbb10f35..7ce50f2f 100644 --- a/docs/build_node_docs.py +++ b/docs/build_node_docs.py @@ -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]) @@ -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( diff --git a/docs/tutorials/05_spa_annotations.md b/docs/tutorials/05_spa_annotations.md new file mode 100644 index 00000000..be468acc --- /dev/null +++ b/docs/tutorials/05_spa_annotations.md @@ -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) \ No newline at end of file diff --git a/molecularnodes/assets/MN_data_file_annotation.blend b/molecularnodes/assets/MN_data_file_annotation.blend new file mode 100644 index 00000000..ec53105b Binary files /dev/null and b/molecularnodes/assets/MN_data_file_annotation.blend differ diff --git a/molecularnodes/assets/MN_data_file_starfile.blend b/molecularnodes/assets/MN_data_file_starfile.blend deleted file mode 100644 index f912728c..00000000 Binary files a/molecularnodes/assets/MN_data_file_starfile.blend and /dev/null differ diff --git a/molecularnodes/blender/nodes.py b/molecularnodes/blender/nodes.py index 84f6531f..ae49e604 100644 --- a/molecularnodes/blender/nodes.py +++ b/molecularnodes/blender/nodes.py @@ -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): @@ -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() @@ -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 diff --git a/molecularnodes/io/parse/star.py b/molecularnodes/io/parse/star.py index b6a273a5..60511624 100644 --- a/molecularnodes/io/parse/star.py +++ b/molecularnodes/io/parse/star.py @@ -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): @@ -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 @@ -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'] @@ -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() @@ -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 @@ -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() @@ -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) @@ -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 diff --git a/molecularnodes/io/star.py b/molecularnodes/io/star.py index 5b6743ca..c1c7cd33 100644 --- a/molecularnodes/io/star.py +++ b/molecularnodes/io/star.py @@ -10,14 +10,14 @@ 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 ): @@ -25,6 +25,7 @@ def load( 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 diff --git a/molecularnodes/ui/node_info.py b/molecularnodes/ui/node_info.py index 2d78eaa4..55026ced 100644 --- a/molecularnodes/ui/node_info.py +++ b/molecularnodes/ui/node_info.py @@ -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' } ], diff --git a/molecularnodes/ui/panel.py b/molecularnodes/ui/panel.py index 3556b3df..672c3a20 100644 --- a/molecularnodes/ui/panel.py +++ b/molecularnodes/ui/panel.py @@ -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) ) @@ -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) diff --git a/tests/test_star.py b/tests/test_star.py index d53df983..b9efdcb4 100644 --- a/tests/test_star.py +++ b/tests/test_star.py @@ -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) @@ -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') @@ -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)