first release
This commit is contained in:
57
.github/release-drafter.yml
vendored
Normal file
57
.github/release-drafter.yml
vendored
Normal 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
24
.github/workflows/release-drafter.yml
vendored
Normal 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
72
.github/workflows/upload-assets.yml
vendored
Normal 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
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# BioxelNodes
|
||||
BioxelNodes is a blender add-on for better volume data visualization with cycles.
|
||||
|
||||
@@ -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
BIN
bioxelnodes/assets/Nodes.blend
LFS
Normal file
Binary file not shown.
@@ -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
45
bioxelnodes/convert.py
Normal 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'}
|
||||
@@ -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.")
|
||||
@@ -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
474
bioxelnodes/io.py
Normal 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
33
bioxelnodes/menu.py
Normal 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
109
bioxelnodes/nodes.py
Normal 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",
|
||||
)
|
||||
@@ -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
111
bioxelnodes/utils.py
Normal 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)
|
||||
BIN
mednodes/assets/Nodes.blend
LFS
BIN
mednodes/assets/Nodes.blend
LFS
Binary file not shown.
@@ -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)
|
||||
@@ -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'
|
||||
)
|
||||
Reference in New Issue
Block a user