Compare commits
27 Commits
v0.1.0-bet
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e929013183 | ||
| d380cf7036 | |||
| 05a61527e7 | |||
|
|
2c80c9ed81 | ||
|
|
8e8b176e11 | ||
| c1886f95f8 | |||
| 5e8d12c564 | |||
| 1990a16972 | |||
| eeca79e681 | |||
|
|
8b45de4a4d | ||
| 2c3d9e5791 | |||
|
|
32b69575b1 | ||
| d663bfd290 | |||
|
|
a1aa776725 | ||
| 6b18b4e9fa | |||
| a579b24f9a | |||
|
|
8b3ab61467 | ||
|
|
084031c496 | ||
| 15b24b61b5 | |||
|
|
81dae0631a | ||
|
|
38d2e52c93 | ||
| cf6ccb1743 | |||
| f889b61494 | |||
|
|
436288df7c | ||
|
|
7967f144f1 | ||
|
|
ba4c6c1dee | ||
| ca9df9065e |
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 OmooLab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
46
README.md
@@ -1,3 +1,45 @@
|
||||
# BioxelNodes
|
||||
BioxelNodes is a blender add-on for better volume data visualization with cycles.
|
||||
[中文文档](https://uj6xfhbzp0.feishu.cn/wiki/Qx3VwHuNPimeI8kr6nDcvl1DnHf?from=from_copylink)
|
||||
|
||||
# Bioxel Nodes
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Bioxel Nodes is a Blender add-on for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes | Cycles to process and render volumetric data.
|
||||
|
||||
# About
|
||||
|
||||
Before us, there have been many tutorials and add-ons for importing volumetric data into Blender. However, we found that there were many details that were not addressed in place, some scientific facts were ignored, and the volume rendering was not pretty enough. With Bioxel Nodes, you can easily import the volumetric data into Blender, and more importantly, it can quickly make a beautiful realistic rendering of it.
|
||||
|
||||
Below are some examples with Bioxel Nodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail:
|
||||
|
||||

|
||||
|
||||
The "Bioxel" in "BioxelNodes", is a combination of the words "Bio-" and "Voxel". Bioxel is a voxel that stores biological data. The volumetric data made up of Bioxel is called Bioxels. We are developing a toolkit around Bioxels for better biological data visualization. but before its release, we made this Blender version of bioxels toolkit first, in order to let more people to have fun with volumetric data. [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started)
|
||||
|
||||
# Supported Format
|
||||
|
||||
| Format | EXT | Test |
|
||||
| ------ | ---------------------------------------- | ------- |
|
||||
| DICOM | .dcm, .DICOM | ✅ pass |
|
||||
| BMP | .bmp, .BMP | ✅ pass |
|
||||
| JPEG | .jpg, .JPG, .jpeg, .JPEG | ✅ pass |
|
||||
| PNG | .png, .PNG | ✅ pass |
|
||||
| TIFF | .tif, .TIF, .tiff, .TIFF | ✅ pass |
|
||||
| Nifti | .nia, .nii, .nii.gz, .hdr, .img, .img.gz | ✅ pass |
|
||||
| Nrrd | .nrrd, .nhdr | ✅ pass |
|
||||
| Meta | .mha, .mhd | yet |
|
||||
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
|
||||
| VTK | .vtk | yet |
|
||||
| BioRad | .PIC, .pic | yet |
|
||||
| Gipl | .gipl, .gipl.gz | yet |
|
||||
| LSM | .lsm, .LSM | yet |
|
||||
| MINC | .mnc, .MNC | yet |
|
||||
| MRC | .mrc, .rec | yet |
|
||||
|
||||
# Known limitations
|
||||
|
||||
- Sections cannot be generated
|
||||
- Time sequence volume not supported (will be supported soon)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
from .nodes import custom_nodes
|
||||
from . import auto_load
|
||||
from . import menu
|
||||
|
||||
import bpy
|
||||
|
||||
from . import auto_load
|
||||
from . import menus
|
||||
|
||||
|
||||
bl_info = {
|
||||
"name": "BiovelNodes",
|
||||
"name": "Bioxel Nodes",
|
||||
"author": "Ma Nan",
|
||||
"description": "",
|
||||
"blender": (4, 0, 0),
|
||||
"version": (0, 1, 0),
|
||||
"version": (0, 1, 1),
|
||||
"location": "File -> Import",
|
||||
"warning": "",
|
||||
"category": "Import-Export"
|
||||
"category": "Node"
|
||||
}
|
||||
|
||||
auto_load.init()
|
||||
@@ -21,14 +20,14 @@ auto_load.init()
|
||||
|
||||
def register():
|
||||
auto_load.register()
|
||||
custom_nodes.register()
|
||||
menu.create_menu()
|
||||
menus.add()
|
||||
bpy.types.Scene.bioxels_dir = bpy.props.StringProperty(
|
||||
name="Bioxels Directory",
|
||||
subtype='DIR_PATH',
|
||||
default="//"
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
try:
|
||||
menu.remove_menu()
|
||||
custom_nodes.unregister()
|
||||
auto_load.unregister()
|
||||
except RuntimeError:
|
||||
pass
|
||||
menus.remove()
|
||||
auto_load.unregister()
|
||||
|
||||
BIN
bioxelnodes/assets/Nodes/BioxelNodes_4.0.blend
LFS
Normal file
@@ -16,6 +16,7 @@ blender_version = bpy.app.version
|
||||
modules = None
|
||||
ordered_classes = None
|
||||
|
||||
|
||||
def init():
|
||||
global modules
|
||||
global ordered_classes
|
||||
@@ -23,6 +24,7 @@ def init():
|
||||
modules = get_all_submodules(Path(__file__).parent)
|
||||
ordered_classes = get_ordered_classes_to_register(modules)
|
||||
|
||||
|
||||
def register():
|
||||
for cls in ordered_classes:
|
||||
bpy.utils.register_class(cls)
|
||||
@@ -33,15 +35,22 @@ def register():
|
||||
if hasattr(module, "register"):
|
||||
module.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(ordered_classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
try:
|
||||
bpy.utils.unregister_class(cls)
|
||||
except Exception as e:
|
||||
print(f"Fail to unregister {cls}, skiped")
|
||||
|
||||
for module in modules:
|
||||
if module.__name__ == __name__:
|
||||
continue
|
||||
if hasattr(module, "unregister"):
|
||||
module.unregister()
|
||||
try:
|
||||
if module.__name__ == __name__:
|
||||
continue
|
||||
if hasattr(module, "unregister"):
|
||||
module.unregister()
|
||||
except Exception as e:
|
||||
print(f"Fail to unregister {module}, skiped")
|
||||
|
||||
|
||||
# Import modules
|
||||
@@ -50,10 +59,12 @@ def unregister():
|
||||
def get_all_submodules(directory):
|
||||
return list(iter_submodules(directory, directory.name))
|
||||
|
||||
|
||||
def iter_submodules(path, package_name):
|
||||
for name in sorted(iter_submodule_names(path)):
|
||||
yield importlib.import_module("." + name, package_name)
|
||||
|
||||
|
||||
def iter_submodule_names(path, root=""):
|
||||
for _, module_name, is_package in pkgutil.iter_modules([str(path)]):
|
||||
if is_package:
|
||||
@@ -70,19 +81,24 @@ def iter_submodule_names(path, root=""):
|
||||
def get_ordered_classes_to_register(modules):
|
||||
return toposort(get_register_deps_dict(modules))
|
||||
|
||||
|
||||
def get_register_deps_dict(modules):
|
||||
my_classes = set(iter_my_classes(modules))
|
||||
my_classes_by_idname = {cls.bl_idname : cls for cls in my_classes if hasattr(cls, "bl_idname")}
|
||||
my_classes_by_idname = {
|
||||
cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
|
||||
|
||||
deps_dict = {}
|
||||
for cls in my_classes:
|
||||
deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname))
|
||||
deps_dict[cls] = set(iter_my_register_deps(
|
||||
cls, my_classes, my_classes_by_idname))
|
||||
return deps_dict
|
||||
|
||||
|
||||
def iter_my_register_deps(cls, my_classes, my_classes_by_idname):
|
||||
yield from iter_my_deps_from_annotations(cls, my_classes)
|
||||
yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname)
|
||||
|
||||
|
||||
def iter_my_deps_from_annotations(cls, my_classes):
|
||||
for value in typing.get_type_hints(cls, {}, {}).values():
|
||||
dependency = get_dependency_from_annotation(value)
|
||||
@@ -90,6 +106,7 @@ def iter_my_deps_from_annotations(cls, my_classes):
|
||||
if dependency in my_classes:
|
||||
yield dependency
|
||||
|
||||
|
||||
def get_dependency_from_annotation(value):
|
||||
if blender_version >= (2, 93):
|
||||
if isinstance(value, bpy.props._PropertyDeferred):
|
||||
@@ -100,6 +117,7 @@ def get_dependency_from_annotation(value):
|
||||
return value[1]["type"]
|
||||
return None
|
||||
|
||||
|
||||
def iter_my_deps_from_parent_id(cls, my_classes_by_idname):
|
||||
if bpy.types.Panel in cls.__bases__:
|
||||
parent_idname = getattr(cls, "bl_parent_id", None)
|
||||
@@ -108,6 +126,7 @@ def iter_my_deps_from_parent_id(cls, my_classes_by_idname):
|
||||
if parent_cls is not None:
|
||||
yield parent_cls
|
||||
|
||||
|
||||
def iter_my_classes(modules):
|
||||
base_types = get_register_base_types()
|
||||
for cls in get_classes_in_modules(modules):
|
||||
@@ -115,6 +134,7 @@ def iter_my_classes(modules):
|
||||
if not getattr(cls, "is_registered", False):
|
||||
yield cls
|
||||
|
||||
|
||||
def get_classes_in_modules(modules):
|
||||
classes = set()
|
||||
for module in modules:
|
||||
@@ -122,11 +142,13 @@ def get_classes_in_modules(modules):
|
||||
classes.add(cls)
|
||||
return classes
|
||||
|
||||
|
||||
def iter_classes_in_module(module):
|
||||
for value in module.__dict__.values():
|
||||
if inspect.isclass(value):
|
||||
yield value
|
||||
|
||||
|
||||
def get_register_base_types():
|
||||
return set(getattr(bpy.types, name) for name in [
|
||||
"Panel", "Operator", "PropertyGroup", "FileHandler",
|
||||
@@ -134,7 +156,7 @@ def get_register_base_types():
|
||||
"Node", "NodeSocket", "NodeTree",
|
||||
"UIList", "RenderEngine",
|
||||
"Gizmo", "GizmoGroup",
|
||||
])
|
||||
] if hasattr(bpy.types, name))
|
||||
|
||||
|
||||
# Find order to register to solve dependencies
|
||||
@@ -151,5 +173,6 @@ def toposort(deps_dict):
|
||||
sorted_values.add(value)
|
||||
else:
|
||||
unsorted.append(value)
|
||||
deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted}
|
||||
deps_dict = {value: deps_dict[value] -
|
||||
sorted_values for value in unsorted}
|
||||
return sorted_list
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import bpy
|
||||
from .utils import get_bioxels_obj
|
||||
from .utils import get_bioxels_obj, get_node_by_type
|
||||
|
||||
|
||||
class ConvertToMesh(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.convert_to_mesh"
|
||||
bl_label = "Bioxels To Mesh"
|
||||
bl_description = "Convert Bioxels To Mesh."
|
||||
bl_description = "Convert Bioxels To Mesh"
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
bioxels_obj = None
|
||||
for obj in bpy.context.selected_objects:
|
||||
bioxels_obj = get_bioxels_obj(obj)
|
||||
break
|
||||
|
||||
return True if bioxels_obj else False
|
||||
|
||||
def execute(self, context):
|
||||
bioxels_objs = []
|
||||
|
||||
@@ -16,7 +25,7 @@ class ConvertToMesh(bpy.types.Operator):
|
||||
if bioxels_obj:
|
||||
bioxels_objs.append(bioxels_obj)
|
||||
|
||||
if len(bioxels_objs)==0:
|
||||
if len(bioxels_objs) == 0:
|
||||
self.report({"WARNING"}, "Cannot find any bioxels.")
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -31,7 +40,7 @@ class ConvertToMesh(bpy.types.Operator):
|
||||
nodes = modifier.node_group.nodes
|
||||
links = modifier.node_group.links
|
||||
|
||||
output_node = nodes.get("Group Output")
|
||||
output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0]
|
||||
object_node = nodes.new("GeometryNodeObjectInfo")
|
||||
realize_nodes = nodes.new("GeometryNodeRealizeInstances")
|
||||
object_node.inputs[0].default_value = bioxels_obj
|
||||
@@ -43,4 +52,6 @@ class ConvertToMesh(bpy.types.Operator):
|
||||
bpy.context.object.active_material_index = 1
|
||||
bpy.ops.object.material_slot_remove()
|
||||
|
||||
self.report({"INFO"}, f"Successfully convert to mesh")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .node_menu import CustomNodes
|
||||
1
bioxelnodes/customnodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .menus import CustomNodes
|
||||
@@ -1,8 +1,60 @@
|
||||
import shutil
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
from .nodes import AddCustomNode
|
||||
|
||||
|
||||
class SaveAllNodes(bpy.types.Operator):
|
||||
bl_idname = "customnodes.save_all_nodes"
|
||||
bl_label = "Save All Custom Nodes"
|
||||
bl_description = "Save All Custom Nodes to Directory."
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
files = []
|
||||
for classname in dir(bpy.types):
|
||||
if "CUSTOMNODES_MT_NODES_" in classname:
|
||||
cls = getattr(bpy.types, classname)
|
||||
files.append(cls.nodes_file)
|
||||
files = list(set(files))
|
||||
|
||||
for file in files:
|
||||
file_name = Path(file).name
|
||||
# "//"
|
||||
custom_nodes_dir = bpy.path.abspath(context.scene.custom_nodes_dir)
|
||||
|
||||
output_path: Path = Path(custom_nodes_dir, file_name).resolve()
|
||||
source_path: Path = Path(file).resolve()
|
||||
|
||||
if output_path != source_path:
|
||||
shutil.copy(source_path, output_path)
|
||||
|
||||
for lib in bpy.data.libraries:
|
||||
lib_path = str(Path(bpy.path.abspath(lib.filepath)).resolve())
|
||||
if lib_path == file:
|
||||
blend_path = Path(bpy.path.abspath("//")).resolve()
|
||||
lib.filepath = bpy.path.relpath(
|
||||
str(output_path), start=str(blend_path))
|
||||
|
||||
self.report({"INFO"}, f"Successfully saved to {output_path}")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class CUSTOMNODES_PT_CustomNodes(bpy.types.Panel):
|
||||
bl_idname = "CUSTOMNODES_PT_CustomNodes"
|
||||
bl_label = "Custom Nodes"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
scene = context.scene
|
||||
layout.prop(scene, 'custom_nodes_dir')
|
||||
layout.operator(SaveAllNodes.bl_idname)
|
||||
|
||||
|
||||
class CustomNodes():
|
||||
def __init__(
|
||||
self,
|
||||
@@ -12,10 +64,10 @@ class CustomNodes():
|
||||
root_icon='NONE'
|
||||
) -> None:
|
||||
if not Path(nodes_file).is_file():
|
||||
raise FileNotFoundError(Path(nodes_file).resolve().as_posix())
|
||||
raise FileNotFoundError(str(Path(nodes_file).resolve()))
|
||||
|
||||
self.menu_items = menu_items
|
||||
self.nodes_file = Path(nodes_file).resolve().as_posix()
|
||||
self.nodes_file = str(Path(nodes_file).resolve())
|
||||
|
||||
self.root_label = root_label
|
||||
self.root_icon = root_icon
|
||||
@@ -28,7 +80,7 @@ class CustomNodes():
|
||||
)
|
||||
self.menu_classes = menu_classes
|
||||
|
||||
idname = f"CUSTOMNODES_MT_NODES__{root_label.replace(' ', '').upper()}"
|
||||
idname = f"CUSTOMNODES_MT_NODES_{root_label.replace(' ', '').upper()}"
|
||||
|
||||
def add_node_menu(self, context):
|
||||
if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type):
|
||||
@@ -40,7 +92,7 @@ class CustomNodes():
|
||||
|
||||
def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None):
|
||||
nodes_file = self.nodes_file
|
||||
idname_namespace = idname_namespace or "CUSTOMNODES_MT_NODES_"
|
||||
idname_namespace = idname_namespace or "CUSTOMNODES_MT_NODES"
|
||||
idname = f"{idname_namespace}_{label.replace(' ', '').upper()}"
|
||||
|
||||
# create submenu class if item is menu.
|
||||
@@ -60,6 +112,7 @@ class CustomNodes():
|
||||
class Menu(bpy.types.Menu):
|
||||
bl_idname = idname
|
||||
bl_label = label
|
||||
nodes_file = self.nodes_file
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
@@ -123,12 +176,17 @@ class CustomNodes():
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
bpy.types.NODE_MT_add.append(self.add_node_menu)
|
||||
bpy.types.Scene.custom_nodes_dir = bpy.props.StringProperty(
|
||||
name="Nodes Directory",
|
||||
subtype='DIR_PATH',
|
||||
default="//"
|
||||
)
|
||||
|
||||
def unregister(self):
|
||||
bpy.types.NODE_MT_add.remove(self.add_node_menu)
|
||||
try:
|
||||
for cls in reversed(self.menu_classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
bpy.types.NODE_MT_add.remove(self.add_node_menu)
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1 +0,0 @@
|
||||
from .preferences import ExternalPackagePreferences
|
||||
2
bioxelnodes/externalpackage/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .preferences import ExternalPackagePreferences
|
||||
from .package import PackageInstaller
|
||||
@@ -42,12 +42,13 @@ class InstallationError(Exception):
|
||||
class PackageInstaller():
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str,
|
||||
requirements_dir: str,
|
||||
log_dir: str = None,
|
||||
pypi_mirror_provider='Default',
|
||||
) -> None:
|
||||
self.pypi_mirror_provider = pypi_mirror_provider
|
||||
self.log_path: Path = Path(log_dir)
|
||||
self.log_path: Path = Path(log_dir) if log_dir \
|
||||
else Path.home() / '.externalpackage' / 'logs'
|
||||
self.requirements_path: Path = Path(requirements_dir)
|
||||
|
||||
def start_logging(self, logfile_name: str = 'side-packages-install') -> logging.Logger:
|
||||
@@ -231,7 +232,7 @@ class PackageInstaller():
|
||||
"""
|
||||
|
||||
# path to python.exe
|
||||
python_exe = Path(sys.executable).resolve().as_posix()
|
||||
python_exe = str(Path(sys.executable).resolve())
|
||||
|
||||
# build the command list
|
||||
cmd_list = [python_exe] + cmd_list
|
||||
@@ -250,7 +251,7 @@ class PackageInstaller():
|
||||
if result.returncode != 0:
|
||||
log.error('Command failed: %s', cmd_list)
|
||||
log.error('stdout: %s', result.stdout.decode())
|
||||
log.error('stderr: %s', result.stderr.decode())
|
||||
log.error('stderr: %s', result.stderr.decode(errors='ignore'))
|
||||
else:
|
||||
log.info('Command succeeded: %s', cmd_list)
|
||||
log.info('stdout: %s', result.stdout.decode())
|
||||
@@ -350,6 +351,7 @@ class EXTERNALPACKAGE_OT_Install_Package(bpy.types.Operator):
|
||||
name='Python Package',
|
||||
description='Python Package to Install'
|
||||
) # type: ignore
|
||||
|
||||
version: bpy.props.StringProperty(
|
||||
name='Python Package',
|
||||
description='Python Package to Install'
|
||||
@@ -373,7 +375,7 @@ class EXTERNALPACKAGE_OT_Install_Package(bpy.types.Operator):
|
||||
)
|
||||
|
||||
result = installer.install_package(f"{self.package}=={self.version}")
|
||||
if result.returncode == 0 and installer.is_installed(self.package):
|
||||
if result.returncode == 0 or installer.is_installed(self.package):
|
||||
self.report(
|
||||
{'INFO'},
|
||||
f"Successfully installed {self.package} v{self.version}"
|
||||
@@ -381,9 +383,7 @@ class EXTERNALPACKAGE_OT_Install_Package(bpy.types.Operator):
|
||||
else:
|
||||
self.report(
|
||||
{'ERROR'},
|
||||
f"""
|
||||
Error installing package. Please check the log files
|
||||
in {preferences.log_dir}.
|
||||
"""
|
||||
f"""Error installing package. Please check the log files
|
||||
in {preferences.log_dir}."""
|
||||
)
|
||||
return {'FINISHED'}
|
||||
@@ -1,4 +1,5 @@
|
||||
import bpy
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from .package import PackageInstaller, PYPI_MIRROR
|
||||
|
||||
@@ -10,17 +11,36 @@ def get_pypi_mirrors(self, context, edit_text):
|
||||
return PYPI_MIRROR.keys()
|
||||
|
||||
|
||||
class RebootBlender(bpy.types.Operator):
|
||||
bl_idname = "externalpackage.reboot_blender"
|
||||
bl_label = "Reboot Blender"
|
||||
bl_description = "Reboot Blender"
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.invoke_confirm(self, event)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def execute(self, context):
|
||||
blender_launcher = Path(
|
||||
bpy.app.binary_path).parent / "blender-launcher.exe"
|
||||
subprocess.run([str(blender_launcher), "-con", "--python-expr",
|
||||
"import bpy; bpy.ops.wm.recover_last_session()"])
|
||||
bpy.ops.wm.quit_blender()
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ExternalPackagePreferences():
|
||||
requirements_dir: bpy.props.StringProperty(
|
||||
name="requirements.txt Directory",
|
||||
subtype='DIR_PATH',
|
||||
default=Path(__file__).parent.as_posix()
|
||||
default=str(Path(__file__).parent)
|
||||
) # type: ignore
|
||||
|
||||
log_dir: bpy.props.StringProperty(
|
||||
name="Python Log Directory",
|
||||
subtype='DIR_PATH',
|
||||
default=Path(Path.home(), '.externalpackage', 'logs').as_posix()
|
||||
default=str(Path(Path.home(), '.externalpackage', 'logs'))
|
||||
) # type: ignore
|
||||
|
||||
pypi_mirror_provider: bpy.props.StringProperty(
|
||||
@@ -34,13 +54,13 @@ class ExternalPackagePreferences():
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Install the required packages.")
|
||||
layout.label(
|
||||
text='**Install the required packages, then hit "Reboot Blender"**')
|
||||
|
||||
col_main = layout.column(heading='', align=False)
|
||||
row_import = col_main.row()
|
||||
row_import.prop(self, 'pypi_mirror_provider', text='Set PyPI Mirror')
|
||||
row_import.prop(self, 'pypi_mirror_provider', text='PyPI Mirror')
|
||||
|
||||
|
||||
installer = PackageInstaller(
|
||||
pypi_mirror_provider=self.pypi_mirror_provider,
|
||||
log_dir=self.log_dir,
|
||||
@@ -71,3 +91,5 @@ class ExternalPackagePreferences():
|
||||
op.package = name
|
||||
op.version = version
|
||||
op.description = f'Install required python package: {name}'
|
||||
|
||||
layout.operator(RebootBlender.bl_idname)
|
||||
@@ -6,71 +6,146 @@ import numpy as np
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
import mathutils
|
||||
from .utils import calc_bbox_verts, extract_last_number, get_bioxels_obj, show_message
|
||||
import math
|
||||
from .utils import calc_bbox_verts, get_text_index_str, get_bioxels_obj, get_node_by_type, show_message
|
||||
from .nodes import custom_nodes
|
||||
from .props import BIOXELNODES_Series
|
||||
try:
|
||||
import SimpleITK as sitk
|
||||
except:
|
||||
...
|
||||
|
||||
SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp', '', '.jpg']
|
||||
SUPPORT_EXTS = ['.dcm', '.DICOM',
|
||||
'.bmp', '.BMP',
|
||||
'.PIC', '.pic',
|
||||
'.gipl', '.gipl.gz',
|
||||
'.jpg', '.JPG', '.jpeg', '.JPEG',
|
||||
'.lsm', '.LSM',
|
||||
'.tif', '.TIF', '.tiff', '.TIFF',
|
||||
'.mnc', '.MNC',
|
||||
'.mrc', '.rec',
|
||||
'.mha', '.mhd',
|
||||
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
|
||||
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
|
||||
'.nrrd', '.nhdr',
|
||||
'.png', '.PNG',
|
||||
'.vtk']
|
||||
|
||||
SEQUENCE_EXTS = ['.dcm', '.DICOM',
|
||||
'.bmp', '.BMP',
|
||||
'.jpg', '.JPG', '.jpeg', '.JPEG',
|
||||
'.tif', '.TIF', '.tiff', '.TIFF',
|
||||
'.png', '.PNG']
|
||||
|
||||
DICOM_EXTS = ['.dcm', '.DICOM']
|
||||
|
||||
FH_EXTS = ['.dcm', '.DICOM',
|
||||
'.gipl', '.gipl.gz',
|
||||
'.mnc', '.MNC',
|
||||
'.mrc', '.rec',
|
||||
'.mha', '.mhd',
|
||||
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
|
||||
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
|
||||
'.nrrd', '.nhdr',
|
||||
'.vtk',
|
||||
'.gz']
|
||||
|
||||
|
||||
def get_data_files(filepath: str):
|
||||
def get_bioxels_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple):
|
||||
shape = (int(orig_shape[0] / bioxel_size * orig_spacing[0]),
|
||||
int(orig_shape[1] / bioxel_size * orig_spacing[1]),
|
||||
int(orig_shape[2] / bioxel_size * orig_spacing[2]))
|
||||
|
||||
return (shape[0] if shape[0] > 0 else 1,
|
||||
shape[1] if shape[1] > 0 else 1,
|
||||
shape[2] if shape[2] > 0 else 1)
|
||||
|
||||
|
||||
def get_ext(filepath: str) -> str:
|
||||
file_path = Path(filepath)
|
||||
if file_path.name.endswith(".nii.gz"):
|
||||
return ".nii.gz"
|
||||
elif file_path.name.endswith(".img.gz"):
|
||||
return ".img.gz"
|
||||
elif file_path.name.endswith(".gipl.gz"):
|
||||
return ".gipl.gz"
|
||||
else:
|
||||
return file_path.suffix
|
||||
|
||||
|
||||
def get_sequence_name(filepath: str) -> str:
|
||||
ext = get_ext(filepath)
|
||||
filename = Path(filepath).name.removesuffix(ext)
|
||||
index: str = get_text_index_str(filename)
|
||||
return filename.removesuffix(index)
|
||||
|
||||
|
||||
def get_sequence_index(filepath: str) -> int:
|
||||
ext = get_ext(filepath)
|
||||
filename = Path(filepath).name.removesuffix(ext)
|
||||
index: str = get_text_index_str(filename)
|
||||
return int(index) if index else 0
|
||||
|
||||
|
||||
def collect_image_sequence(filepath: str):
|
||||
file_path = Path(filepath).resolve()
|
||||
suffix = file_path.suffix
|
||||
|
||||
files = list(file_path.parent.iterdir())
|
||||
files = [f for f in files if f.is_file() and f.suffix == suffix]
|
||||
files = [f for f in files if f.is_file()
|
||||
and get_ext(file_path) == get_ext(f)
|
||||
and get_sequence_name(file_path) == get_sequence_name(f)]
|
||||
|
||||
def last_number(file: Path):
|
||||
return extract_last_number(file.stem)
|
||||
|
||||
files.sort(key=last_number)
|
||||
files = [f.as_posix() for f in files]
|
||||
return files
|
||||
files.sort(key=get_sequence_index)
|
||||
sequence = [str(f) for f in files]
|
||||
return sequence
|
||||
|
||||
|
||||
def on_orig_spacing_changed(self, context):
|
||||
if self.auto:
|
||||
self.auto = False
|
||||
new_orig_spacing = tuple(self.orig_spacing)
|
||||
def read_image(filepath: str, series_id=""):
|
||||
ext = get_ext(filepath)
|
||||
|
||||
orig_shape = tuple(self.orig_shape)
|
||||
bioxel_size = float(self.bioxel_size)
|
||||
if ext in DICOM_EXTS:
|
||||
dir_path = Path(filepath).resolve().parent
|
||||
reader = sitk.ImageSeriesReader()
|
||||
reader.MetaDataDictionaryArrayUpdateOn()
|
||||
reader.LoadPrivateTagsOn()
|
||||
series_files = reader.GetGDCMSeriesFileNames(
|
||||
str(dir_path), series_id)
|
||||
reader.SetFileNames(series_files)
|
||||
image = reader.Execute()
|
||||
name = dir_path.name
|
||||
|
||||
self.bioxels_shape = (
|
||||
int(orig_shape[0] / bioxel_size * new_orig_spacing[0]),
|
||||
int(orig_shape[1] / bioxel_size * new_orig_spacing[1]),
|
||||
int(orig_shape[2] / bioxel_size * new_orig_spacing[2]),
|
||||
)
|
||||
elif ext in SEQUENCE_EXTS:
|
||||
image = sitk.ReadImage(filepath)
|
||||
if image.GetDimension() == 2:
|
||||
sequence = collect_image_sequence(filepath)
|
||||
image = sitk.ReadImage(sequence)
|
||||
name = get_sequence_name(filepath)
|
||||
else:
|
||||
image = sitk.ReadImage(filepath)
|
||||
name = Path(filepath).name.removesuffix(ext)
|
||||
else:
|
||||
self.auto = True
|
||||
image = sitk.ReadImage(filepath)
|
||||
name = Path(filepath).name.removesuffix(ext)
|
||||
|
||||
return image, name
|
||||
|
||||
|
||||
def on_bioxel_size_changed(self, context):
|
||||
if self.auto:
|
||||
self.auto = False
|
||||
new_bioxel_size = float(self.bioxel_size)
|
||||
def rgb2gray(image):
|
||||
# Convert sRGB image to gray scale and rescale results to [0,255]
|
||||
channels = [sitk.VectorIndexSelectionCast(
|
||||
image, i, sitk.sitkFloat32) for i in range(image.GetNumberOfComponentsPerPixel())]
|
||||
# linear mapping
|
||||
I = (0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2])
|
||||
|
||||
orig_shape = tuple(self.orig_shape)
|
||||
orig_spacing = tuple(self.orig_spacing)
|
||||
return sitk.Cast(I, sitk.sitkFloat32)
|
||||
|
||||
self.bioxels_shape = (
|
||||
int(orig_shape[0] / new_bioxel_size * orig_spacing[0]),
|
||||
int(orig_shape[1] / new_bioxel_size * orig_spacing[1]),
|
||||
int(orig_shape[2] / new_bioxel_size * orig_spacing[2]),
|
||||
)
|
||||
else:
|
||||
self.auto = True
|
||||
def x2gray(image):
|
||||
return sitk.VectorIndexSelectionCast(image, 0, sitk.sitkUInt16)
|
||||
|
||||
|
||||
def on_info_changed(self, context):
|
||||
for region in context.area.regions:
|
||||
if region.type == "UI":
|
||||
region.tag_redraw()
|
||||
|
||||
|
||||
class ImportDICOMDialog(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.import_dicom_dialog"
|
||||
bl_label = "Volume Data as Biovels"
|
||||
bl_description = "Import Volume Data as Biovels (VDB)."
|
||||
class ImportVolumeDataDialog(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.import_volume_data_dialog"
|
||||
bl_label = "Volume Data as Bioxels"
|
||||
bl_description = "Import Volume Data as Bioxels (VDB)"
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
filepath: bpy.props.StringProperty(
|
||||
@@ -78,74 +153,67 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
options={'HIDDEN'}
|
||||
) # type: ignore
|
||||
|
||||
bioxels_shape: bpy.props.IntVectorProperty(
|
||||
name="Bioxels Shape",
|
||||
min=0,
|
||||
default=(100, 100, 100)
|
||||
series_id: bpy.props.StringProperty() # type: ignore
|
||||
|
||||
resample_method: bpy.props.EnumProperty(
|
||||
name="Resample Method",
|
||||
default="linear",
|
||||
items=[("linear", "Linear", ""),
|
||||
("nearest_neighbor", "Nearest Neighbor", ""),
|
||||
("gaussian", "Gaussian", "")]
|
||||
) # type: ignore
|
||||
|
||||
read_as: bpy.props.EnumProperty(
|
||||
name="Read as",
|
||||
default="scalar",
|
||||
items=[("scalar", "Scalar", ""),
|
||||
("labels", "Labels", "")]
|
||||
) # type: ignore
|
||||
|
||||
label_index: bpy.props.IntProperty(
|
||||
name="Label Index",
|
||||
default=1
|
||||
) # type: ignore
|
||||
|
||||
bioxel_size: bpy.props.FloatProperty(
|
||||
name="Bioxel Size",
|
||||
name="Bioxel Size (Larger size means small resolution)",
|
||||
soft_min=0.1, soft_max=10.0,
|
||||
min=1e-2, max=1e2,
|
||||
default=1,
|
||||
update=on_bioxel_size_changed
|
||||
) # type: ignore
|
||||
|
||||
orig_spacing: bpy.props.FloatVectorProperty(
|
||||
name="Original Spacing",
|
||||
default=(1, 1, 1),
|
||||
update=on_orig_spacing_changed
|
||||
) # type: ignore
|
||||
|
||||
orig_shape: bpy.props.IntVectorProperty(
|
||||
name="Original Shape",
|
||||
default=(100, 100, 100),
|
||||
options={'HIDDEN'}
|
||||
) # type: ignore
|
||||
|
||||
auto: bpy.props.BoolProperty(
|
||||
name="Auto Setting",
|
||||
default=False,
|
||||
options={'HIDDEN'}
|
||||
default=(100, 100, 100)
|
||||
) # type: ignore
|
||||
|
||||
scene_scale: bpy.props.FloatProperty(
|
||||
name="Scene Scale",
|
||||
name="Scene Scale (Bioxels Unit pre Blender Unit)",
|
||||
soft_min=0.001, soft_max=100.0,
|
||||
min=1e-6, max=1e6,
|
||||
default=0.01,
|
||||
) # type: ignore
|
||||
|
||||
do_add_segmentnode: bpy.props.BoolProperty(
|
||||
name="Add Segment Node",
|
||||
do_orient: bpy.props.BoolProperty(
|
||||
name="Orient to RAS",
|
||||
default=True,
|
||||
) # type: ignore
|
||||
|
||||
do_change_render_setting: bpy.props.BoolProperty(
|
||||
name="Change Render Setting",
|
||||
default=True,
|
||||
invert_scalar: bpy.props.BoolProperty(
|
||||
name="Invert Scalar (Background value maybe higher than object)",
|
||||
default=False,
|
||||
) # type: ignore
|
||||
|
||||
def execute(self, context):
|
||||
if not self.filepath:
|
||||
self.report({"WARNING"}, "Get no file path.")
|
||||
return {'CANCELLED'}
|
||||
image, bioxels_name = read_image(self.filepath, self.series_id)
|
||||
|
||||
if Path(self.filepath).suffix not in SUPPORT_EXTS:
|
||||
self.report({"WARNING"}, "Not Supported extension.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
file_path = Path(self.filepath).resolve()
|
||||
name = file_path.parent.name
|
||||
files = get_data_files(self.filepath)
|
||||
|
||||
import SimpleITK as sitk
|
||||
image = sitk.ReadImage(files)
|
||||
|
||||
bioxel_size = float(self.bioxel_size)
|
||||
|
||||
orig_spacing = tuple(self.orig_spacing)
|
||||
bioxel_size = self.bioxel_size
|
||||
orig_spacing = self.orig_spacing
|
||||
image_spacing = image.GetSpacing()
|
||||
image_shape = image.GetSize()
|
||||
|
||||
@@ -155,37 +223,55 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
image_spacing[2] / orig_spacing[2] * bioxel_size
|
||||
)
|
||||
|
||||
bioxels_shape = (
|
||||
int(image_shape[0] / bioxel_size * orig_spacing[0]),
|
||||
int(image_shape[1] / bioxel_size * orig_spacing[1]),
|
||||
int(image_shape[2] / bioxel_size * orig_spacing[2]),
|
||||
)
|
||||
bioxels_shape = get_bioxels_shape(
|
||||
bioxel_size, image_shape, orig_spacing)
|
||||
|
||||
print("Bioxel Size:", bioxel_size)
|
||||
print("Bioxels Shape:", bioxels_shape)
|
||||
if self.read_as == "labels":
|
||||
if "vector" in image.GetPixelIDTypeAsString():
|
||||
print("Convet to Grayscale...")
|
||||
image = x2gray(image)
|
||||
else:
|
||||
image = sitk.Cast(image, sitk.sitkUInt16)
|
||||
default_value = 0
|
||||
elif self.read_as == "scalar":
|
||||
if "vector" in image.GetPixelIDTypeAsString():
|
||||
print("Convet to Grayscale...")
|
||||
image = rgb2gray(image)
|
||||
else:
|
||||
image = sitk.Cast(image, sitk.sitkFloat32)
|
||||
|
||||
print("Resampling...")
|
||||
stats = sitk.StatisticsImageFilter()
|
||||
stats.Execute(image)
|
||||
default_value = stats.GetMaximum() if self.invert_scalar else stats.GetMinimum()
|
||||
|
||||
if self.resample_method == "linear":
|
||||
interpolator = sitk.sitkLinear
|
||||
elif self.resample_method == "nearest_neighbor":
|
||||
interpolator = sitk.sitkNearestNeighbor
|
||||
elif self.resample_method == "gaussian":
|
||||
interpolator = sitk.sitkGaussian
|
||||
|
||||
print(f"Resampling...")
|
||||
image = sitk.Resample(
|
||||
image1=image,
|
||||
size=bioxels_shape,
|
||||
transform=sitk.Transform(),
|
||||
interpolator=sitk.sitkLinear,
|
||||
interpolator=interpolator,
|
||||
outputOrigin=image.GetOrigin(),
|
||||
outputSpacing=bioxels_spacing,
|
||||
outputDirection=image.GetDirection(),
|
||||
defaultPixelValue=0,
|
||||
defaultPixelValue=default_value,
|
||||
outputPixelType=image.GetPixelID(),
|
||||
)
|
||||
|
||||
try:
|
||||
if self.do_orient:
|
||||
print("Orienting to RAS...")
|
||||
image = sitk.DICOMOrient(image, 'RAS')
|
||||
except:
|
||||
...
|
||||
|
||||
array = sitk.GetArrayFromImage(image)
|
||||
orig_dtype = str(array.dtype)
|
||||
print(f"Coverting Dtype {orig_dtype} -> float")
|
||||
array = array.astype(float)
|
||||
print("Oriented Origin:", image.GetOrigin())
|
||||
print("Oriented Direction:", image.GetDirection())
|
||||
|
||||
# return {'FINISHED'}
|
||||
|
||||
# ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i]
|
||||
# https://www.slicer.org/wiki/Coordinate_systems
|
||||
@@ -194,23 +280,63 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
# R (ight) i -> k -> x
|
||||
# A (nterior) j -> j -> y
|
||||
# S (uperior) k -> i -> z
|
||||
array = sitk.GetArrayFromImage(image)
|
||||
|
||||
orig_dtype = str(array.dtype)
|
||||
# print(f"Coverting Dtype from {orig_dtype} to float...")
|
||||
# array = array.astype(float)
|
||||
|
||||
if array.ndim == 4:
|
||||
# if array.shape[0] == 3 and self.read_as == "scalar":
|
||||
# # RGB -> Grayscale
|
||||
# array = np.dot(array[..., :3], [0.2989, 0.5870, 0.1140])
|
||||
# else:
|
||||
# array = array[:, :, :, 0]
|
||||
array = array[:, :, :, 0]
|
||||
|
||||
array = np.transpose(array)
|
||||
bioxels_max = float(np.max(array))
|
||||
bioxels_min = float(np.min(array))
|
||||
|
||||
bioxels_shape = array.shape
|
||||
bioxels_offset = 0.0
|
||||
if bioxels_min < 0 and orig_dtype[0] != "u":
|
||||
bioxels_offset = -bioxels_min
|
||||
array = array + np.full_like(array, bioxels_offset)
|
||||
|
||||
if self.read_as == "labels":
|
||||
array = array == np.full_like(array, self.label_index)
|
||||
bioxels_default_threshold = 1.0
|
||||
bioxels_max = 1.0
|
||||
bioxels_min = 0.0
|
||||
bioxels_name = f"{bioxels_name}_{self.label_index}"
|
||||
else:
|
||||
if self.invert_scalar:
|
||||
array = -array
|
||||
|
||||
orig_max = float(np.max(array))
|
||||
orig_min = float(np.min(array))
|
||||
orig_median = float(np.median(array))
|
||||
orig_percentile80 = float(np.percentile(array, 80)) \
|
||||
if self.invert_scalar else float(np.percentile(array, 80))
|
||||
|
||||
# if (orig_dtype[0] != "u" and orig_min < 0) \
|
||||
# or (orig_dtype[0] == "u" and self.invert_scalar):
|
||||
if orig_min < 0:
|
||||
bioxels_offset = -orig_min
|
||||
array = array + np.full_like(array, bioxels_offset)
|
||||
|
||||
bioxels_default_threshold = orig_percentile80
|
||||
bioxels_max = float(np.max(array))
|
||||
bioxels_min = float(np.min(array))
|
||||
print("Offseted Max:", bioxels_max)
|
||||
print("Offseted Min:", bioxels_min)
|
||||
|
||||
# Build VDB
|
||||
grid = vdb.FloatGrid()
|
||||
grid.copyFromArray(array.copy())
|
||||
stats_table = [("Max", orig_max),
|
||||
("Min", orig_min),
|
||||
("Median", orig_median),
|
||||
("80%", orig_percentile80)]
|
||||
|
||||
print("Bioxels Value Stats:")
|
||||
for stats in stats_table:
|
||||
print("| {: >10} | {: >40} |".format(*stats))
|
||||
|
||||
# # Build VDB
|
||||
grid = vdb.BoolGrid() if self.read_as == "labels" else vdb.FloatGrid()
|
||||
grid.copyFromArray(array.copy().astype(float))
|
||||
|
||||
# After sitk.DICOMOrient(), origin and direction will also orient base on LPS
|
||||
# so we need to convert them into RAS
|
||||
@@ -221,41 +347,45 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
to_up='Y'
|
||||
).to_4x4()
|
||||
|
||||
origin = image.GetOrigin()
|
||||
direction = image.GetDirection()
|
||||
|
||||
mat_location = mathutils.Matrix.Translation(
|
||||
mathutils.Vector(image.GetOrigin())
|
||||
mathutils.Vector(origin)
|
||||
)
|
||||
|
||||
mat_rotation = mathutils.Matrix(
|
||||
np.array(image.GetDirection()).reshape((3, 3))
|
||||
np.array(direction).reshape((3, 3))
|
||||
).to_4x4()
|
||||
|
||||
mat_scale = mathutils.Matrix.Scale(
|
||||
bioxel_size, 4
|
||||
)
|
||||
|
||||
transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale
|
||||
transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale \
|
||||
if self.do_orient else mat_location @ mat_rotation @ mat_scale
|
||||
|
||||
grid.transform = vdb.createLinearTransform(transfrom.transposed())
|
||||
grid.name = "value"
|
||||
grid.name = "scalar"
|
||||
|
||||
preferences = context.preferences.addons[__package__].preferences
|
||||
vdb_dirpath = Path(preferences.cache_dir, 'VDBs')
|
||||
vdb_dirpath.mkdir(parents=True, exist_ok=True)
|
||||
vdb_path = Path(vdb_dirpath, f"{uuid4()}.vdb")
|
||||
|
||||
print(f"Storing the VDB file ({vdb_path.as_posix()})...")
|
||||
vdb.write(vdb_path.as_posix(), grids=[grid])
|
||||
print(f"Storing the VDB file ({str(vdb_path)})...")
|
||||
vdb.write(str(vdb_path), grids=[grid])
|
||||
|
||||
# Read VDB
|
||||
print(f"Importing the VDB file to Blender scene...")
|
||||
bpy.ops.object.volume_import(
|
||||
filepath=vdb_path.as_posix(), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
|
||||
filepath=str(vdb_path), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
|
||||
|
||||
bioxels_obj = bpy.context.active_object
|
||||
|
||||
# Set props to VDB object
|
||||
bioxels_obj.name = f"{name}_Bioxels"
|
||||
bioxels_obj.data.name = f"{name}_Bioxels"
|
||||
bioxels_obj.name = f"{bioxels_name}_Bioxels"
|
||||
bioxels_obj.data.name = f"{bioxels_name}_Bioxels"
|
||||
|
||||
# Make transformation
|
||||
scene_scale = float(self.scene_scale)
|
||||
@@ -274,7 +404,6 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
)
|
||||
|
||||
bioxels_obj.matrix_world = mat_ras2blender @ mat_scene_scale
|
||||
|
||||
bioxels_obj.lock_location[0] = True
|
||||
bioxels_obj.lock_location[1] = True
|
||||
bioxels_obj.lock_location[2] = True
|
||||
@@ -297,14 +426,12 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
container_obj = bpy.context.active_object
|
||||
bbox_verts = calc_bbox_verts((0, 0, 0), bioxels_shape)
|
||||
for index, vert in enumerate(container_obj.data.vertices):
|
||||
co = transfrom @ mathutils.Vector(bbox_verts[index])
|
||||
co = mat_ras2blender @ co
|
||||
co = mat_scene_scale @ co
|
||||
vert.co = co
|
||||
bbox_transform = mat_ras2blender @ mat_scene_scale @ transfrom
|
||||
vert.co = bbox_transform @ mathutils.Vector(bbox_verts[index])
|
||||
|
||||
bioxels_obj.parent = container_obj
|
||||
container_obj.name = name
|
||||
container_obj.data.name = name
|
||||
container_obj.name = bioxels_name
|
||||
container_obj.data.name = bioxels_name
|
||||
container_obj.visible_camera = False
|
||||
container_obj.visible_diffuse = False
|
||||
container_obj.visible_glossy = False
|
||||
@@ -329,19 +456,23 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
# Create BioxelNodes to VDB object
|
||||
print(f"Creating BioxelNodes to the VDB...")
|
||||
|
||||
if self.do_add_segmentnode:
|
||||
if preferences.do_add_segmentnode:
|
||||
nodes = node_tree.nodes
|
||||
links = node_tree.links
|
||||
|
||||
input_node = nodes.get("Group Input")
|
||||
output_node = nodes.get("Group Output")
|
||||
segment_node = custom_nodes.add_node(nodes, 'BioxelNodes_Segment')
|
||||
input_node = get_node_by_type(nodes, 'NodeGroupInput')[0]
|
||||
output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0]
|
||||
|
||||
segment_node = custom_nodes.add_node(
|
||||
nodes, 'BioxelNodes_Segment')
|
||||
|
||||
segment_node.inputs['Threshold'].default_value = bioxels_default_threshold
|
||||
|
||||
links.new(input_node.outputs[0], segment_node.inputs[0])
|
||||
links.new(segment_node.outputs[0], output_node.inputs[0])
|
||||
|
||||
# Change render setting for better result
|
||||
if self.do_change_render_setting:
|
||||
if preferences.do_change_render_setting:
|
||||
bpy.context.scene.render.engine = 'CYCLES'
|
||||
bpy.context.scene.cycles.volume_bounces = 12
|
||||
bpy.context.scene.cycles.transparent_max_bounces = 64
|
||||
@@ -351,44 +482,98 @@ class ImportDICOMDialog(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
# print(tuple(self.orig_shape))
|
||||
self.auto = True
|
||||
self.bioxel_size = 1
|
||||
context.window_manager.invoke_props_dialog(self)
|
||||
min_size = min(
|
||||
self.orig_spacing[0], self.orig_spacing[1], self.orig_spacing[2])
|
||||
default_size = 1.0
|
||||
self.bioxel_size = min_size if min_size > default_size else default_size
|
||||
context.window_manager.invoke_props_dialog(self, width=400)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
bioxels_shape = get_bioxels_shape(
|
||||
self.bioxel_size, self.orig_shape, self.orig_spacing)
|
||||
bioxel_count = bioxels_shape[0] * bioxels_shape[1] * bioxels_shape[2]
|
||||
text = f"Shape will be: {str(bioxels_shape)} {bioxel_count:,} "
|
||||
if bioxel_count > 100000000:
|
||||
text += "**TOO LARGE!**"
|
||||
|
||||
panel = layout.box()
|
||||
panel.prop(self, "resample_method")
|
||||
panel.prop(self, "bioxel_size")
|
||||
row = panel.row()
|
||||
row.prop(self, "orig_spacing")
|
||||
panel.label(text=text)
|
||||
|
||||
panel = layout.box()
|
||||
panel.prop(self, "read_as")
|
||||
if self.read_as == "labels":
|
||||
panel.prop(self, "label_index")
|
||||
else:
|
||||
panel.prop(self, "invert_scalar")
|
||||
|
||||
panel = layout.box()
|
||||
panel.prop(self, "scene_scale")
|
||||
panel.prop(self, "do_orient")
|
||||
|
||||
|
||||
def get_series_ids(self, context):
|
||||
items = []
|
||||
for index, series_id in enumerate(self.series_ids):
|
||||
items.append((
|
||||
series_id.id,
|
||||
series_id.label,
|
||||
"",
|
||||
index
|
||||
))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class ReadDICOM(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.read_dicom"
|
||||
bl_label = "Volume Data as Biovels"
|
||||
bl_description = "Import Volume Data as Biovels (VDB)."
|
||||
bl_label = "Volume Data as Bioxels"
|
||||
bl_description = "Import Volume Data as Bioxels (VDB)"
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
filepath: bpy.props.StringProperty(
|
||||
subtype="FILE_PATH"
|
||||
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
|
||||
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
|
||||
|
||||
series_id: bpy.props.EnumProperty(
|
||||
name="Select Series",
|
||||
items=get_series_ids
|
||||
) # type: ignore
|
||||
|
||||
ok: bpy.props.BoolProperty(
|
||||
default=True
|
||||
) # type: ignore
|
||||
series_ids: bpy.props.CollectionProperty(
|
||||
type=BIOXELNODES_Series) # type: ignore
|
||||
|
||||
def execute(self, context):
|
||||
files = get_data_files(self.filepath)
|
||||
|
||||
import SimpleITK as sitk
|
||||
image = sitk.ReadImage(files)
|
||||
ext = get_ext(self.filepath)
|
||||
if ext not in SUPPORT_EXTS:
|
||||
self.report({"WARNING"}, "Not supported extension.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
print("Collecting Meta Data...")
|
||||
print("Original Shape:", image.GetSize())
|
||||
print("Original Spacing:", image.GetSpacing())
|
||||
print("Original Origin:", image.GetOrigin())
|
||||
print("Original Direction:", image.GetDirection())
|
||||
image, name = read_image(self.filepath, self.series_id)
|
||||
stats_table = [("Shape", str(image.GetSize())),
|
||||
("Spacing", str(image.GetSpacing())),
|
||||
("Origin", str(image.GetOrigin())),
|
||||
("Direction", str(image.GetDirection())),
|
||||
("Data Type", image.GetPixelIDTypeAsString())]
|
||||
|
||||
bpy.ops.bioxelnodes.import_dicom_dialog(
|
||||
print("Meta Data:")
|
||||
for stats in stats_table:
|
||||
print("| {: >10} | {: >40} |".format(*stats))
|
||||
|
||||
do_orient = ext not in SEQUENCE_EXTS or ext in DICOM_EXTS
|
||||
|
||||
bpy.ops.bioxelnodes.import_volume_data_dialog(
|
||||
'INVOKE_DEFAULT',
|
||||
filepath=self.filepath,
|
||||
orig_shape=image.GetSize(),
|
||||
orig_spacing=image.GetSpacing()
|
||||
orig_spacing=image.GetSpacing(),
|
||||
series_id=self.series_id or "",
|
||||
do_orient=do_orient
|
||||
)
|
||||
|
||||
self.report({"INFO"}, "Successfully Readed.")
|
||||
@@ -400,32 +585,56 @@ class ReadDICOM(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
if not self.filepath:
|
||||
if not self.filepath and not self.directory:
|
||||
return {'CANCELLED'}
|
||||
|
||||
if Path(self.filepath).suffix not in SUPPORT_EXTS:
|
||||
return {'CANCELLED'}
|
||||
|
||||
show_message('Reading image data, it may take a while',
|
||||
show_message('Reading image data, it may take a while...',
|
||||
'Please be patient...')
|
||||
|
||||
if get_ext(self.filepath) == '.dcm':
|
||||
dir_path = Path(self.filepath).parent
|
||||
reader = sitk.ImageSeriesReader()
|
||||
reader.MetaDataDictionaryArrayUpdateOn()
|
||||
reader.LoadPrivateTagsOn()
|
||||
series_ids = reader.GetGDCMSeriesIDs(str(dir_path))
|
||||
|
||||
for _id in series_ids:
|
||||
series_id = self.series_ids.add()
|
||||
series_id.id = _id
|
||||
series_id.label = _id
|
||||
|
||||
if len(series_ids) > 1:
|
||||
context.window_manager.invoke_props_dialog(self, width=400)
|
||||
return {'RUNNING_MODAL'}
|
||||
else:
|
||||
self.series_id = series_ids[0]
|
||||
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "series_id")
|
||||
layout.label(
|
||||
text="Reading image data, it may take a while...")
|
||||
layout.label(
|
||||
text="Please be patient...")
|
||||
|
||||
class ImportDICOM(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.import_dicom"
|
||||
bl_label = "Volume Data as Biovels"
|
||||
bl_description = "Import Volume Data as Biovels (VDB)."
|
||||
|
||||
class ImportVolumeData(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.import_volume_data"
|
||||
bl_label = "Volume Data as Bioxels"
|
||||
bl_description = "Import Volume Data as Bioxels (VDB)"
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
filepath: bpy.props.StringProperty(
|
||||
subtype="FILE_PATH"
|
||||
) # type: ignore
|
||||
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
|
||||
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
|
||||
|
||||
def execute(self, context):
|
||||
bpy.ops.bioxelnodes.read_dicom(
|
||||
'INVOKE_DEFAULT',
|
||||
filepath=self.filepath
|
||||
filepath=self.filepath,
|
||||
directory=self.directory
|
||||
)
|
||||
return {'FINISHED'}
|
||||
|
||||
@@ -434,21 +643,24 @@ class ImportDICOM(bpy.types.Operator):
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
|
||||
class BIOXELNODES_FH_ImportDicom(bpy.types.FileHandler):
|
||||
bl_idname = "BIOXELNODES_FH_ImportDicom"
|
||||
bl_label = "File handler for dicom import"
|
||||
bl_import_operator = "bioxelnodes.read_dicom"
|
||||
bl_file_extensions = ".dcm"
|
||||
try:
|
||||
class BIOXELNODES_FH_ImportVolumeData(bpy.types.FileHandler):
|
||||
bl_idname = "BIOXELNODES_FH_ImportVolumeData"
|
||||
bl_label = "File handler for dicom import"
|
||||
bl_import_operator = "bioxelnodes.read_dicom"
|
||||
bl_file_extensions = ";".join(FH_EXTS)
|
||||
|
||||
@classmethod
|
||||
def poll_drop(cls, context):
|
||||
return (context.area and context.area.type == 'VIEW_3D')
|
||||
@classmethod
|
||||
def poll_drop(cls, context):
|
||||
return (context.area and context.area.type == 'VIEW_3D')
|
||||
except:
|
||||
...
|
||||
|
||||
|
||||
class ExportVDB(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.export_vdb"
|
||||
bl_label = "Bioxels as VDB"
|
||||
bl_description = "Export Bioxels original VDB data."
|
||||
bl_description = "Export Bioxels original VDB data"
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
filepath: bpy.props.StringProperty(
|
||||
@@ -457,18 +669,18 @@ class ExportVDB(bpy.types.Operator):
|
||||
|
||||
filename_ext = ".vdb"
|
||||
|
||||
def invoke(self, context, event):
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
bioxels_obj = None
|
||||
for obj in bpy.context.selected_objects:
|
||||
bioxels_obj = get_bioxels_obj(obj)
|
||||
break
|
||||
|
||||
if bioxels_obj:
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
else:
|
||||
self.report({"WARNING"}, "Cannot find any bioxels.")
|
||||
return {'CANCELLED'}
|
||||
return True if bioxels_obj else False
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def execute(self, context):
|
||||
bioxels_obj = None
|
||||
@@ -477,10 +689,15 @@ class ExportVDB(bpy.types.Operator):
|
||||
break
|
||||
|
||||
filepath = f"{self.filepath.split('.')[0]}.vdb"
|
||||
# "//"
|
||||
source_dir = bpy.path.abspath(bioxels_obj.data.filepath)
|
||||
|
||||
output_path: Path = Path(filepath).resolve()
|
||||
source_path: Path = Path(bioxels_obj.data.filepath).resolve()
|
||||
source_path: Path = Path(source_dir).resolve()
|
||||
# print('output_path', output_path)
|
||||
# print('source_path', source_path)
|
||||
shutil.copy(source_path, output_path)
|
||||
|
||||
self.report({"INFO"}, f"Successfully exported to {output_path}")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
221
bioxelnodes/io_points.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# TODO: WIP
|
||||
import bpy
|
||||
import bmesh
|
||||
|
||||
|
||||
def create_points_obj(points, name="points"):
|
||||
bpy.ops.mesh.primitive_cube_add(
|
||||
enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
|
||||
)
|
||||
obj = bpy.context.active_object
|
||||
mesh = obj.data
|
||||
|
||||
bm = bmesh.new()
|
||||
value_layer = bm.verts.layers.float.new('value')
|
||||
|
||||
for point in points:
|
||||
pos = point['pos']
|
||||
value = point['value']
|
||||
vert = bm.verts.new(pos) # add a new vert
|
||||
vert[value_layer] = value
|
||||
|
||||
# make the bmesh the object's mesh
|
||||
bm.to_mesh(mesh)
|
||||
bm.free() # always do this when finished
|
||||
return obj
|
||||
|
||||
|
||||
# class ImportDICOMPointsDialog(bpy.types.Operator):
|
||||
# bl_idname = "bioxelnodes.import_dicom_points_dialog"
|
||||
# bl_label = "Volume Data as Bioxels"
|
||||
# bl_description = "Import Volume Data as Bioxels (VDB)"
|
||||
# bl_options = {'UNDO'}
|
||||
|
||||
# filepath: bpy.props.StringProperty(
|
||||
# subtype="FILE_PATH",
|
||||
# options={'HIDDEN'}
|
||||
# ) # type: ignore
|
||||
|
||||
# bioxels_shape: bpy.props.IntVectorProperty(
|
||||
# name="Bioxels Shape (ReadOnly)",
|
||||
# min=0,
|
||||
# default=(100, 100, 100)
|
||||
# ) # type: ignore
|
||||
|
||||
# bioxel_size: bpy.props.FloatProperty(
|
||||
# name="Bioxel Size",
|
||||
# soft_min=0.1, soft_max=10.0,
|
||||
# min=1e-2, max=1e2,
|
||||
# default=1,
|
||||
# update=on_bioxel_size_changed
|
||||
# ) # type: ignore
|
||||
|
||||
# orig_spacing: bpy.props.FloatVectorProperty(
|
||||
# name="Original Spacing",
|
||||
# default=(1, 1, 1),
|
||||
# update=on_orig_spacing_changed
|
||||
# ) # type: ignore
|
||||
|
||||
# orig_shape: bpy.props.IntVectorProperty(
|
||||
# name="Original Shape",
|
||||
# default=(100, 100, 100),
|
||||
# options={'HIDDEN'}
|
||||
# ) # type: ignore
|
||||
|
||||
# auto: bpy.props.BoolProperty(
|
||||
# name="Auto Setting",
|
||||
# default=False,
|
||||
# options={'HIDDEN'}
|
||||
# ) # type: ignore
|
||||
|
||||
# scene_scale: bpy.props.FloatProperty(
|
||||
# name="Scene Scale",
|
||||
# soft_min=0.001, soft_max=100.0,
|
||||
# min=1e-6, max=1e6,
|
||||
# default=0.01,
|
||||
# ) # type: ignore
|
||||
|
||||
# do_add_segmentnode: bpy.props.BoolProperty(
|
||||
# name="Add Segment Node",
|
||||
# default=True,
|
||||
# ) # type: ignore
|
||||
|
||||
# do_change_render_setting: bpy.props.BoolProperty(
|
||||
# name="Change Render Setting",
|
||||
# default=True,
|
||||
# ) # type: ignore
|
||||
|
||||
# def execute(self, context):
|
||||
# files = get_data_files(self.filepath)
|
||||
# name = Path(self.filepath).parent.name
|
||||
|
||||
# image = sitk.ReadImage(files)
|
||||
|
||||
# bioxel_size = float(self.bioxel_size)
|
||||
# orig_spacing = tuple(self.orig_spacing)
|
||||
# image_spacing = image.GetSpacing()
|
||||
# image_shape = image.GetSize()
|
||||
|
||||
# bioxels_spacing = (
|
||||
# image_spacing[0] / orig_spacing[0] * bioxel_size,
|
||||
# image_spacing[1] / orig_spacing[1] * bioxel_size,
|
||||
# image_spacing[2] / orig_spacing[2] * bioxel_size
|
||||
# )
|
||||
|
||||
# bioxels_shape = (
|
||||
# int(image_shape[0] / bioxel_size * orig_spacing[0]),
|
||||
# int(image_shape[1] / bioxel_size * orig_spacing[1]),
|
||||
# int(image_shape[2] / bioxel_size * orig_spacing[2]),
|
||||
# )
|
||||
|
||||
# print("Resampling...")
|
||||
# image = sitk.Resample(
|
||||
# image1=image,
|
||||
# size=bioxels_shape,
|
||||
# transform=sitk.Transform(),
|
||||
# interpolator=sitk.sitkLinear,
|
||||
# outputOrigin=image.GetOrigin(),
|
||||
# outputSpacing=bioxels_spacing,
|
||||
# outputDirection=image.GetDirection(),
|
||||
# defaultPixelValue=0,
|
||||
# outputPixelType=image.GetPixelID(),
|
||||
# )
|
||||
|
||||
# print("Orienting to RAS...")
|
||||
# image = sitk.DICOMOrient(image, 'RAS')
|
||||
|
||||
# array = sitk.GetArrayFromImage(image)
|
||||
# orig_dtype = str(array.dtype)
|
||||
# print(f"Coverting Dtype from {orig_dtype} to float...")
|
||||
# array = array.astype(float)
|
||||
|
||||
# # ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i]
|
||||
# # https://www.slicer.org/wiki/Coordinate_systems
|
||||
|
||||
# # ITK Numpy 3D
|
||||
# # R (ight) i -> k -> x
|
||||
# # A (nterior) j -> j -> y
|
||||
# # S (uperior) k -> i -> z
|
||||
|
||||
# array = np.transpose(array)
|
||||
# bioxels_max = float(np.max(array))
|
||||
# bioxels_min = float(np.min(array))
|
||||
# bioxels_shape = array.shape
|
||||
|
||||
# print("Bioxel Size:", bioxel_size)
|
||||
# print("Bioxels Shape:", bioxels_shape)
|
||||
|
||||
# bioxels_offset = 0.0
|
||||
# if bioxels_min < 0 and orig_dtype[0] != "u":
|
||||
# bioxels_offset = -bioxels_min
|
||||
# array = array + np.full_like(array, bioxels_offset)
|
||||
# bioxels_max = float(np.max(array))
|
||||
# bioxels_min = float(np.min(array))
|
||||
# print("Offseted Max:", bioxels_max)
|
||||
# print("Offseted Min:", bioxels_min)
|
||||
|
||||
# points = []
|
||||
# for x in range(array.shape[0]):
|
||||
# for y in range(array.shape[1]):
|
||||
# for z in range(array.shape[2]):
|
||||
# points.append({
|
||||
# "pos": (x, y, z),
|
||||
# "value": array[x, y, z],
|
||||
# })
|
||||
|
||||
# # # Build VDB
|
||||
# # grid = vdb.FloatGrid()
|
||||
# # grid.copyFromArray(array.copy())
|
||||
|
||||
# # # After sitk.DICOMOrient(), origin and direction will also orient base on LPS
|
||||
# # # so we need to convert them into RAS
|
||||
# # mat_lps2ras = axis_conversion(
|
||||
# # from_forward='-Z',
|
||||
# # from_up='-Y',
|
||||
# # to_forward='-Z',
|
||||
# # to_up='Y'
|
||||
# # ).to_4x4()
|
||||
|
||||
# # mat_location = mathutils.Matrix.Translation(
|
||||
# # mathutils.Vector(image.GetOrigin())
|
||||
# # )
|
||||
|
||||
# # mat_rotation = mathutils.Matrix(
|
||||
# # np.array(image.GetDirection()).reshape((3, 3))
|
||||
# # ).to_4x4()
|
||||
|
||||
# # mat_scale = mathutils.Matrix.Scale(
|
||||
# # bioxel_size, 4
|
||||
# # )
|
||||
|
||||
# # transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale
|
||||
|
||||
# obj = create_points_obj(points)
|
||||
|
||||
# # Make transformation
|
||||
# scene_scale = float(self.scene_scale)
|
||||
# # (S)uperior -Z -> Y
|
||||
# # (A)osterior Y -> Z
|
||||
# mat_ras2blender = axis_conversion(
|
||||
# from_forward='-Z',
|
||||
# from_up='Y',
|
||||
# to_forward='Y',
|
||||
# to_up='Z'
|
||||
# ).to_4x4()
|
||||
|
||||
# mat_scene_scale = mathutils.Matrix.Scale(
|
||||
# scene_scale, 4
|
||||
# )
|
||||
|
||||
# obj.matrix_world = mat_ras2blender @ mat_scene_scale
|
||||
|
||||
# self.report({"INFO"}, "Successfully Imported")
|
||||
|
||||
# return {'FINISHED'}
|
||||
|
||||
# def invoke(self, context, event):
|
||||
# # print(tuple(self.orig_shape))
|
||||
# self.auto = True
|
||||
# self.bioxel_size = 1
|
||||
# context.window_manager.invoke_props_dialog(self)
|
||||
# return {'RUNNING_MODAL'}
|
||||
@@ -1,12 +1,24 @@
|
||||
import bpy
|
||||
from .convert import ConvertToMesh
|
||||
from .io import ExportVDB, ImportDICOM
|
||||
from .io import ExportVDB, ImportVolumeData
|
||||
from .misc import SaveBioxels
|
||||
|
||||
|
||||
class ObjectMenu(bpy.types.Menu):
|
||||
bl_idname = "BIOXELNODES_MT_OBJECT"
|
||||
bl_label = "Bioxels"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.operator(ConvertToMesh.bl_idname)
|
||||
layout.separator()
|
||||
layout.operator(SaveBioxels.bl_idname)
|
||||
|
||||
|
||||
def TOPBAR_FILE_IMPORT(self, context):
|
||||
layout = self.layout
|
||||
layout.separator()
|
||||
layout.operator(ImportDICOM.bl_idname)
|
||||
layout.operator(ImportVolumeData.bl_idname)
|
||||
|
||||
|
||||
def TOPBAR_FILE_EXPORT(self, context):
|
||||
@@ -17,17 +29,17 @@ def TOPBAR_FILE_EXPORT(self, context):
|
||||
|
||||
def VIEW3D_OBJECT(self, context):
|
||||
layout = self.layout
|
||||
layout.operator(ConvertToMesh.bl_idname)
|
||||
layout.menu(ObjectMenu.bl_idname)
|
||||
layout.separator()
|
||||
|
||||
|
||||
def create_menu():
|
||||
def add():
|
||||
bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT)
|
||||
bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.prepend(VIEW3D_OBJECT)
|
||||
|
||||
|
||||
def remove_menu():
|
||||
def remove():
|
||||
bpy.types.TOPBAR_MT_file_import.remove(TOPBAR_FILE_IMPORT)
|
||||
bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(VIEW3D_OBJECT)
|
||||
103
bioxelnodes/misc.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from bioxelnodes.utils import get_all_bioxels_objs, get_bioxels_obj
|
||||
|
||||
|
||||
class SaveBioxels(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.save_bioxels"
|
||||
bl_label = "Save Bioxels"
|
||||
bl_description = "Save Bioxels to Directory."
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
bioxels_obj = None
|
||||
for obj in bpy.context.selected_objects:
|
||||
bioxels_obj = get_bioxels_obj(obj)
|
||||
break
|
||||
|
||||
return True if bioxels_obj else False
|
||||
|
||||
def execute(self, context):
|
||||
bioxels_objs = []
|
||||
|
||||
for obj in bpy.context.selected_objects:
|
||||
bioxels_obj = get_bioxels_obj(obj)
|
||||
if bioxels_obj:
|
||||
bioxels_objs.append(bioxels_obj)
|
||||
|
||||
if len(bioxels_objs) == 0:
|
||||
self.report({"WARNING"}, "Cannot find any bioxels.")
|
||||
return {'FINISHED'}
|
||||
|
||||
for bioxels_obj in bioxels_objs:
|
||||
name = bioxels_obj.parent.name
|
||||
|
||||
# "//"
|
||||
bioxels_dir = bpy.path.abspath(context.scene.bioxels_dir)
|
||||
source_dir = bpy.path.abspath(bioxels_obj.data.filepath)
|
||||
|
||||
output_path: Path = Path(bioxels_dir, f"{name}.vdb").resolve()
|
||||
source_path: Path = Path(source_dir).resolve()
|
||||
|
||||
if output_path != source_path:
|
||||
shutil.copy(source_path, output_path)
|
||||
|
||||
blend_path = Path(bpy.path.abspath("//")).resolve()
|
||||
bioxels_obj.data.filepath = bpy.path.relpath(
|
||||
str(output_path), start=str(blend_path))
|
||||
|
||||
self.report({"INFO"}, f"Successfully saved bioxels")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class SaveAllBioxels(bpy.types.Operator):
|
||||
bl_idname = "bioxelnodes.save_all_bioxels"
|
||||
bl_label = "Save All Bioxels"
|
||||
bl_description = "Save All Bioxels to Directory."
|
||||
bl_options = {'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
bioxels_objs = get_all_bioxels_objs()
|
||||
|
||||
if len(bioxels_objs) == 0:
|
||||
self.report({"WARNING"}, "Cannot find any bioxels.")
|
||||
return {'FINISHED'}
|
||||
|
||||
for bioxels_obj in bioxels_objs:
|
||||
name = bioxels_obj.parent.name
|
||||
|
||||
# "//"
|
||||
bioxels_dir = bpy.path.abspath(context.scene.bioxels_dir)
|
||||
source_dir = bpy.path.abspath(bioxels_obj.data.filepath)
|
||||
|
||||
output_path: Path = Path(bioxels_dir, f"{name}.vdb").resolve()
|
||||
source_path: Path = Path(source_dir).resolve()
|
||||
|
||||
if output_path != source_path:
|
||||
shutil.copy(source_path, output_path)
|
||||
|
||||
blend_path = Path(bpy.path.abspath("//")).resolve()
|
||||
bioxels_obj.data.filepath = bpy.path.relpath(
|
||||
str(output_path), start=str(blend_path))
|
||||
|
||||
self.report({"INFO"}, f"Successfully saved bioxels")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class BIOXELNODES_PT_Bioxels(bpy.types.Panel):
|
||||
bl_idname = "BIOXELNODES_PT_Bioxels"
|
||||
bl_label = "Bioxels"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
scene = context.scene
|
||||
layout.prop(scene, 'bioxels_dir')
|
||||
layout.operator(SaveAllBioxels.bl_idname)
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
from pathlib import Path
|
||||
from .custom_nodes import CustomNodes
|
||||
from .customnodes import CustomNodes
|
||||
import bpy
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ def add_driver_to_node_factory(source_prop, target_prop):
|
||||
import bpy
|
||||
from bioxelnodes.utils import add_direct_driver, get_bioxels_obj
|
||||
bioxels_obj = get_bioxels_obj(bpy.context.active_object)
|
||||
container_obj = bioxels_obj.parent
|
||||
if bioxels_obj:
|
||||
container_obj = bioxels_obj.parent
|
||||
add_direct_driver(
|
||||
target=node,
|
||||
target_prop='{target_prop}',
|
||||
@@ -28,9 +28,10 @@ def set_prop_to_node_factory(source_prop, target_prop):
|
||||
import bpy
|
||||
from bioxelnodes.utils import get_bioxels_obj
|
||||
bioxels_obj = get_bioxels_obj(bpy.context.active_object)
|
||||
container_obj = bioxels_obj.parent
|
||||
if bioxels_obj:
|
||||
node.inputs.get('{target_prop}').default_value = container_obj.get('{source_prop}')
|
||||
container_obj = bioxels_obj.parent
|
||||
node.inputs.get('{target_prop}').default_value = container_obj.get(
|
||||
'{source_prop}')
|
||||
else:
|
||||
print('Cannot find any bioxels.')
|
||||
"""
|
||||
@@ -39,29 +40,29 @@ else:
|
||||
|
||||
MENU_ITEMS = [
|
||||
{
|
||||
'label': 'Present',
|
||||
'label': 'Presets',
|
||||
'icon': 'MESH_MONKEY',
|
||||
'items': [
|
||||
{
|
||||
'label': 'Segment',
|
||||
'icon': 'OUTLINER_OB_META',
|
||||
'icon': 'BONE_DATA',
|
||||
'node_type': 'BioxelNodes_Segment',
|
||||
'node_description': '',
|
||||
'node_callback': "\n".join([
|
||||
set_prop_to_node_factory("bioxel_size", "Detail Size"),
|
||||
set_prop_to_node_factory("bioxels_offset", "Offset"),
|
||||
])
|
||||
},
|
||||
{
|
||||
'label': 'Anatomy',
|
||||
'icon': 'OUTLINER_OB_META',
|
||||
'node_type': 'BioxelNodes_Anatomy',
|
||||
'node_description': '',
|
||||
'node_callback': "\n".join([
|
||||
set_prop_to_node_factory("bioxel_size", "Detail Size"),
|
||||
set_prop_to_node_factory("bioxels_offset", "Offset"),
|
||||
])
|
||||
}
|
||||
# {
|
||||
# 'label': 'Anatomy',
|
||||
# 'icon': 'CONSTRAINT_BONE',
|
||||
# 'node_type': 'BioxelNodes_Anatomy',
|
||||
# 'node_description': '',
|
||||
# 'node_callback': "\n".join([
|
||||
# set_prop_to_node_factory("bioxel_size", "Detail Size"),
|
||||
# set_prop_to_node_factory("bioxels_offset", "Offset"),
|
||||
# ])
|
||||
# }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -70,7 +71,7 @@ MENU_ITEMS = [
|
||||
'items': [
|
||||
{
|
||||
'label': 'Segment by Level',
|
||||
'icon': 'AREA_JOIN',
|
||||
'icon': 'EMPTY_SINGLE_ARROW',
|
||||
'node_type': 'BioxelNodes_SegmentByLevel',
|
||||
'node_description': '',
|
||||
'node_callback': "\n".join([
|
||||
@@ -80,24 +81,24 @@ MENU_ITEMS = [
|
||||
},
|
||||
{
|
||||
'label': 'Segment by Range',
|
||||
'icon': 'AREA_SWAP',
|
||||
'icon': 'IPO_CONSTANT',
|
||||
'node_type': 'BioxelNodes_SegmentByRange',
|
||||
'node_description': '',
|
||||
'node_callback': "\n".join([
|
||||
set_prop_to_node_factory("bioxel_size", "Detail Size"),
|
||||
set_prop_to_node_factory("bioxels_offset", "Offset"),
|
||||
])
|
||||
},
|
||||
{
|
||||
'label': 'Segment by Layers',
|
||||
'icon': 'OUTLINER_OB_FORCE_FIELD',
|
||||
'node_type': 'BioxelNodes_SegmentsByLayers',
|
||||
'node_description': '',
|
||||
'node_callback': "\n".join([
|
||||
set_prop_to_node_factory("bioxel_size", "Detail Size"),
|
||||
set_prop_to_node_factory("bioxels_offset", "Offset"),
|
||||
])
|
||||
}
|
||||
# {
|
||||
# 'label': 'Segment by Layers',
|
||||
# 'icon': 'ALIGN_JUSTIFY',
|
||||
# 'node_type': 'BioxelNodes_SegmentsByLayers',
|
||||
# 'node_description': '',
|
||||
# 'node_callback': "\n".join([
|
||||
# set_prop_to_node_factory("bioxel_size", "Detail Size"),
|
||||
# set_prop_to_node_factory("bioxels_offset", "Offset"),
|
||||
# ])
|
||||
# }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -111,8 +112,13 @@ MENU_ITEMS = [
|
||||
'node_description': ''
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
NODE_FILE = "BioxelNodes_4.0"
|
||||
|
||||
if bpy.app.version >= (4, 1, 0):
|
||||
MENU_ITEMS.append({
|
||||
'label': 'Slicer',
|
||||
'icon': 'CLIPUV_DEHLT',
|
||||
'items': [
|
||||
@@ -135,12 +141,21 @@ MENU_ITEMS = [
|
||||
'node_description': '',
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
})
|
||||
NODE_FILE = "BioxelNodes_4.1"
|
||||
|
||||
|
||||
custom_nodes = CustomNodes(
|
||||
menu_items=MENU_ITEMS,
|
||||
nodes_file=Path(__file__).parent/"assets/Nodes.blend",
|
||||
root_label='BioxelNodes',
|
||||
nodes_file=Path(__file__).parent / f"assets/Nodes/{NODE_FILE}.blend",
|
||||
root_label='Bioxel Nodes',
|
||||
root_icon="FILE_VOLUME",
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
custom_nodes.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
custom_nodes.unregister()
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import bpy
|
||||
from pathlib import Path
|
||||
from .external_package import ExternalPackagePreferences
|
||||
from .externalpackage import ExternalPackagePreferences
|
||||
|
||||
|
||||
class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
|
||||
cache_dir: bpy.props.StringProperty(
|
||||
name="Python Log Directory",
|
||||
name="Set Cache Directory",
|
||||
subtype='DIR_PATH',
|
||||
default=Path(Path.home(), '.bioxelnodes').as_posix()
|
||||
default=str(Path(Path.home(), '.bioxelnodes'))
|
||||
) # type: ignore
|
||||
|
||||
do_add_segmentnode: bpy.props.BoolProperty(
|
||||
name="Add Segment Node",
|
||||
default=True,
|
||||
) # type: ignore
|
||||
|
||||
do_change_render_setting: bpy.props.BoolProperty(
|
||||
name="Change Render Setting",
|
||||
default=True,
|
||||
) # type: ignore
|
||||
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Configuration")
|
||||
layout.prop(self, 'cache_dir', text='Set Cache Directory')
|
||||
|
||||
|
||||
# ExternalPackagePreferences Config
|
||||
self.requirements_dir = Path(__file__).parent.as_posix()
|
||||
self.requirements_dir = str(Path(__file__).parent)
|
||||
super().draw(context)
|
||||
|
||||
layout.label(text="Configuration")
|
||||
layout.prop(self, 'cache_dir')
|
||||
|
||||
layout.prop(self, "do_change_render_setting")
|
||||
layout.prop(self, "do_add_segmentnode")
|
||||
|
||||
5
bioxelnodes/props.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import bpy
|
||||
|
||||
class BIOXELNODES_Series(bpy.types.PropertyGroup):
|
||||
id: bpy.props.StringProperty() # type: ignore
|
||||
label: bpy.props.StringProperty() # type: ignore
|
||||
@@ -2,6 +2,13 @@ import bpy
|
||||
import mathutils
|
||||
|
||||
|
||||
def get_type(cls):
|
||||
return type(cls).__name__
|
||||
|
||||
|
||||
def get_node_by_type(nodes, type_name: str):
|
||||
return [node for node in nodes if get_type(node) == type_name]
|
||||
|
||||
def show_message(message="", title="Message Box", icon='INFO'):
|
||||
|
||||
def draw(self, context):
|
||||
@@ -78,7 +85,18 @@ def get_bioxels_obj(current_obj):
|
||||
return bioxels_obj
|
||||
|
||||
|
||||
def extract_last_number(text):
|
||||
def get_all_bioxels_objs():
|
||||
bioxels_objs = []
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.parent:
|
||||
if obj.parent.get('bioxels_container') and obj.get('bioxels'):
|
||||
bioxels_objs.append(obj)
|
||||
|
||||
return list(set(bioxels_objs))
|
||||
|
||||
|
||||
def get_text_index_str(text):
|
||||
# Initialize an empty string to store the digits
|
||||
digits = ""
|
||||
|
||||
@@ -97,7 +115,7 @@ def extract_last_number(text):
|
||||
# Reverse the digits string to get the correct order
|
||||
last_number = digits[::-1]
|
||||
|
||||
return int(last_number)
|
||||
return last_number
|
||||
|
||||
|
||||
def add_driver(target_prop, var_sources, expression):
|
||||
|
||||
BIN
docs/assets/as-labels.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
docs/assets/as-scalar.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
docs/assets/bake-node.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
docs/assets/before-sharing.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/assets/dependency.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/assets/gallery.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
docs/assets/general-graph-example.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/assets/general-graph.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/assets/import-dialog.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/assets/import-others.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
docs/assets/importing.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/assets/logo.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/assets/resample.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/assets/result.png
Normal file
|
After Width: | Height: | Size: 770 KiB |
BIN
docs/assets/segment-node.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/assets/to-mesh.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
45
docs/features.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Features & Options
|
||||
|
||||
## Import Volume Data as Biovels
|
||||
|
||||
File > Import > Volume Data as Biovels
|
||||
|
||||
### Resample
|
||||
|
||||

|
||||
|
||||
Sometimes the original data is too big, or the spacing in the original data is not reasonable, you can modify the `Bioxel Size` and `Original Spacing` to adjust the Shape of the Bixoels.
|
||||
|
||||
A bioxel is like a pixel, the larger the `Bioxel Size`, the lower the resolution of the image, Original Spacing will be read from the original data record, but sometimes the image doesn't have original spacing, you may need to input it manually to get the correct shape.
|
||||
|
||||
### Read as
|
||||
|
||||
#### as Scalar
|
||||
|
||||

|
||||
|
||||
In some cases the environment value is higher than the value of the target object, you can check `Invert Scalar` to adjust the value for better result.
|
||||
|
||||
#### as Labels
|
||||
|
||||

|
||||
|
||||
Many AI segmentation task datasets, provide segmentation data, which are often an integer value representing a layer of segmentation labels. You can set the Label Index to determine exactly which labels to load.
|
||||
|
||||
#### as Vector (Not implemented)
|
||||
|
||||
#### as Color (Not implemented)
|
||||
|
||||
### Others
|
||||
|
||||

|
||||
|
||||
## Convert Bioxels to Mesh
|
||||
|
||||
In 3D view, select bioxels and right-click, Bioxels > Bioxels To Mesh
|
||||
|
||||

|
||||
|
||||
## Export Biovels as VDB
|
||||
|
||||
File > Export > Biovels as VDB
|
||||
63
docs/getting-started.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Getting Started
|
||||
|
||||
Currently only support Blender 4.0 or above, make sure you have the correct version of Blender.
|
||||
|
||||
## Add-on Installation
|
||||
|
||||
Download the latest version https://github.com/OmooLab/BioxelNodes/releases/latest
|
||||
In Blender, Edit > Preferences > Add-ons > Install, select the `BioxelNodes_{version}.zip` you just downloaded.
|
||||
|
||||
The add-on requires a third-party python dependency called SimpleITK, click `Install SimpleITK` button below to install the dependency. After clicking, blender may get stuck, it is downloading and installing, just wait for a moment. After that, click `Reboot Blender` button.
|
||||
|
||||

|
||||
|
||||
This step may have failed due to network factors, just click "Set PyPI Mirror" to change the mirror.
|
||||
|
||||
## Prepare Your Data
|
||||
|
||||
First you need to have your volumetric data ready. If not, you can access open research data from list below:
|
||||
|
||||
| Source | Object |
|
||||
| ------------------------------------------------------------------------------------ | ------------------ |
|
||||
| [Dryad](https://datadryad.org) | Open Research Data |
|
||||
| [OpenOrganelle](https://openorganelle.janelia.org/datasets) | Cells |
|
||||
| [Embodi3D](https://www.embodi3d.com/files/category/37-medical-scans/) | Medical Images |
|
||||
| [Github](https://github.com/sfikas/medical-imaging-datasets) | Medical Images |
|
||||
| [NIHR](https://nhsx.github.io/open-source-imaging-data-sets/) | Medical Images |
|
||||
| [Medical Segmentation Decathlon](http://medicaldecathlon.com/) | Medical Images |
|
||||
| [Allen Cell Explorer](https://www.allencell.org/3d-cell-viewer.html) | Cells |
|
||||
| [Visible Human Project](https://www.nlm.nih.gov/research/visible/visible_human.html) | Medical Images |
|
||||
|
||||
## Import Volume Data
|
||||
|
||||
File > Import > Volume Data as Bioxels, select **one** of the .dcm files and click on "Volume Data as Bioxels" (you can also drag one of the .dcm files directly into the 3D viewport to trigger the import)
|
||||
|
||||

|
||||
|
||||
It may take a while to read data. After finishing reading, it will pop up a dialog box
|
||||
|
||||

|
||||
|
||||
Ignore the options, just click OK!
|
||||
|
||||
After importing, the necessary nodes are automatically added, the reconstruction and shader are created. Click select Bioxels Object, and open the Geometry Nodes panel, change the "Threshold" in segment node.
|
||||
|
||||

|
||||
|
||||
and then turned on the cycles rendering to directly see the result.
|
||||
|
||||

|
||||
|
||||
You can change the "Threshold" to modify the reconstruction model, or change the "Color", "Density" to modify the shader effect. All the parameters are straightforward, you can understand them by changing the values.
|
||||
|
||||
## Share Your File
|
||||
|
||||
The VDB cache and the Bioxel nodes are not reachable to computers if you don't save them. If you need to give your blender file to someone else, you will need to perform the following steps:
|
||||
|
||||
1. Save your blender file
|
||||
2. Click Scene > Bioxels > Save All Bioxels in the Properties panel.
|
||||
3. Click Scene > Custom Nodes > Save All Custom Nodes under the Properties panel.
|
||||
|
||||

|
||||
|
||||
After that, both the VDB cache and the Bioxel nodes are stored in relative locations. When you share a file with someone, pack the entire directory so that the resources are not lost. The other person will be able to load correctly, regardless of whether they have BioxelNodes installed.
|
||||
45
docs/index.md
Normal file
@@ -0,0 +1,45 @@
|
||||
[中文文档](https://uj6xfhbzp0.feishu.cn/wiki/Qx3VwHuNPimeI8kr6nDcvl1DnHf?from=from_copylink)
|
||||
|
||||
# Bioxel Nodes
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Bioxel Nodes is a Blender add-on for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes | Cycles to process and render volumetric data.
|
||||
|
||||
# About
|
||||
|
||||
Before us, there have been many tutorials and add-ons for importing volumetric data into Blender. However, we found that there were many details that were not addressed in place, some scientific facts were ignored, and the volume rendering was not pretty enough. With Bioxel Nodes, you can easily import the volumetric data into Blender, and more importantly, it can quickly make a beautiful realistic rendering of it.
|
||||
|
||||
Below are some examples with Bioxel Nodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail:
|
||||
|
||||

|
||||
|
||||
The "Bioxel" in "BioxelNodes", is a combination of the words "Bio-" and "Voxel". Bioxel is a voxel that stores biological data. The volumetric data made up of Bioxel is called Bioxels. We are developing a toolkit around Bioxels for better biological data visualization. but before its release, we made this Blender version of bioxels toolkit first, in order to let more people to have fun with volumetric data. [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started)
|
||||
|
||||
# Supported Format
|
||||
|
||||
| Format | EXT | Test |
|
||||
| ------ | ---------------------------------------- | ------- |
|
||||
| DICOM | .dcm, .DICOM | ✅ pass |
|
||||
| BMP | .bmp, .BMP | ✅ pass |
|
||||
| JPEG | .jpg, .JPG, .jpeg, .JPEG | ✅ pass |
|
||||
| PNG | .png, .PNG | ✅ pass |
|
||||
| TIFF | .tif, .TIF, .tiff, .TIFF | ✅ pass |
|
||||
| Nifti | .nia, .nii, .nii.gz, .hdr, .img, .img.gz | ✅ pass |
|
||||
| Nrrd | .nrrd, .nhdr | ✅ pass |
|
||||
| Meta | .mha, .mhd | yet |
|
||||
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
|
||||
| VTK | .vtk | yet |
|
||||
| BioRad | .PIC, .pic | yet |
|
||||
| Gipl | .gipl, .gipl.gz | yet |
|
||||
| LSM | .lsm, .LSM | yet |
|
||||
| MINC | .mnc, .MNC | yet |
|
||||
| MRC | .mrc, .rec | yet |
|
||||
|
||||
# Known limitations
|
||||
|
||||
- Sections cannot be generated
|
||||
- Time sequence volume not supported (will be supported soon)
|
||||
20
docs/misc.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Future Features
|
||||
|
||||
- Support more volumetric data formats (.map, .txm...)
|
||||
- Generate sections
|
||||
- More segmentation methods, e.g. AI segmentation.
|
||||
- Even better shader for volumetric rendering
|
||||
|
||||
### Bioxels
|
||||
|
||||
Bioxels is based on the RAS coordinate system, Right Aanterior Superior, which was chosen over LPS because it is more compatible with most 3D CG software coordinate systems, and is in line with the 3D artist's understanding of space.
|
||||
|
||||
All distances within Bioxels are in Units, and are specified in Meter pre unit. However, when Bioxels is imported into 3D CG software, its size in the software is not scaled by reading the Meter pre unit directly. The reason for this is that many 3D operations in software require that the primtives not be too large or too small.
|
||||
|
||||
### Based on OpenVDB
|
||||
|
||||
Bioxels is based entirely on OpenVDB for storage and rendering. The main reason for choosing OpenVDB is that as a volumetric data format, it is the fastest way to work with most CG renderers.
|
||||
|
||||
### Based on Geometry Nodes
|
||||
|
||||
Bioxel Nodes relies on Blender Geometry Nodes to reconstruct and render volumetric data. Node-based operations ensure that the original data is not permanently altered during reconstruction and rendering operations. The fact that the processing is based on Geometry Nodes without any additional dependencies also ensures that Blender can open files without this plugin installed. Look for more support for OpenVDB in GeometryNodes so that Bioxel Nodes can do more in the future.
|
||||
19
docs/nodes.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Geometry Nodes
|
||||
|
||||
The node, that named "Segment", is a preset node that combines the steps of 3D reconstruction process and shading process. In general, you need to add one of the segmentation node to reconstruction first, and followed by one of the shader node to set the shader. As you can see in the following figure:
|
||||
|
||||

|
||||
|
||||
Currently there are 3 types of nodes
|
||||
|
||||
- Segmentation nodes, responsible for splitting the volume into a reconstructed model (we call them "Segment").
|
||||
- Shader nodes, responsible for giving shader to the Segment.
|
||||
- Slicer nodes, responsible for partially cutting the Segment.
|
||||
|
||||
For example:
|
||||
|
||||

|
||||
|
||||
You can add a "Bake" node between segmentation node and shader node, if the reconstruction process consumes too much computing time. But be sure, you are selecting the volume object, not the container object.
|
||||
|
||||

|
||||
20
docs/stylesheets/extra.css
Normal file
@@ -0,0 +1,20 @@
|
||||
[data-md-color-scheme="slate"] {
|
||||
--md-code-hl-string-color: #8b8b8b;
|
||||
--md-code-hl-keyword-color: white;
|
||||
--md-code-hl-number-color: white;
|
||||
--md-typeset-a-color: white;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="default"] {
|
||||
--md-code-hl-string-color: #444444;
|
||||
--md-code-hl-keyword-color: black;
|
||||
--md-code-hl-number-color: black;
|
||||
}
|
||||
|
||||
[data-md-color-scheme=slate][data-md-color-primary=black] {
|
||||
--md-typeset-a-color: white;
|
||||
}
|
||||
|
||||
[data-md-color-scheme=default][data-md-color-primary=black] {
|
||||
--md-typeset-a-color: black;
|
||||
}
|
||||
82
mkdocs.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
site_name: Bioxel Nodes Docs
|
||||
site_url: https://omoolab.github.io/BioxelNodes/
|
||||
site_author: OmooLab
|
||||
site_description: Bioxel Nodes is a Blender add-on for scientific volumetric data visualization.
|
||||
|
||||
repo_name: OmooLab/BioxelNodes
|
||||
repo_url: https://github.com/OmooLab/BioxelNodes
|
||||
|
||||
nav:
|
||||
- BioxelNodes:
|
||||
- index.md
|
||||
- Getting Started: getting-started.md
|
||||
- Features & Options: features.md
|
||||
- Geometry Nodes: nodes.md
|
||||
- Future Features: misc.md
|
||||
|
||||
theme:
|
||||
name: material
|
||||
logo: assets/logo.png
|
||||
language: en
|
||||
font:
|
||||
text: Open Sans
|
||||
code: Cascadia Code
|
||||
palette:
|
||||
# Palette toggle for automatic mode
|
||||
- media: "(prefers-color-scheme)"
|
||||
primary: black
|
||||
toggle:
|
||||
icon: fontawesome/solid/circle-half-stroke
|
||||
name: Switch to light mode
|
||||
|
||||
# Palette toggle for light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: black
|
||||
toggle:
|
||||
icon: fontawesome/regular/moon
|
||||
name: Switch to dark mode
|
||||
|
||||
# Palette toggle for dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: black
|
||||
toggle:
|
||||
icon: fontawesome/solid/moon
|
||||
name: Switch to system preference
|
||||
features:
|
||||
- header.autohide
|
||||
- navigation.footer
|
||||
- navigation.indexes
|
||||
- navigation.sections
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- content.code.copy
|
||||
- content.code.select
|
||||
|
||||
extra:
|
||||
version:
|
||||
provider: mike
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
|
||||
copyright: Copyright © 2020 - 2024 Omoolab
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- mkdocstrings
|
||||
|
||||
markdown_extensions:
|
||||
- mkdocs-click
|
||||
- admonition
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
1966
poetry.lock
generated
Normal file
29
pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[tool.poetry]
|
||||
name = "bioxelnodes"
|
||||
version = "0.1.1"
|
||||
description = ""
|
||||
authors = ["Ma Nan <icrdr2010@outlook.com>"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "~=3.10.0"
|
||||
bpy = "~=4.0"
|
||||
simpleitk = "^2.3.1"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ipykernel = "^6.25.2"
|
||||
pytest = "^7.4.2"
|
||||
pytest-dependency = "^0.5.1"
|
||||
mkdocs = "^1.5.3"
|
||||
mkdocs-material = "^9.4.6"
|
||||
mkdocstrings = { extras = ["python"], version = "^0.23.0" }
|
||||
mkdocs-click = "^0.8.1"
|
||||
autopep8 = "^2.0.4"
|
||||
pytest-cov = "^4.1.0"
|
||||
mike = "^2.0.0"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||