feat: vdb in RAS not LPS

This commit is contained in:
2024-04-17 22:57:31 +08:00
parent 28b787638c
commit 5b8d107392
6 changed files with 143 additions and 97 deletions

Binary file not shown.

View File

@@ -84,6 +84,7 @@ class CustomNodes():
op.node_label = item.get('label') or "" op.node_label = item.get('label') or ""
op.node_description = item.get( op.node_description = item.get(
'node_description') or "Add Custom Node." 'node_description') or "Add Custom Node."
op.node_link = item.get('link') or True
op.node_callback = item.get('node_callback') or "" op.node_callback = item.get('node_callback') or ""
menu_classes.append(Menu) menu_classes.append(Menu)
@@ -111,6 +112,7 @@ class CustomNodes():
op.nodes_file = self.nodes_file op.nodes_file = self.nodes_file
op.node_type = item['node_type'] op.node_type = item['node_type']
op.node_label = item.get('label') or "" op.node_label = item.get('label') or ""
op.node_link = item.get('link') or True
op.node_callback = item.get('node_callback') or "" op.node_callback = item.get('node_callback') or ""
return op.add_node(node_tree) return op.add_node(node_tree)
else: else:

View File

@@ -29,6 +29,11 @@ class AddCustomNode():
subtype="NONE" subtype="NONE"
) # type: ignore ) # type: ignore
node_link: bpy.props.BoolProperty(
name='node_link',
default=True
) # type: ignore
node_callback: bpy.props.StringProperty( node_callback: bpy.props.StringProperty(
name='node_callback', name='node_callback',
default='' default=''
@@ -50,7 +55,7 @@ class AddCustomNode():
return node return node
def get_node_tree(self, node_type): def get_node_tree(self, node_type, node_link):
# try to get node from current file if exists # try to get node from current file if exists
node_tree = bpy.data.node_groups.get(node_type) node_tree = bpy.data.node_groups.get(node_type)
# if not exists, get it from asset file. # if not exists, get it from asset file.
@@ -59,7 +64,7 @@ class AddCustomNode():
'EXEC_DEFAULT', 'EXEC_DEFAULT',
directory=f"{self.nodes_file}/NodeTree", directory=f"{self.nodes_file}/NodeTree",
filename=node_type, filename=node_type,
link=False, link=node_link,
use_recursive=True use_recursive=True
) )
@@ -81,10 +86,8 @@ class AddCustomNode():
... ...
def add_node(self, node_tree): def add_node(self, node_tree):
self.get_node_tree(self.node_type) self.get_node_tree(self.node_type, self.node_link)
node = node_tree.new("GeometryNodeGroup") node = node_tree.new("GeometryNodeGroup")
self.assign_node_tree(node) self.assign_node_tree(node)
return node return node
@@ -96,7 +99,7 @@ class CUSTOMNODES_OT_Add_Custom_Node(bpy.types.Operator, AddCustomNode):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
def execute(self, context): def execute(self, context):
self.get_node_tree(self.node_type) self.get_node_tree(self.node_type, self.node_link)
# intended to be called upon button press in the node tree # intended to be called upon button press in the node tree
prev_context = bpy.context.area.type prev_context = bpy.context.area.type

View File

@@ -9,7 +9,7 @@ import mathutils
from .utils import calc_bbox_verts, extract_last_number, get_bioxels_obj, show_message from .utils import calc_bbox_verts, extract_last_number, get_bioxels_obj, show_message
from .nodes import custom_nodes from .nodes import custom_nodes
SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp'] SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp', '', '.jpg']
def get_data_files(filepath: str): def get_data_files(filepath: str):
@@ -84,13 +84,6 @@ class ImportDICOMDialog(bpy.types.Operator):
default=(100, 100, 100) default=(100, 100, 100)
) # type: ignore ) # 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( bioxel_size: bpy.props.FloatProperty(
name="Bioxel Size", name="Bioxel Size",
soft_min=0.1, soft_max=10.0, soft_min=0.1, soft_max=10.0,
@@ -117,6 +110,13 @@ class ImportDICOMDialog(bpy.types.Operator):
options={'HIDDEN'} options={'HIDDEN'}
) # type: ignore ) # 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( do_add_segmentnode: bpy.props.BoolProperty(
name="Add Segment Node", name="Add Segment Node",
default=True, default=True,
@@ -178,25 +178,10 @@ class ImportDICOMDialog(bpy.types.Operator):
) )
try: try:
image = sitk.DICOMOrient(image, 'LPS') image = sitk.DICOMOrient(image, 'RAS')
except: except:
... ...
image_origin = image.GetOrigin()
bioxels_scale = float(self.bioxels_scale)
bioxel_size *= bioxels_scale
bioxels_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) array = sitk.GetArrayFromImage(image)
orig_dtype = str(array.dtype) orig_dtype = str(array.dtype)
print(f"Coverting Dtype {orig_dtype} -> float") print(f"Coverting Dtype {orig_dtype} -> float")
@@ -206,26 +191,51 @@ class ImportDICOMDialog(bpy.types.Operator):
# https://www.slicer.org/wiki/Coordinate_systems # https://www.slicer.org/wiki/Coordinate_systems
# ITK Numpy 3D # ITK Numpy 3D
# L (eft) i -> k -> x # R (ight) i -> k -> x
# P (osterior) j -> j -> y # A (nterior) j -> j -> y
# S (uperior) k -> i -> z # S (uperior) k -> i -> z
array = np.transpose(array) array = np.transpose(array)
value_max = float(np.max(array)) bioxels_max = float(np.max(array))
value_min = float(np.min(array)) bioxels_min = float(np.min(array))
value_offset = 0.0 bioxels_offset = 0.0
if value_min < 0 and orig_dtype[0] != "u": if bioxels_min < 0 and orig_dtype[0] != "u":
value_offset = -value_min bioxels_offset = -bioxels_min
array = array + np.full_like(array, value_offset) array = array + np.full_like(array, bioxels_offset)
value_max = float(np.max(array)) bioxels_max = float(np.max(array))
value_min = float(np.min(array)) bioxels_min = float(np.min(array))
print("Offseted Max:", value_max) print("Offseted Max:", bioxels_max)
print("Offseted Min:", value_min) print("Offseted Min:", bioxels_min)
# Build VDB # Build VDB
grid = vdb.FloatGrid() grid = vdb.FloatGrid()
grid.copyFromArray(array.copy()) 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
grid.transform = vdb.createLinearTransform(transfrom.transposed())
grid.name = "value" grid.name = "value"
preferences = context.preferences.addons[__package__].preferences preferences = context.preferences.addons[__package__].preferences
@@ -244,28 +254,26 @@ class ImportDICOMDialog(bpy.types.Operator):
bioxels_obj = bpy.context.active_object bioxels_obj = bpy.context.active_object
# Set props to VDB object # Set props to VDB object
bioxels_obj.name = f"{name}_Values" bioxels_obj.name = f"{name}_Bioxels"
bioxels_obj.data.name = f"{name}_Values" bioxels_obj.data.name = f"{name}_Bioxels"
# Make transformation # Make transformation
scene_scale = float(self.scene_scale)
# (S)uperior -Z -> Y # (S)uperior -Z -> Y
# (P)osterior -Y -> Z # (A)osterior Y -> Z
axis_rot = axis_conversion( mat_ras2blender = axis_conversion(
from_forward='-Z', from_forward='-Z',
from_up='-Y', from_up='Y',
to_forward='Y', to_forward='Y',
to_up='Z' to_up='Z'
).to_4x4() ).to_4x4()
mat_loc = mathutils.Matrix.Translation( mat_scene_scale = mathutils.Matrix.Scale(
mathutils.Vector(bioxels_origin)) scene_scale, 4
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.matrix_world = mat_ras2blender @ mat_scene_scale
bioxels_obj.lock_location[0] = True bioxels_obj.lock_location[0] = True
bioxels_obj.lock_location[1] = True bioxels_obj.lock_location[1] = True
@@ -277,16 +285,7 @@ class ImportDICOMDialog(bpy.types.Operator):
bioxels_obj.lock_scale[1] = True bioxels_obj.lock_scale[1] = True
bioxels_obj.lock_scale[2] = True bioxels_obj.lock_scale[2] = True
bioxels_obj.hide_select = True bioxels_obj.hide_select = True
bioxels_obj['value_max'] = value_max bioxels_obj['bioxels'] = True
bioxels_obj['value_min'] = value_min
bioxels_obj['value_offset'] = value_offset
bioxels_obj['image_origin'] = mathutils.Vector(
bioxels_origin) / bioxel_size
bioxels_obj['bioxel_size'] = bioxel_size
bioxels_obj['bioxels_shape'] = bioxels_shape
bioxels_obj['bioxels_size'] = bioxels_size
bioxels_obj['bioxels_origin'] = bioxels_origin
bioxels_obj['bioxels_values'] = True
bpy.ops.node.new_geometry_nodes_modifier() bpy.ops.node.new_geometry_nodes_modifier()
node_tree = bioxels_obj.modifiers[0].node_group node_tree = bioxels_obj.modifiers[0].node_group
@@ -296,14 +295,16 @@ class ImportDICOMDialog(bpy.types.Operator):
enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1) enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
) )
container_obj = bpy.context.active_object container_obj = bpy.context.active_object
bbox_verts = calc_bbox_verts((0, 0, 0), bioxels_shape)
bbox_verts = calc_bbox_verts(bioxels_origin, bioxels_size)
for index, vert in enumerate(container_obj.data.vertices): for index, vert in enumerate(container_obj.data.vertices):
vert.co = bbox_verts[index] co = transfrom @ mathutils.Vector(bbox_verts[index])
co = mat_ras2blender @ co
co = mat_scene_scale @ co
vert.co = co
bioxels_obj.parent = container_obj bioxels_obj.parent = container_obj
container_obj.name = name container_obj.name = name
container_obj.data.name = f"{name}_Container" container_obj.data.name = name
container_obj.visible_camera = False container_obj.visible_camera = False
container_obj.visible_diffuse = False container_obj.visible_diffuse = False
container_obj.visible_glossy = False container_obj.visible_glossy = False
@@ -311,7 +312,13 @@ class ImportDICOMDialog(bpy.types.Operator):
container_obj.visible_volume_scatter = False container_obj.visible_volume_scatter = False
container_obj.visible_shadow = False container_obj.visible_shadow = False
container_obj.display_type = 'WIRE' container_obj.display_type = 'WIRE'
container_obj['bioxels'] = True container_obj['bioxels_container'] = True
container_obj['scene_scale'] = scene_scale
container_obj['bioxel_size'] = bioxel_size
container_obj['bioxels_max'] = bioxels_max
container_obj['bioxels_min'] = bioxels_min
container_obj['bioxels_offset'] = bioxels_offset
container_obj['bioxels_shape'] = bioxels_shape
bpy.ops.object.modifier_add(type='NODES') bpy.ops.object.modifier_add(type='NODES')
container_obj.modifiers[0].node_group = node_tree container_obj.modifiers[0].node_group = node_tree
@@ -440,8 +447,8 @@ class BIOXELNODES_FH_ImportDicom(bpy.types.FileHandler):
class ExportVDB(bpy.types.Operator): class ExportVDB(bpy.types.Operator):
bl_idname = "bioxelnodes.export_vdb" bl_idname = "bioxelnodes.export_vdb"
bl_label = "Biovels as VDB" bl_label = "Bioxels as VDB"
bl_description = "Export Biovels original VDB data." bl_description = "Export Bioxels original VDB data."
bl_options = {'UNDO'} bl_options = {'UNDO'}
filepath: bpy.props.StringProperty( filepath: bpy.props.StringProperty(
@@ -451,13 +458,12 @@ class ExportVDB(bpy.types.Operator):
filename_ext = ".vdb" filename_ext = ".vdb"
def invoke(self, context, event): def invoke(self, context, event):
bioxels_objs = [] bioxels_obj = None
for obj in bpy.context.selected_objects: for obj in bpy.context.selected_objects:
bioxels_obj = get_bioxels_obj(obj) bioxels_obj = get_bioxels_obj(obj)
if bioxels_obj: break
bioxels_objs.append(bioxels_obj)
if len(bioxels_objs) > 0: if bioxels_obj:
context.window_manager.fileselect_add(self) context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
else: else:
@@ -465,14 +471,13 @@ class ExportVDB(bpy.types.Operator):
return {'CANCELLED'} return {'CANCELLED'}
def execute(self, context): def execute(self, context):
bioxels_objs = [] bioxels_obj = None
for obj in bpy.context.selected_objects: for obj in bpy.context.selected_objects:
bioxels_obj = get_bioxels_obj(obj) bioxels_obj = get_bioxels_obj(obj)
if bioxels_obj: break
bioxels_objs.append(bioxels_obj)
bioxels_obj = bioxels_objs[0] filepath = f"{self.filepath.split('.')[0]}.vdb"
output_path: Path = Path(self.filepath).resolve() output_path: Path = Path(filepath).resolve()
source_path: Path = Path(bioxels_obj.data.filepath).resolve() source_path: Path = Path(bioxels_obj.data.filepath).resolve()
# print('output_path', output_path) # print('output_path', output_path)
# print('source_path', source_path) # print('source_path', source_path)

View File

@@ -9,11 +9,12 @@ def add_driver_to_node_factory(source_prop, target_prop):
import bpy import bpy
from bioxelnodes.utils import add_direct_driver, get_bioxels_obj from bioxelnodes.utils import add_direct_driver, get_bioxels_obj
bioxels_obj = get_bioxels_obj(bpy.context.active_object) bioxels_obj = get_bioxels_obj(bpy.context.active_object)
container_obj = bioxels_obj.parent
if bioxels_obj: if bioxels_obj:
add_direct_driver( add_direct_driver(
target=node, target=node,
target_prop='{target_prop}', target_prop='{target_prop}',
source=bioxels_obj, source=container_obj,
source_prop='{source_prop}' source_prop='{source_prop}'
) )
else: else:
@@ -22,6 +23,20 @@ else:
return callback_str return callback_str
def set_prop_to_node_factory(source_prop, target_prop):
callback_str = f"""
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}')
else:
print('Cannot find any bioxels.')
"""
return callback_str
MENU_ITEMS = [ MENU_ITEMS = [
{ {
'label': 'Present', 'label': 'Present',
@@ -32,7 +47,20 @@ MENU_ITEMS = [
'icon': 'OUTLINER_OB_META', 'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_Segment', 'node_type': 'BioxelNodes_Segment',
'node_description': '', 'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset") '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"),
])
} }
] ]
}, },
@@ -45,21 +73,30 @@ MENU_ITEMS = [
'icon': 'AREA_JOIN', 'icon': 'AREA_JOIN',
'node_type': 'BioxelNodes_SegmentByLevel', 'node_type': 'BioxelNodes_SegmentByLevel',
'node_description': '', 'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset") 'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
}, },
{ {
'label': 'Segment by Range', 'label': 'Segment by Range',
'icon': 'AREA_SWAP', 'icon': 'AREA_SWAP',
'node_type': 'BioxelNodes_SegmentByRange', 'node_type': 'BioxelNodes_SegmentByRange',
'node_description': '', 'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset") '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', 'label': 'Segment by Layers',
'icon': 'OUTLINER_OB_FORCE_FIELD', 'icon': 'OUTLINER_OB_FORCE_FIELD',
'node_type': 'BioxelNodes_SegmentsByLayers', 'node_type': 'BioxelNodes_SegmentsByLayers',
'node_description': '', 'node_description': '',
'node_callback': add_driver_to_node_factory("value_offset", "Offset") 'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
} }
] ]
}, },
@@ -80,11 +117,10 @@ MENU_ITEMS = [
'icon': 'CLIPUV_DEHLT', 'icon': 'CLIPUV_DEHLT',
'items': [ 'items': [
{ {
'label': 'LPS Slicer', 'label': 'RAS Slicer',
'icon': 'EMPTY_DATA', 'icon': 'EMPTY_DATA',
'node_type': 'BioxelNodes_LPS-Slicer', 'node_type': 'BioxelNodes_RAS-Slicer',
'node_description': '', 'node_description': ''
'node_callback': add_driver_to_node_factory("image_origin", "Origin")
}, },
{ {
'label': 'Axis Slicer', 'label': 'Axis Slicer',

View File

@@ -12,9 +12,9 @@ def show_message(message="", title="Message Box", icon='INFO'):
def calc_bbox_verts(origin, size): def calc_bbox_verts(origin, size):
bbox_origin = mathutils.Vector( bbox_origin = mathutils.Vector(
(-origin[0], -origin[2], -origin[1])) (origin[0], origin[1], origin[2]))
bbox_size = mathutils.Vector( bbox_size = mathutils.Vector(
(-size[0], -size[2], -size[1])) (size[0], size[1], size[2]))
bbox_verts = [ bbox_verts = [
( (
bbox_origin[0] + 0, bbox_origin[0] + 0,
@@ -62,17 +62,17 @@ def calc_bbox_verts(origin, size):
def get_bioxels_obj(current_obj): def get_bioxels_obj(current_obj):
bioxels_obj = None bioxels_obj = None
if current_obj.get('bioxels'): if current_obj.get('bioxels_container'):
bioxels_values = [] bioxels_values = []
for obj in bpy.data.objects: for obj in bpy.data.objects:
if obj.parent == current_obj and obj.get('bioxels_values'): if obj.parent == current_obj and obj.get('bioxels'):
bioxels_values.append(obj) bioxels_values.append(obj)
if len(bioxels_values) > 0: if len(bioxels_values) > 0:
bioxels_obj = bioxels_values[0] bioxels_obj = bioxels_values[0]
elif current_obj.get('bioxels_values') and current_obj.parent: elif current_obj.get('bioxels') and current_obj.parent:
if current_obj.parent.get('bioxels'): if current_obj.parent.get('bioxels_container'):
bioxels_obj = current_obj bioxels_obj = current_obj
return bioxels_obj return bioxels_obj