first release

This commit is contained in:
2024-04-15 09:36:13 +08:00
parent 00a69e11e8
commit dce9627d82
23 changed files with 956 additions and 348 deletions

57
.github/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
# release title (appears, for example, in repo's sidebar)
# NOTE: $RESOLVED_VERSION == $MAJOR.$MINOR.$PATCH
name-template: "v$RESOLVED_VERSION"
# git tag to be used for the release
tag-template: "v$RESOLVED_VERSION"
# Release Notes template (keep it as is)
template: |
## What’s Changed
$CHANGES
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
# Define which PR label will cause which kind of
# version bump (following semantic versioning).
# If no labels match, the default "patch".
version-resolver:
major:
labels:
- 'major'
minor:
labels:
- 'minor'
patch:
labels:
- 'patch'
default: patch
# Define which PR label will be listed in which
# category of changes in the Release Notes.
# If no labels match, the default is to be
# listed on the top, before the sections.
categories:
- title: "🚨 BREAKING CHANGES 🚨"
labels:
- "BREAKING CHANGE"
- title: "🚀 New Features"
labels:
- "feature"
- "enhancement"
- title: "🐛 Bug Fixes"
labels:
- "fix"
- "bug"
- title: "🛠️ Other Changes"
labels:
- "chore"
- "refactor"
- "documentation"
- "style"
- "test"
- "revert"
- "dependencies"
- "ci"

24
.github/workflows/release-drafter.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Release Drafter
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, labeled, unlabeled]
permissions:
contents: read
jobs:
update_release_draft:
permissions:
# write permission required to create a github release
contents: write
# write permission required for autolabeler
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

72
.github/workflows/upload-assets.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Upload Assets to Release
on:
push:
branches:
- "release/*"
jobs:
# draft_release:
# name: Draft Release
# runs-on: ubuntu-latest
# outputs:
# upload_url: ${{ steps.draft_release.outputs.upload_url }}
# version: ${{ steps.set_env.outputs.version }}
# steps:
# - name: Set Version Env
# id: set_env
# run: |
# ref_name=${{ github.ref_name }}
# echo "version=${ref_name#release/}" >> "$GITHUB_OUTPUT"
# - name: Get release
# id: draft_release
# uses: cardinalby/git-get-release-action@1.2.4
# with:
# releaseName: Draft
# env:
# GITHUB_TOKEN: ${{ github.token }}
draft_release:
name: Draft Release
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.draft_release.outputs.upload_url }}
version: ${{ steps.set_env.outputs.version }}
steps:
- name: Set Version Env
id: set_env
run: |
ref_name=${{ github.ref_name }}
echo "version=${ref_name#release/}" >> "$GITHUB_OUTPUT"
- name: Draft Release ${{ steps.set_env.outputs.version }}
uses: release-drafter/release-drafter@v5
id: draft_release
with:
name: ${{ steps.set_env.outputs.version }}
tag: ${{ steps.set_env.outputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload_blender_addon:
name: Upload Blender Add-on
needs: draft_release
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Zip Add-on
run: |
zip -r package.zip bioxelnodes
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.draft_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
asset_path: ./package.zip
asset_name: BioxelNodes_${{ needs.draft_release.outputs.version }}.zip
asset_content_type: application/zip

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# BioxelNodes
BioxelNodes is a blender add-on for better volume data visualization with cycles.

View File

@@ -1,11 +1,12 @@
from .nodes import custom_nodes
from . import auto_load
from .dicom import MEDNODES_add_topbar_menu
from . import menu
import bpy
bl_info = {
"name": "MedNodes",
"name": "BiovelNodes",
"author": "Ma Nan",
"description": "",
"blender": (4, 0, 0),
@@ -21,12 +22,12 @@ auto_load.init()
def register():
auto_load.register()
custom_nodes.register()
bpy.types.TOPBAR_MT_file_import.prepend(MEDNODES_add_topbar_menu)
menu.create_menu()
def unregister():
try:
bpy.types.TOPBAR_MT_file_import.remove(MEDNODES_add_topbar_menu)
menu.remove_menu()
custom_nodes.unregister()
auto_load.unregister()
except RuntimeError:

BIN
bioxelnodes/assets/Nodes.blend LFS Normal file

Binary file not shown.

View File

@@ -129,7 +129,7 @@ def iter_classes_in_module(module):
def get_register_base_types():
return set(getattr(bpy.types, name) for name in [
"Panel", "Operator", "PropertyGroup",
"Panel", "Operator", "PropertyGroup", "FileHandler",
"AddonPreferences", "Header", "Menu",
"Node", "NodeSocket", "NodeTree",
"UIList", "RenderEngine",

45
bioxelnodes/convert.py Normal file
View File

@@ -0,0 +1,45 @@
import bpy
from .utils import get_bioxels_obj
class ConvertToMesh(bpy.types.Operator):
bl_idname = "bioxelnodes.convert_to_mesh"
bl_label = "Bioxels To Mesh"
bl_description = "Convert Bioxels To Mesh."
bl_options = {'UNDO'}
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:
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
mesh_obj = bpy.context.active_object
mesh_obj.name = f"{bioxels_obj.parent.name}_Mesh"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = mesh_obj.modifiers[0]
nodes = modifier.node_group.nodes
links = modifier.node_group.links
output_node = nodes.get("Group Output")
object_node = nodes.new("GeometryNodeObjectInfo")
realize_nodes = nodes.new("GeometryNodeRealizeInstances")
object_node.inputs[0].default_value = bioxels_obj
object_node.transform_space = 'RELATIVE'
links.new(object_node.outputs['Geometry'], realize_nodes.inputs[0])
links.new(realize_nodes.outputs[0], output_node.inputs[0])
bpy.ops.object.convert(target='MESH')
bpy.context.object.active_material_index = 1
bpy.ops.object.material_slot_remove()
return {'FINISHED'}

View File

@@ -9,7 +9,7 @@ class CustomNodes():
menu_items,
nodes_file,
root_label='CustomNodes',
root_icon=88
root_icon='NONE'
) -> None:
if not Path(nodes_file).is_file():
raise FileNotFoundError(Path(nodes_file).resolve().as_posix())
@@ -34,7 +34,7 @@ class CustomNodes():
if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type):
layout = self.layout
layout.separator()
layout.menu(idname, icon_value=root_icon)
layout.menu(idname, icon=root_icon)
self.add_node_menu = add_node_menu
@@ -71,17 +71,20 @@ class CustomNodes():
elif item.get('menu_class'):
layout.menu(
item.get('menu_class').bl_idname,
icon_value=item.get('icon') or 0
icon=item.get('icon') or 'NONE'
)
else:
op = layout.operator(
'customnodes.add_custom_node', text=item.get('label'))
'customnodes.add_custom_node',
text=item.get('label'),
icon=item.get('icon') or 'NONE'
)
op.nodes_file = nodes_file
op.node_type = item['node_type']
op.node_label = item.get('label') or ""
op.node_description = item.get(
'node_description') or "Add Custom Node."
op.node_driver = item.get('node_driver') or ""
op.node_callback = item.get('node_callback') or ""
menu_classes.append(Menu)
return Menu
@@ -108,9 +111,7 @@ class CustomNodes():
op.nodes_file = self.nodes_file
op.node_type = item['node_type']
op.node_label = item.get('label') or ""
op.node_description = item.get(
'node_description') or "Add Custom Node."
op.node_driver = item.get('node_driver') or ""
op.node_callback = item.get('node_callback') or ""
return op.add_node(node_tree)
else:
raise RuntimeError("No custom node type found.")

View File

@@ -1,41 +1,6 @@
import bpy
def add_driver(target_prop, var_sources, expression):
driver = target_prop.driver_add("default_value")
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
target_prop = target.inputs.get(target_prop)
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target_prop, drivers, expression)
class AddCustomNode():
nodes_file: bpy.props.StringProperty(
@@ -57,11 +22,6 @@ class AddCustomNode():
default=''
) # type: ignore
node_driver: bpy.props.StringProperty(
name='node_driver',
default=''
) # type: ignore
node_description: bpy.props.StringProperty(
name="node_description",
description="",
@@ -69,9 +29,9 @@ class AddCustomNode():
subtype="NONE"
) # type: ignore
node_link: bpy.props.BoolProperty(
name='node_link',
default=True
node_callback: bpy.props.StringProperty(
name='node_callback',
default=''
) # type: ignore
@classmethod
@@ -85,19 +45,12 @@ class AddCustomNode():
node.label = self.node_label or self.node_type
node.name = self.node_type
if self.node_driver:
source_prop = self.node_driver.split(":")[0]
target_prop = self.node_driver.split(":")[1]
add_direct_driver(
target=node,
target_prop=target_prop,
source=bpy.context.active_object,
source_prop=source_prop
)
if self.node_callback:
exec(self.node_callback)
return node
def append_node_tree(self, node_type):
def get_node_tree(self, node_type):
# try to get node from current file if exists
node_tree = bpy.data.node_groups.get(node_type)
# if not exists, get it from asset file.
@@ -128,7 +81,7 @@ class AddCustomNode():
...
def add_node(self, node_tree):
self.append_node_tree(self.node_type)
self.get_node_tree(self.node_type)
node = node_tree.new("GeometryNodeGroup")
@@ -143,7 +96,7 @@ class CUSTOMNODES_OT_Add_Custom_Node(bpy.types.Operator, AddCustomNode):
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
self.append_node_tree(self.node_type)
self.get_node_tree(self.node_type)
# intended to be called upon button press in the node tree
prev_context = bpy.context.area.type

474
bioxelnodes/io.py Normal file
View File

@@ -0,0 +1,474 @@
import bpy
import shutil
from bpy_extras.io_utils import axis_conversion
import pyopenvdb as vdb
import numpy as np
from pathlib import Path
from uuid import uuid4
import mathutils
from .utils import calc_bbox_verts, get_bioxels_obj, show_message
from .nodes import custom_nodes
SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp']
def on_orig_spacing_changed(self, context):
if self.auto:
self.auto = False
new_orig_spacing = tuple(self.orig_spacing)
orig_shape = tuple(self.orig_shape)
bioxel_size = float(self.bioxel_size)
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]),
)
else:
self.auto = True
def on_bioxel_size_changed(self, context):
if self.auto:
self.auto = False
new_bioxel_size = float(self.bioxel_size)
orig_shape = tuple(self.orig_shape)
orig_spacing = tuple(self.orig_spacing)
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 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)."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH",
options={'HIDDEN'}
) # type: ignore
bioxels_shape: bpy.props.IntVectorProperty(
name="Bioxels Shape",
min=0,
default=(100, 100, 100)
) # type: ignore
bioxels_scale: bpy.props.FloatProperty(
name="Bioxel Size Scale",
soft_min=0.001, soft_max=100.0,
min=1e-6, max=1e6,
default=0.01,
) # 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
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):
if not self.filepath:
self.report({"WARNING"}, "Get no file path.")
return {'CANCELLED'}
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.stem
suffix = file_path.suffix
files = list(file_path.parent.iterdir())
files = [f.as_posix()
for f in files if f.is_file() and f.suffix == suffix]
import SimpleITK as sitk
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("Bioxel Size:", bioxel_size)
print("Bioxels Shape:", bioxels_shape)
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(),
)
try:
image = sitk.DICOMOrient(image, 'LPS')
except:
...
image_origin = image.GetOrigin()
bioxels_scale = float(self.bioxels_scale)
bioxel_size *= bioxels_scale
bioxel_origin = (
image_origin[0] * bioxels_scale,
image_origin[1] * bioxels_scale,
image_origin[2] * bioxels_scale,
)
bioxels_size = (
bioxels_shape[0] * bioxel_size,
bioxels_shape[1] * bioxel_size,
bioxels_shape[2] * bioxel_size,
)
direction = image.GetDirection()
array = sitk.GetArrayFromImage(image)
orig_dtype = str(array.dtype)
print(f"Coverting Dtype {orig_dtype} -> 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
# L (eft) i -> k -> x
# P (osterior) j -> j -> y
# S (uperior) k -> i -> z
array = np.transpose(array)
value_max = float(np.max(array))
value_min = float(np.min(array))
value_offset = 0.0
if value_min < 0 and orig_dtype[0] != "u":
value_offset = -value_min
array = array + np.full_like(array, value_offset)
value_max = float(np.max(array))
value_min = float(np.min(array))
print("Offseted Max:", value_max)
print("Offseted Min:", value_min)
# Build VDB
grid = vdb.FloatGrid()
grid.copyFromArray(array.copy())
grid.name = "value"
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])
# 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))
bioxels_obj = bpy.context.active_object
# Set props to VDB object
bioxels_obj.name = f"{name}_Values"
bioxels_obj.data.name = f"{name}_Values"
# Make transformation
# (S)uperior -Z -> Y
# (P)osterior -Y -> Z
axis_rot = axis_conversion(
from_forward='-Z',
from_up='-Y',
to_forward='Y',
to_up='Z'
).to_4x4()
mat_loc = mathutils.Matrix.Translation(
mathutils.Vector(bioxel_origin))
mat_sca = mathutils.Matrix.Scale(bioxel_size, 4)
mat_rot = mathutils.Matrix(
np.array(direction).reshape((3, 3))
).to_4x4()
bioxels_obj.matrix_world = axis_rot @ mat_loc @ mat_rot @ mat_sca
bioxels_obj.lock_location[0] = True
bioxels_obj.lock_location[1] = True
bioxels_obj.lock_location[2] = True
bioxels_obj.lock_rotation[0] = True
bioxels_obj.lock_rotation[1] = True
bioxels_obj.lock_rotation[2] = True
bioxels_obj.lock_scale[0] = True
bioxels_obj.lock_scale[1] = True
bioxels_obj.lock_scale[2] = True
bioxels_obj.hide_select = True
bioxels_obj['value_max'] = value_max
bioxels_obj['value_min'] = value_min
bioxels_obj['value_offset'] = value_offset
bioxels_obj['bioxels_shape'] = bioxels_shape
bioxels_obj['bioxels_size'] = bioxels_size
bioxels_obj['bioxel_size'] = bioxel_size
bioxels_obj['bioxel_origin'] = bioxel_origin
bioxels_obj['bioxels_values'] = True
bpy.ops.node.new_geometry_nodes_modifier()
node_tree = bioxels_obj.modifiers[0].node_group
# Wrapper a Container
bpy.ops.mesh.primitive_cube_add(
enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
)
container_obj = bpy.context.active_object
bbox_verts = calc_bbox_verts(bioxel_origin, bioxels_size)
for index, vert in enumerate(container_obj.data.vertices):
vert.co = bbox_verts[index]
bioxels_obj.parent = container_obj
container_obj.name = name
container_obj.data.name = f"{name}_Container"
container_obj.visible_camera = False
container_obj.visible_diffuse = False
container_obj.visible_glossy = False
container_obj.visible_transmission = False
container_obj.visible_volume_scatter = False
container_obj.visible_shadow = False
container_obj.display_type = 'WIRE'
container_obj['bioxels'] = True
bpy.ops.object.modifier_add(type='NODES')
container_obj.modifiers[0].node_group = node_tree
container_obj.modifiers[0].show_in_editmode = False
container_obj.modifiers[0].show_viewport = False
container_obj.modifiers[0].show_render = False
# Create BioxelNodes to VDB object
print(f"Creating BioxelNodes to the VDB...")
if self.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')
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:
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.volume_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 64
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'}
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_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
ok: bpy.props.BoolProperty(
default=True
) # type: ignore
def execute(self, context):
file_path = Path(self.filepath).resolve()
suffix = file_path.suffix
files = list(file_path.parent.iterdir())
files = [f.as_posix()
for f in files if f.is_file() and f.suffix == suffix]
import SimpleITK as sitk
image = sitk.ReadImage(files)
print("Collecting Meta Data...")
print("Original Shape:", image.GetSize())
print("Original Spacing:", image.GetSpacing())
print("Original Origin:", image.GetOrigin())
print("Original Direction:", image.GetDirection())
bpy.ops.bioxelnodes.import_dicom_dialog(
'INVOKE_DEFAULT',
filepath=self.filepath,
orig_shape=image.GetSize(),
orig_spacing=image.GetSpacing()
)
self.report({"INFO"}, "Successfully Readed.")
return {'FINISHED'}
def modal(self, context, event):
self.execute(context)
return {'FINISHED'}
def invoke(self, context, event):
if not self.filepath:
return {'CANCELLED'}
if Path(self.filepath).suffix not in SUPPORT_EXTS:
return {'CANCELLED'}
show_message('Reading image data, it may take a while',
'Please be patient...')
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
class ImportDICOM(bpy.types.Operator):
bl_idname = "bioxelnodes.import_dicom"
bl_label = "Volume Data as Biovels"
bl_description = "Import Volume Data as Biovels (VDB)."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
def execute(self, context):
bpy.ops.bioxelnodes.read_dicom(
'INVOKE_DEFAULT',
filepath=self.filepath
)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
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"
@classmethod
def poll_drop(cls, context):
return (context.area and context.area.type == 'VIEW_3D')
class ExportVDB(bpy.types.Operator):
bl_idname = "bioxelnodes.export_vdb"
bl_label = "Biovels as VDB"
bl_description = "Export Biovels original VDB data."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".vdb"
def invoke(self, context, event):
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:
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
else:
self.report({"WARNING"}, "Cannot find any bioxels.")
return {'CANCELLED'}
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)
bioxels_obj = bioxels_objs[0]
output_path: Path = Path(self.filepath).resolve()
source_path: Path = Path(bioxels_obj.data.filepath).resolve()
# print('output_path', output_path)
# print('source_path', source_path)
shutil.copy(source_path, output_path)
return {'FINISHED'}

33
bioxelnodes/menu.py Normal file
View File

@@ -0,0 +1,33 @@
import bpy
from .convert import ConvertToMesh
from .io import ExportVDB, ImportDICOM
def TOPBAR_FILE_IMPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ImportDICOM.bl_idname)
def TOPBAR_FILE_EXPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ExportVDB.bl_idname)
def VIEW3D_OBJECT(self, context):
layout = self.layout
layout.operator(ConvertToMesh.bl_idname)
layout.separator()
def create_menu():
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():
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)

109
bioxelnodes/nodes.py Normal file
View File

@@ -0,0 +1,109 @@
from .custom_nodes import CustomNodes
import bpy
def add_driver_to_node_factory(source_prop, target_prop):
callback_str = f"""
import bpy
from bioxelnodes.utils import add_direct_driver, get_bioxels_obj
bioxels_obj = get_bioxels_obj(bpy.context.active_object)
if bioxels_obj:
add_direct_driver(
target=node,
target_prop='{target_prop}',
source=bioxels_obj,
source_prop='{source_prop}'
)
else:
print('Cannot find any bioxels.')
"""
return callback_str
MENU_ITEMS = [
{
'label': 'Present',
'icon': 'MESH_MONKEY',
'items': [
{
'label': 'Segment',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_Segment',
'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset")
}
]
},
{
'label': 'Segment',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Segment by Level',
'icon': 'AREA_JOIN',
'node_type': 'BioxelNodes_SegmentByLevel',
'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset")
},
{
'label': 'Segment by Range',
'icon': 'AREA_SWAP',
'node_type': 'BioxelNodes_SegmentByRange',
'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset")
},
{
'label': 'Segment by Layers',
'icon': 'OUTLINER_OB_FORCE_FIELD',
'node_type': 'BioxelNodes_SegmentsByLayers',
'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset")
}
]
},
{
'label': 'Shader',
'icon': 'SHADING_RENDERED',
'items': [
{
'label': 'Segment Shader',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_SegmentShader',
'node_description': ''
}
]
},
{
'label': 'Slicer',
'icon': 'CLIPUV_DEHLT',
'items': [
{
'label': 'LPS Slicer',
'icon': 'EMPTY_DATA',
'node_type': 'BioxelNodes_LPS-Slicer',
'node_description': '',
'node_callback': add_driver_to_node_factory("origin", "Origin")
},
{
'label': 'Axis Slicer',
'icon': 'OUTLINER_OB_EMPTY',
'node_type': 'BioxelNodes_AxisSlicer',
'node_description': '',
},
{
'label': 'Box Slicer',
'icon': 'MOD_WIREFRAME',
'node_type': 'BioxelNodes_BoxSlicer',
'node_description': '',
}
]
},
]
custom_nodes = CustomNodes(
menu_items=MENU_ITEMS,
nodes_file="assets/Nodes.blend",
root_label='BioxelNodes',
root_icon="FILE_VOLUME",
)

View File

@@ -3,13 +3,13 @@ from pathlib import Path
from .external_package import ExternalPackagePreferences
class MedNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences):
class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences):
bl_idname = __package__
cache_dir: bpy.props.StringProperty(
name="Python Log Directory",
subtype='DIR_PATH',
default=Path(Path.home(), '.mednodes').as_posix()
default=Path(Path.home(), '.bioxelnodes').as_posix()
) # type: ignore

111
bioxelnodes/utils.py Normal file
View File

@@ -0,0 +1,111 @@
import bpy
import mathutils
def show_message(message = "", title = "Message Box", icon = 'INFO'):
def draw(self, context):
self.layout.label(text=message)
bpy.context.window_manager.popup_menu(draw, title = title, icon = icon)
def calc_bbox_verts(origin, size):
bbox_origin = mathutils.Vector(
(-origin[0], -origin[2], -origin[1]))
bbox_size = mathutils.Vector(
(-size[0], -size[2], -size[1]))
bbox_verts = [
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2],
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0,
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2],
),
]
return bbox_verts
def get_bioxels_obj(current_obj):
bioxels_obj = None
if current_obj.get('bioxels'):
bioxels_values = []
for obj in bpy.data.objects:
if obj.parent == current_obj and obj.get('bioxels_values'):
bioxels_values.append(obj)
if len(bioxels_values) > 0:
bioxels_obj = bioxels_values[0]
elif current_obj.get('bioxels_values') and current_obj.parent:
if current_obj.parent.get('bioxels'):
bioxels_obj = current_obj
return bioxels_obj
def add_driver(target_prop, var_sources, expression):
driver = target_prop.driver_add("default_value")
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
target_prop = target.inputs.get(target_prop)
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target_prop, drivers, expression)

Binary file not shown.

View File

@@ -1,200 +0,0 @@
import bpy
from bpy_extras.io_utils import axis_conversion, ImportHelper
import pyopenvdb as vdb
import numpy as np
from pathlib import Path
from uuid import uuid4
import mathutils
from .nodes import custom_nodes
class ImportDICOM(bpy.types.Operator, ImportHelper):
bl_idname = "mednodes.import_dicom"
bl_label = "DICOM as VDB (.dcm)"
bl_description = "Load DICOM as VDB"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
global_scale: bpy.props.FloatProperty(
name="Scale",
soft_min=0.001, soft_max=100.0,
min=1e-6, max=1e6,
default=0.01,
) # type: ignore
do_resample: bpy.props.BoolProperty(
name="Uniform Spacing (Resample)",
default=True,
) # type: ignore
do_change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting",
default=True,
) # type: ignore
def execute(self, context):
import SimpleITK as sitk
preferences = context.preferences.addons[__package__].preferences
file_path: Path = Path(self.filepath).resolve()
name = file_path.parent.stem
reader = sitk.ImageSeriesReader()
dicom_names = reader.GetGDCMSeriesFileNames(
file_path.parent.as_posix())
reader.SetFileNames(dicom_names)
image = reader.Execute()
print("Spacing:", image.GetSpacing())
print("Origin:", image.GetOrigin())
print("Direction:", image.GetDirection())
image = sitk.DICOMOrient(image, 'LPS')
size = image.GetSize()
spacing = image.GetSpacing()
origin = image.GetOrigin()
direction = image.GetDirection()
print("Size:", size)
print("Spacing:", spacing)
print("Origin:", origin)
print("Direction:", direction)
if self.do_resample:
resampled_spacing = (1, 1, 1)
resampled_size = (
int(size[0]*spacing[0]),
int(size[1]*spacing[1]),
int(size[2]*spacing[2]),
)
print("Resampling...")
image = sitk.Resample(
image1=image,
size=resampled_size,
transform=sitk.Transform(),
interpolator=sitk.sitkLinear,
outputOrigin=origin,
outputSpacing=resampled_spacing,
outputDirection=direction,
defaultPixelValue=0,
outputPixelType=image.GetPixelID(),
)
print("Resampled Size:", resampled_size)
spacing = resampled_spacing
size = resampled_size
array = sitk.GetArrayFromImage(image)
# 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
# L (eft) i -> k -> x
# P (osterior) j -> j -> y
# S (uperior) k -> i -> z
array = np.transpose(array)
value_max = float(np.max(array))
value_min = float(np.min(array))
value_offset = 0.0
if value_min < 0:
value_offset = -value_min
array = array + np.full_like(array, value_offset)
value_max = float(np.max(array))
value_min = float(np.min(array))
print("offset max value:", value_max)
print("offset min value:", value_min)
# Build VDB
grid = vdb.FloatGrid()
grid.copyFromArray(array.copy())
grid.name = "density"
vdb_dirpath = Path(preferences.cache_dir, 'VDBs')
vdb_dirpath.mkdir(parents=True, exist_ok=True)
vdb_path = Path(vdb_dirpath, f"{uuid4()}.vdb")
vdb.write(vdb_path.as_posix(), grids=[grid])
# Read VDB
bpy.ops.object.volume_import(
filepath=vdb_path.as_posix(), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
vdb_obj = bpy.context.active_object
# Set props to VDB object
vdb_obj.name = name
vdb_obj.data.name = name
vdb_obj['value_max'] = value_max
vdb_obj['value_min'] = value_min
vdb_obj['value_offset'] = value_offset
vdb_obj['size'] = size
vdb_obj['spacing'] = spacing
vdb_obj['origin'] = origin
# Make transformation
# (S)uperior -Z -> Y
# (P)osterior -Y -> Z
axis_rot = axis_conversion(
from_forward='-Z',
from_up='-Y',
to_forward='Y',
to_up='Z'
).to_4x4()
mat_loc = mathutils.Matrix.Translation(
mathutils.Vector(origin) * self.global_scale)
mat_sca = mathutils.Matrix.Scale(self.global_scale, 4)
mat_rot = mathutils.Matrix(
np.array(direction).reshape((3, 3))
).to_4x4()
vdb_obj.matrix_world = axis_rot @ mat_loc @ mat_rot @ mat_sca
# Wrapper a Locator
bpy.ops.object.empty_add(
type='PLAIN_AXES',
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1)
)
loc_obj = bpy.context.active_object
loc_obj.name = f"LOC_{name}"
vdb_obj.parent = loc_obj
# Create MedNodes to VDB object
bpy.context.view_layer.objects.active = vdb_obj
bpy.ops.node.new_geometry_nodes_modifier()
modifier = vdb_obj.modifiers[0]
nodes = modifier.node_group.nodes
links = modifier.node_group.links
input_node = nodes.get("Group Input")
output_node = nodes.get("Group Output")
segment_node = custom_nodes.add_node(nodes, 'MedNodes_Segment')
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:
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.volume_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 64
return {'FINISHED'}
def MEDNODES_add_topbar_menu(self, context):
layout = self.layout
layout.operator(ImportDICOM.bl_idname)

View File

@@ -1,75 +0,0 @@
from .custom_nodes import CustomNodes
MENU_ITEMS = [
{
'label': 'Present',
'items': [
{
'label': 'Segment',
'node_type': 'MedNodes_Segment',
'node_description': '',
'node_driver': 'value_offset:Offset'
}
]
},
{
'label': 'Segment',
'items': [
{
'label': 'Segment by Level',
'node_type': 'MedNodes_SegmentByLevel',
'node_description': '',
'node_driver': 'value_offset:Offset'
},
{
'label': 'Segment by Range',
'node_type': 'MedNodes_SegmentByRange',
'node_description': '',
'node_driver': 'value_offset:Offset'
},
{
'label': 'Segment by Layers',
'node_type': 'MedNodes_SegmentsByLayers',
'node_description': '',
'node_driver': 'value_offset:Offset'
}
]
},
{
'label': 'Shader',
'items': [
{
'label': 'Segment Shader',
'node_type': 'MedNodes_SegmentShader',
'node_description': ''
}
]
},
{
'label': 'Slicer',
'items': [
{
'label': 'LPS Slicer',
'node_type': 'MedNodes_LPS-Slicer',
'node_description': '',
'node_driver': 'origin:Origin'
},
{
'label': 'Axis Slicer',
'node_type': 'MedNodes_AxisSlicer',
'node_description': '',
},
{
'label': 'Box Slicer',
'node_type': 'MedNodes_BoxSlicer',
'node_description': '',
}
]
},
]
custom_nodes = CustomNodes(
menu_items=MENU_ITEMS,
nodes_file="assets/Nodes.blend",
root_label='MedNodes'
)