feat: add more format support

This commit is contained in:
2024-05-11 10:43:00 +08:00
parent eeca79e681
commit 1990a16972
18 changed files with 671 additions and 238 deletions

View File

@@ -2,14 +2,13 @@ import bpy
from .nodes import custom_nodes
from . import auto_load
from . import menu
bl_info = {
"name": "Bioxel Nodes",
"author": "Ma Nan",
"description": "",
"blender": (4, 1, 0),
"blender": (4, 0, 0),
"version": (0, 1, 0),
"location": "File -> Import",
"warning": "",
@@ -18,23 +17,18 @@ bl_info = {
auto_load.init()
bpy.types.Scene.bioxels_dir = bpy.props.StringProperty(
name="Bioxels Directory",
subtype='DIR_PATH',
default="//"
)
def register():
auto_load.register()
custom_nodes.register()
menu.create_menu()
bpy.types.Scene.bioxels_dir = bpy.props.StringProperty(
name="Bioxels Directory",
subtype='DIR_PATH',
default="//"
)
def unregister():
try:
menu.remove_menu()
custom_nodes.unregister()
auto_load.unregister()
except RuntimeError:
pass

Binary file not shown.

View File

@@ -134,7 +134,7 @@ def get_register_base_types():
"Node", "NodeSocket", "NodeTree",
"UIList", "RenderEngine",
"Gizmo", "GizmoGroup",
])
] if hasattr(bpy.types, name))
# Find order to register to solve dependencies

View File

@@ -5,7 +5,7 @@ from .utils import get_bioxels_obj, get_node_by_type
class ConvertToMesh(bpy.types.Operator):
bl_idname = "bioxelnodes.convert_to_mesh"
bl_label = "Bioxels To Mesh"
bl_description = "Convert Bioxels To Mesh."
bl_description = "Convert Bioxels To Mesh"
bl_options = {'UNDO'}
@classmethod

View File

@@ -1 +1 @@
from .menu import CustomNodes
from .menus import CustomNodes

View File

@@ -3,12 +3,6 @@ import bpy
from pathlib import Path
from .nodes import AddCustomNode
bpy.types.Scene.custom_nodes_dir = bpy.props.StringProperty(
name="Nodes Directory",
subtype='DIR_PATH',
default="//"
)
class SaveAllNodes(bpy.types.Operator):
bl_idname = "customnodes.save_all_nodes"
@@ -182,12 +176,17 @@ class CustomNodes():
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(self.add_node_menu)
bpy.types.Scene.custom_nodes_dir = bpy.props.StringProperty(
name="Nodes Directory",
subtype='DIR_PATH',
default="//"
)
def unregister(self):
bpy.types.NODE_MT_add.remove(self.add_node_menu)
try:
for cls in reversed(self.menu_classes):
bpy.utils.unregister_class(cls)
bpy.types.NODE_MT_add.remove(self.add_node_menu)
except RuntimeError:
pass

View File

@@ -1 +1,2 @@
from .preferences import ExternalPackagePreferences
from .preferences import ExternalPackagePreferences
from .package import PackageInstaller

View File

@@ -42,12 +42,13 @@ class InstallationError(Exception):
class PackageInstaller():
def __init__(
self,
log_dir: str,
requirements_dir: str,
log_dir: str = None,
pypi_mirror_provider='Default',
) -> None:
self.pypi_mirror_provider = pypi_mirror_provider
self.log_path: Path = Path(log_dir)
self.log_path: Path = Path(log_dir) if log_dir \
else Path.home() / '.externalpackage' / 'logs'
self.requirements_path: Path = Path(requirements_dir)
def start_logging(self, logfile_name: str = 'side-packages-install') -> logging.Logger:
@@ -250,7 +251,7 @@ class PackageInstaller():
if result.returncode != 0:
log.error('Command failed: %s', cmd_list)
log.error('stdout: %s', result.stdout.decode())
log.error('stderr: %s', result.stderr.decode())
log.error('stderr: %s', result.stderr.decode(errors='ignore'))
else:
log.info('Command succeeded: %s', cmd_list)
log.info('stdout: %s', result.stdout.decode())
@@ -350,6 +351,7 @@ class EXTERNALPACKAGE_OT_Install_Package(bpy.types.Operator):
name='Python Package',
description='Python Package to Install'
) # type: ignore
version: bpy.props.StringProperty(
name='Python Package',
description='Python Package to Install'

View File

@@ -1,4 +1,5 @@
import bpy
import subprocess
from pathlib import Path
from .package import PackageInstaller, PYPI_MIRROR
@@ -10,6 +11,25 @@ def get_pypi_mirrors(self, context, edit_text):
return PYPI_MIRROR.keys()
class RebootBlender(bpy.types.Operator):
bl_idname = "externalpackage.reboot_blender"
bl_label = "Reboot Blender"
bl_description = "Reboot Blender"
bl_options = {'UNDO'}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self, event)
return {'RUNNING_MODAL'}
def execute(self, context):
blender_launcher = Path(
bpy.app.binary_path).parent / "blender-launcher.exe"
subprocess.run([str(blender_launcher), "-con", "--python-expr",
"import bpy; bpy.ops.wm.recover_last_session()"])
bpy.ops.wm.quit_blender()
return {'FINISHED'}
class ExternalPackagePreferences():
requirements_dir: bpy.props.StringProperty(
name="requirements.txt Directory",
@@ -34,11 +54,12 @@ class ExternalPackagePreferences():
def draw(self, context):
layout = self.layout
layout.label(text="Install the required packages.")
layout.label(
text='**Install the required packages, then hit "Reboot Blender"**')
col_main = layout.column(heading='', align=False)
row_import = col_main.row()
row_import.prop(self, 'pypi_mirror_provider', text='Set PyPI Mirror')
row_import.prop(self, 'pypi_mirror_provider', text='PyPI Mirror')
installer = PackageInstaller(
pypi_mirror_provider=self.pypi_mirror_provider,
@@ -70,3 +91,5 @@ class ExternalPackagePreferences():
op.package = name
op.version = version
op.description = f'Install required python package: {name}'
layout.operator(RebootBlender.bl_idname)

View File

@@ -6,71 +6,114 @@ import numpy as np
from pathlib import Path
from uuid import uuid4
import mathutils
from .utils import calc_bbox_verts, extract_last_number, get_bioxels_obj, get_node_by_type, show_message
import math
from .utils import calc_bbox_verts, get_text_index, get_bioxels_obj, get_node_by_type, show_message
from .nodes import custom_nodes
from .props import BIOXELNODES_Series
try:
import SimpleITK as sitk
except:
...
SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp', '', '.jpg']
SUPPORT_EXTS = ['.dcm', '.DICOM',
'.bmp', '.BMP',
'.PIC', '.pic',
'.gipl', '.gipl.gz',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.lsm', '.LSM',
'.tif', '.TIF', '.tiff', '.TIFF',
'.mnc', '.MNC',
'.mrc', '.rec',
'.mha', '.mhd',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.nrrd', '.nhdr',
'.png', '.PNG',
'.vtk']
SEQUENCE_EXTS = ['.dcm', '.DICOM',
'.bmp', '.BMP',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.tif', '.TIF', '.tiff', '.TIFF',
'.png', '.PNG']
DICOM_EXTS = ['.dcm', '.DICOM']
FH_EXTS = ['.dcm', '.DICOM',
'.gipl', '.gipl.gz',
'.mnc', '.MNC',
'.mrc', '.rec',
'.mha', '.mhd',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nrrd', '.nhdr',
'.vtk',
'.gz']
def get_data_files(filepath: str):
def get_bioxels_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple):
return (
math.ceil(orig_shape[0] / bioxel_size * orig_spacing[0]),
math.ceil(orig_shape[1] / bioxel_size * orig_spacing[1]),
math.ceil(orig_shape[2] / bioxel_size * orig_spacing[2]),
)
def collect_image_sequence(filepath: str):
file_path = Path(filepath).resolve()
suffix = file_path.suffix
files = list(file_path.parent.iterdir())
files = [f for f in files if f.is_file() and f.suffix == suffix]
files = [f for f in files if f.is_file()
and get_ext(file_path) == get_ext(f)
and get_text_index(f.stem)]
def last_number(file: Path):
return extract_last_number(file.stem)
def get_index(f: Path):
return get_text_index(f.stem)
files.sort(key=last_number)
files = [str(f) for f in files]
return files
files.sort(key=get_index)
sequence = [str(f) for f in files]
return sequence
def on_orig_spacing_changed(self, context):
if self.auto:
self.auto = False
new_orig_spacing = tuple(self.orig_spacing)
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]),
)
def get_ext(filepath: str):
file_path = Path(filepath)
if file_path.name.endswith(".nii.gz"):
return ".nii.gz"
elif file_path.name.endswith(".img.gz"):
return ".img.gz"
elif file_path.name.endswith(".gipl.gz"):
return ".gipl.gz"
else:
self.auto = True
return file_path.suffix
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]),
)
def read_image(filepath: str, series_id=""):
ext = get_ext(filepath)
dir_path = Path(filepath).resolve().parent
filename = Path(filepath).resolve().stem
if ext in DICOM_EXTS:
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_files = reader.GetGDCMSeriesFileNames(
str(dir_path), series_id)
reader.SetFileNames(series_files)
image = reader.Execute()
elif ext in SEQUENCE_EXTS and get_text_index(filename):
sequence = collect_image_sequence(filepath)
image = sitk.ReadImage(sequence)
else:
self.auto = True
image = sitk.ReadImage(filepath)
if image.GetDimension() == 2:
image = sitk.ReadImage([filepath])
return image
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"
class ImportImageDialog(bpy.types.Operator):
bl_idname = "bioxelnodes.import_image_dialog"
bl_label = "Volume Data as Bioxels"
bl_description = "Import Volume Data as Bioxels (VDB)."
bl_description = "Import Volume Data as Bioxels (VDB)"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
@@ -78,64 +121,62 @@ class ImportDICOMDialog(bpy.types.Operator):
options={'HIDDEN'}
) # type: ignore
bioxels_shape: bpy.props.IntVectorProperty(
name="Bioxels Shape (ReadOnly)",
min=0,
default=(100, 100, 100)
series_id: bpy.props.StringProperty() # type: ignore
read_as: bpy.props.EnumProperty(
name="Read as",
default="scalar",
items=[("scalar", "Scalar", ""), ("labels", "Labels", "")]
) # type: ignore
label_index: bpy.props.IntProperty(
name="Label Index",
default=1
) # type: ignore
bioxel_size: bpy.props.FloatProperty(
name="Bioxel Size",
name="Bioxel Size (Larger size means small resolution)",
soft_min=0.1, soft_max=10.0,
min=1e-2, max=1e2,
default=1,
update=on_bioxel_size_changed
) # type: ignore
orig_spacing: bpy.props.FloatVectorProperty(
name="Original Spacing",
default=(1, 1, 1),
update=on_orig_spacing_changed
) # type: ignore
orig_shape: bpy.props.IntVectorProperty(
name="Original Shape",
default=(100, 100, 100),
options={'HIDDEN'}
) # type: ignore
auto: bpy.props.BoolProperty(
name="Auto Setting",
default=False,
options={'HIDDEN'}
default=(100, 100, 100)
) # type: ignore
scene_scale: bpy.props.FloatProperty(
name="Scene Scale",
name="Scene Scale (Bioxels Unit pre Blender Unit)",
soft_min=0.001, soft_max=100.0,
min=1e-6, max=1e6,
default=0.01,
) # type: ignore
do_add_segmentnode: bpy.props.BoolProperty(
name="Add Segment Node",
do_orient: bpy.props.BoolProperty(
name="Orient to RAS",
default=True,
) # type: ignore
do_change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting",
default=True,
invert_scalar: bpy.props.BoolProperty(
name="Invert Scalar (Background value maybe higher than object)",
default=False,
) # type: ignore
def execute(self, context):
files = get_data_files(self.filepath)
name = Path(self.filepath).parent.name
bioxels_name = Path(self.filepath).parent.name \
if get_ext(self.filepath) in SEQUENCE_EXTS \
else Path(self.filepath).name.removesuffix(get_ext(self.filepath))
import SimpleITK as sitk
image = sitk.ReadImage(files)
image = read_image(self.filepath, self.series_id)
bioxel_size = float(self.bioxel_size)
orig_spacing = tuple(self.orig_spacing)
bioxel_size = self.bioxel_size
orig_spacing = self.orig_spacing
image_spacing = image.GetSpacing()
image_shape = image.GetSize()
@@ -145,11 +186,8 @@ class ImportDICOMDialog(bpy.types.Operator):
image_spacing[2] / orig_spacing[2] * bioxel_size
)
bioxels_shape = (
int(image_shape[0] / bioxel_size * orig_spacing[0]),
int(image_shape[1] / bioxel_size * orig_spacing[1]),
int(image_shape[2] / bioxel_size * orig_spacing[2]),
)
bioxels_shape = get_bioxels_shape(
bioxel_size, image_shape, orig_spacing)
print("Resampling...")
image = sitk.Resample(
@@ -164,13 +202,14 @@ class ImportDICOMDialog(bpy.types.Operator):
outputPixelType=image.GetPixelID(),
)
print("Orienting to RAS...")
image = sitk.DICOMOrient(image, 'RAS')
if self.do_orient:
print("Orienting to RAS...")
image = sitk.DICOMOrient(image, 'RAS')
array = sitk.GetArrayFromImage(image)
orig_dtype = str(array.dtype)
print(f"Coverting Dtype from {orig_dtype} to float...")
array = array.astype(float)
print("Oriented Origin:", image.GetOrigin())
print("Oriented Direction:", image.GetDirection())
# return {'FINISHED'}
# ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i]
# https://www.slicer.org/wiki/Coordinate_systems
@@ -179,27 +218,62 @@ class ImportDICOMDialog(bpy.types.Operator):
# R (ight) i -> k -> x
# A (nterior) j -> j -> y
# S (uperior) k -> i -> z
array = sitk.GetArrayFromImage(image)
array = np.transpose(array)
bioxels_max = float(np.max(array))
bioxels_min = float(np.min(array))
orig_dtype = str(array.dtype)
# print(f"Coverting Dtype from {orig_dtype} to float...")
# array = array.astype(float)
if array.ndim == 4:
if array.shape[3] == 3 and self.read_as == "scalar":
# RGB -> Grayscale
array = np.dot(array[..., :3], [0.2989, 0.5870, 0.1140])
else:
array = array[:, :, :, 0]
bioxels_shape = array.shape
print("Bioxel Size:", bioxel_size)
print("Bioxels Shape:", bioxels_shape)
bioxels_offset = 0.0
if bioxels_min < 0 and orig_dtype[0] != "u":
bioxels_offset = -bioxels_min
array = array + np.full_like(array, bioxels_offset)
if self.read_as == "labels":
array = array == np.full_like(array, self.label_index)
bioxels_default_threshold = 1.0
bioxels_max = 1.0
bioxels_min = 0.0
bioxels_name = f"{bioxels_name}_{self.label_index}"
else:
if self.invert_scalar:
array = -array
orig_max = float(np.max(array))
orig_min = float(np.min(array))
orig_median = float(np.median(array))
orig_percentile80 = float(np.percentile(array, 80)) \
if self.invert_scalar else float(np.percentile(array, 80))
# if (orig_dtype[0] != "u" and orig_min < 0) \
# or (orig_dtype[0] == "u" and self.invert_scalar):
if orig_min < 0:
bioxels_offset = -orig_min
array = array + np.full_like(array, bioxels_offset)
bioxels_default_threshold = orig_percentile80
bioxels_max = float(np.max(array))
bioxels_min = float(np.min(array))
print("Offseted Max:", bioxels_max)
print("Offseted Min:", bioxels_min)
# Build VDB
grid = vdb.FloatGrid()
grid.copyFromArray(array.copy())
stat_table = [("Max", orig_max),
("Min", orig_min),
("Median", orig_median),
("Percentile 80%", orig_percentile80),
("Max (Offset)", bioxels_max),
("Min (Offset)", bioxels_min)]
print("Bioxels Value Stat: Max:")
for stat in stat_table:
print("| {: >20} | {: >20} |".format(*stat))
# # Build VDB
grid = vdb.BoolGrid() if self.read_as == "labels" else vdb.FloatGrid()
grid.copyFromArray(array.copy().astype(float))
# After sitk.DICOMOrient(), origin and direction will also orient base on LPS
# so we need to convert them into RAS
@@ -210,22 +284,26 @@ class ImportDICOMDialog(bpy.types.Operator):
to_up='Y'
).to_4x4()
origin = image.GetOrigin()
direction = image.GetDirection()
mat_location = mathutils.Matrix.Translation(
mathutils.Vector(image.GetOrigin())
mathutils.Vector(origin)
)
mat_rotation = mathutils.Matrix(
np.array(image.GetDirection()).reshape((3, 3))
np.array(direction).reshape((3, 3))
).to_4x4()
mat_scale = mathutils.Matrix.Scale(
bioxel_size, 4
)
transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale
transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale \
if self.do_orient else mat_location @ mat_rotation @ mat_scale
grid.transform = vdb.createLinearTransform(transfrom.transposed())
grid.name = "value"
grid.name = "scalar"
preferences = context.preferences.addons[__package__].preferences
vdb_dirpath = Path(preferences.cache_dir, 'VDBs')
@@ -243,8 +321,8 @@ class ImportDICOMDialog(bpy.types.Operator):
bioxels_obj = bpy.context.active_object
# Set props to VDB object
bioxels_obj.name = f"{name}_Bioxels"
bioxels_obj.data.name = f"{name}_Bioxels"
bioxels_obj.name = f"{bioxels_name}_Bioxels"
bioxels_obj.data.name = f"{bioxels_name}_Bioxels"
# Make transformation
scene_scale = float(self.scene_scale)
@@ -289,8 +367,8 @@ class ImportDICOMDialog(bpy.types.Operator):
vert.co = bbox_transform @ mathutils.Vector(bbox_verts[index])
bioxels_obj.parent = container_obj
container_obj.name = name
container_obj.data.name = name
container_obj.name = bioxels_name
container_obj.data.name = bioxels_name
container_obj.visible_camera = False
container_obj.visible_diffuse = False
container_obj.visible_glossy = False
@@ -315,7 +393,7 @@ class ImportDICOMDialog(bpy.types.Operator):
# Create BioxelNodes to VDB object
print(f"Creating BioxelNodes to the VDB...")
if self.do_add_segmentnode:
if preferences.do_add_segmentnode:
nodes = node_tree.nodes
links = node_tree.links
@@ -324,11 +402,14 @@ class ImportDICOMDialog(bpy.types.Operator):
segment_node = custom_nodes.add_node(
nodes, 'BioxelNodes_Segment')
segment_node.inputs['Threshold'].default_value = bioxels_default_threshold
links.new(input_node.outputs[0], segment_node.inputs[0])
links.new(segment_node.outputs[0], output_node.inputs[0])
# Change render setting for better result
if self.do_change_render_setting:
if preferences.do_change_render_setting:
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.volume_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 64
@@ -338,37 +419,78 @@ class ImportDICOMDialog(bpy.types.Operator):
return {'FINISHED'}
def invoke(self, context, event):
# print(tuple(self.orig_shape))
self.auto = True
self.bioxel_size = 1
context.window_manager.invoke_props_dialog(self)
min_size = min(
self.orig_spacing[0], self.orig_spacing[1], self.orig_spacing[2])
default_size = 1.0
self.bioxel_size = min_size if min_size > default_size else default_size
context.window_manager.invoke_props_dialog(self, width=400)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
bioxels_shape = get_bioxels_shape(
self.bioxel_size, self.orig_shape, self.orig_spacing)
bioxel_count = bioxels_shape[0] * bioxels_shape[1] * bioxels_shape[2]
text = f"Shape will be: {str(bioxels_shape)} {bioxel_count:,} "
if bioxel_count > 100000000:
text += "**TOO LARGE!**"
panel = layout.box()
panel.prop(self, "bioxel_size")
row = panel.row()
row.prop(self, "orig_spacing")
panel.label(text=text)
panel = layout.box()
panel.prop(self, "read_as")
if self.read_as == "labels":
panel.prop(self, "label_index")
else:
panel.prop(self, "invert_scalar")
panel = layout.box()
panel.prop(self, "scene_scale")
panel.prop(self, "do_orient")
def get_series_ids(self, context):
items = []
for index, series_id in enumerate(self.series_ids):
items.append((
series_id.id,
series_id.label,
"",
index
))
return items
class ReadDICOM(bpy.types.Operator):
bl_idname = "bioxelnodes.read_dicom"
bl_label = "Volume Data as Bioxels"
bl_description = "Import Volume Data as Bioxels (VDB)."
bl_description = "Import Volume Data as Bioxels (VDB)"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
ok: bpy.props.BoolProperty(
default=True
series_id: bpy.props.EnumProperty(
name="Select Series",
items=get_series_ids
) # type: ignore
series_ids: bpy.props.CollectionProperty(
type=BIOXELNODES_Series) # type: ignore
def execute(self, context):
if Path(self.filepath).suffix not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not Supported extension.")
ext = get_ext(self.filepath)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported extension.")
return {'CANCELLED'}
files = get_data_files(self.filepath)
import SimpleITK as sitk
image = sitk.ReadImage(files)
image = read_image(self.filepath, self.series_id)
print("Collecting Meta Data...")
print("Original Shape:", image.GetSize())
@@ -376,11 +498,12 @@ class ReadDICOM(bpy.types.Operator):
print("Original Origin:", image.GetOrigin())
print("Original Direction:", image.GetDirection())
bpy.ops.bioxelnodes.import_dicom_dialog(
bpy.ops.bioxelnodes.import_image_dialog(
'INVOKE_DEFAULT',
filepath=self.filepath,
orig_shape=image.GetSize(),
orig_spacing=image.GetSpacing()
orig_spacing=image.GetSpacing(),
series_id=self.series_id or ""
)
self.report({"INFO"}, "Successfully Readed.")
@@ -395,16 +518,43 @@ class ReadDICOM(bpy.types.Operator):
if not self.filepath:
return {'CANCELLED'}
show_message('Reading image data, it may take a while',
show_message('Reading image data, it may take a while...',
'Please be patient...')
if get_ext(self.filepath) == '.dcm':
dir_path = Path(self.filepath).parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_ids = reader.GetGDCMSeriesIDs(str(dir_path))
for _id in series_ids:
series_id = self.series_ids.add()
series_id.id = _id
series_id.label = _id
if len(series_ids) > 1:
context.window_manager.invoke_props_dialog(self, width=400)
return {'RUNNING_MODAL'}
else:
self.series_id = series_ids[0]
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
layout.prop(self, "series_id")
layout.label(
text="Reading image data, it may take a while...")
layout.label(
text="Please be patient...")
class ImportDICOM(bpy.types.Operator):
bl_idname = "bioxelnodes.import_dicom"
class ImportImage(bpy.types.Operator):
bl_idname = "bioxelnodes.import_image"
bl_label = "Volume Data as Bioxels"
bl_description = "Import Volume Data as Bioxels (VDB)."
bl_description = "Import Volume Data as Bioxels (VDB)"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
@@ -423,21 +573,24 @@ class ImportDICOM(bpy.types.Operator):
return {'RUNNING_MODAL'}
class BIOXELNODES_FH_ImportDicom(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportDicom"
bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.read_dicom"
bl_file_extensions = ".dcm"
try:
class BIOXELNODES_FH_ImportImage(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportImage"
bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.read_dicom"
bl_file_extensions = ";".join(FH_EXTS)
@classmethod
def poll_drop(cls, context):
return (context.area and context.area.type == 'VIEW_3D')
@classmethod
def poll_drop(cls, context):
return (context.area and context.area.type == 'VIEW_3D')
except:
...
class ExportVDB(bpy.types.Operator):
bl_idname = "bioxelnodes.export_vdb"
bl_label = "Bioxels as VDB"
bl_description = "Export Bioxels original VDB data."
bl_description = "Export Bioxels original VDB data"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(

221
bioxelnodes/io_points.py Normal file
View File

@@ -0,0 +1,221 @@
# TODO: WIP
import bpy
import bmesh
def create_points_obj(points, name="points"):
bpy.ops.mesh.primitive_cube_add(
enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
)
obj = bpy.context.active_object
mesh = obj.data
bm = bmesh.new()
value_layer = bm.verts.layers.float.new('value')
for point in points:
pos = point['pos']
value = point['value']
vert = bm.verts.new(pos) # add a new vert
vert[value_layer] = value
# make the bmesh the object's mesh
bm.to_mesh(mesh)
bm.free() # always do this when finished
return obj
# class ImportDICOMPointsDialog(bpy.types.Operator):
# bl_idname = "bioxelnodes.import_dicom_points_dialog"
# bl_label = "Volume Data as Bioxels"
# bl_description = "Import Volume Data as Bioxels (VDB)"
# bl_options = {'UNDO'}
# filepath: bpy.props.StringProperty(
# subtype="FILE_PATH",
# options={'HIDDEN'}
# ) # type: ignore
# bioxels_shape: bpy.props.IntVectorProperty(
# name="Bioxels Shape (ReadOnly)",
# min=0,
# default=(100, 100, 100)
# ) # type: ignore
# bioxel_size: bpy.props.FloatProperty(
# name="Bioxel Size",
# soft_min=0.1, soft_max=10.0,
# min=1e-2, max=1e2,
# default=1,
# update=on_bioxel_size_changed
# ) # type: ignore
# orig_spacing: bpy.props.FloatVectorProperty(
# name="Original Spacing",
# default=(1, 1, 1),
# update=on_orig_spacing_changed
# ) # type: ignore
# orig_shape: bpy.props.IntVectorProperty(
# name="Original Shape",
# default=(100, 100, 100),
# options={'HIDDEN'}
# ) # type: ignore
# auto: bpy.props.BoolProperty(
# name="Auto Setting",
# default=False,
# options={'HIDDEN'}
# ) # type: ignore
# scene_scale: bpy.props.FloatProperty(
# name="Scene Scale",
# soft_min=0.001, soft_max=100.0,
# min=1e-6, max=1e6,
# default=0.01,
# ) # type: ignore
# do_add_segmentnode: bpy.props.BoolProperty(
# name="Add Segment Node",
# default=True,
# ) # type: ignore
# do_change_render_setting: bpy.props.BoolProperty(
# name="Change Render Setting",
# default=True,
# ) # type: ignore
# def execute(self, context):
# files = get_data_files(self.filepath)
# name = Path(self.filepath).parent.name
# image = sitk.ReadImage(files)
# bioxel_size = float(self.bioxel_size)
# orig_spacing = tuple(self.orig_spacing)
# image_spacing = image.GetSpacing()
# image_shape = image.GetSize()
# bioxels_spacing = (
# image_spacing[0] / orig_spacing[0] * bioxel_size,
# image_spacing[1] / orig_spacing[1] * bioxel_size,
# image_spacing[2] / orig_spacing[2] * bioxel_size
# )
# bioxels_shape = (
# int(image_shape[0] / bioxel_size * orig_spacing[0]),
# int(image_shape[1] / bioxel_size * orig_spacing[1]),
# int(image_shape[2] / bioxel_size * orig_spacing[2]),
# )
# print("Resampling...")
# image = sitk.Resample(
# image1=image,
# size=bioxels_shape,
# transform=sitk.Transform(),
# interpolator=sitk.sitkLinear,
# outputOrigin=image.GetOrigin(),
# outputSpacing=bioxels_spacing,
# outputDirection=image.GetDirection(),
# defaultPixelValue=0,
# outputPixelType=image.GetPixelID(),
# )
# print("Orienting to RAS...")
# image = sitk.DICOMOrient(image, 'RAS')
# array = sitk.GetArrayFromImage(image)
# orig_dtype = str(array.dtype)
# print(f"Coverting Dtype from {orig_dtype} to float...")
# array = array.astype(float)
# # ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i]
# # https://www.slicer.org/wiki/Coordinate_systems
# # ITK Numpy 3D
# # R (ight) i -> k -> x
# # A (nterior) j -> j -> y
# # S (uperior) k -> i -> z
# array = np.transpose(array)
# bioxels_max = float(np.max(array))
# bioxels_min = float(np.min(array))
# bioxels_shape = array.shape
# print("Bioxel Size:", bioxel_size)
# print("Bioxels Shape:", bioxels_shape)
# bioxels_offset = 0.0
# if bioxels_min < 0 and orig_dtype[0] != "u":
# bioxels_offset = -bioxels_min
# array = array + np.full_like(array, bioxels_offset)
# bioxels_max = float(np.max(array))
# bioxels_min = float(np.min(array))
# print("Offseted Max:", bioxels_max)
# print("Offseted Min:", bioxels_min)
# points = []
# for x in range(array.shape[0]):
# for y in range(array.shape[1]):
# for z in range(array.shape[2]):
# points.append({
# "pos": (x, y, z),
# "value": array[x, y, z],
# })
# # # Build VDB
# # grid = vdb.FloatGrid()
# # grid.copyFromArray(array.copy())
# # # After sitk.DICOMOrient(), origin and direction will also orient base on LPS
# # # so we need to convert them into RAS
# # mat_lps2ras = axis_conversion(
# # from_forward='-Z',
# # from_up='-Y',
# # to_forward='-Z',
# # to_up='Y'
# # ).to_4x4()
# # mat_location = mathutils.Matrix.Translation(
# # mathutils.Vector(image.GetOrigin())
# # )
# # mat_rotation = mathutils.Matrix(
# # np.array(image.GetDirection()).reshape((3, 3))
# # ).to_4x4()
# # mat_scale = mathutils.Matrix.Scale(
# # bioxel_size, 4
# # )
# # transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale
# obj = create_points_obj(points)
# # Make transformation
# scene_scale = float(self.scene_scale)
# # (S)uperior -Z -> Y
# # (A)osterior Y -> Z
# mat_ras2blender = axis_conversion(
# from_forward='-Z',
# from_up='Y',
# to_forward='Y',
# to_up='Z'
# ).to_4x4()
# mat_scene_scale = mathutils.Matrix.Scale(
# scene_scale, 4
# )
# obj.matrix_world = mat_ras2blender @ mat_scene_scale
# self.report({"INFO"}, "Successfully Imported")
# return {'FINISHED'}
# def invoke(self, context, event):
# # print(tuple(self.orig_shape))
# self.auto = True
# self.bioxel_size = 1
# context.window_manager.invoke_props_dialog(self)
# return {'RUNNING_MODAL'}

View File

@@ -1,6 +1,6 @@
import bpy
from .convert import ConvertToMesh
from .io import ExportVDB, ImportDICOM
from .io import ExportVDB, ImportImage
from .misc import SaveBioxels
@@ -10,15 +10,15 @@ class ObjectMenu(bpy.types.Menu):
def draw(self, context):
layout = self.layout
layout.operator(SaveBioxels.bl_idname)
layout.separator()
layout.operator(ConvertToMesh.bl_idname)
layout.separator()
layout.operator(SaveBioxels.bl_idname)
def TOPBAR_FILE_IMPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ImportDICOM.bl_idname)
layout.operator(ImportImage.bl_idname)
def TOPBAR_FILE_EXPORT(self, context):
@@ -30,17 +30,16 @@ def TOPBAR_FILE_EXPORT(self, context):
def VIEW3D_OBJECT(self, context):
layout = self.layout
layout.menu(ObjectMenu.bl_idname)
layout.separator()
def create_menu():
def register():
bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT)
bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT)
bpy.types.VIEW3D_MT_object_context_menu.prepend(VIEW3D_OBJECT)
def remove_menu():
def unregister():
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)

View File

@@ -40,7 +40,7 @@ else:
MENU_ITEMS = [
{
'label': 'Present',
'label': 'Presets',
'icon': 'MESH_MONKEY',
'items': [
{
@@ -52,17 +52,17 @@ MENU_ITEMS = [
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
},
{
'label': 'Anatomy',
'icon': 'CONSTRAINT_BONE',
'node_type': 'BioxelNodes_Anatomy',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
}
# {
# 'label': 'Anatomy',
# 'icon': 'CONSTRAINT_BONE',
# 'node_type': 'BioxelNodes_Anatomy',
# 'node_description': '',
# 'node_callback': "\n".join([
# set_prop_to_node_factory("bioxel_size", "Detail Size"),
# set_prop_to_node_factory("bioxels_offset", "Offset"),
# ])
# }
]
},
{
@@ -71,7 +71,7 @@ MENU_ITEMS = [
'items': [
{
'label': 'Segment by Level',
'icon': 'AREA_JOIN',
'icon': 'EMPTY_SINGLE_ARROW',
'node_type': 'BioxelNodes_SegmentByLevel',
'node_description': '',
'node_callback': "\n".join([
@@ -81,24 +81,24 @@ MENU_ITEMS = [
},
{
'label': 'Segment by Range',
'icon': 'AREA_SWAP',
'icon': 'IPO_CONSTANT',
'node_type': 'BioxelNodes_SegmentByRange',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
},
{
'label': 'Segment by Layers',
'icon': 'OUTLINER_OB_FORCE_FIELD',
'node_type': 'BioxelNodes_SegmentsByLayers',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
}
# {
# 'label': 'Segment by Layers',
# 'icon': 'ALIGN_JUSTIFY',
# 'node_type': 'BioxelNodes_SegmentsByLayers',
# 'node_description': '',
# 'node_callback': "\n".join([
# set_prop_to_node_factory("bioxel_size", "Detail Size"),
# set_prop_to_node_factory("bioxels_offset", "Offset"),
# ])
# }
]
},
{
@@ -112,8 +112,13 @@ MENU_ITEMS = [
'node_description': ''
}
]
},
{
}
]
NODE_FILE = "BioxelNodes_4.0"
if bpy.app.version >= (4, 1, 0):
MENU_ITEMS.append({
'label': 'Slicer',
'icon': 'CLIPUV_DEHLT',
'items': [
@@ -136,12 +141,21 @@ MENU_ITEMS = [
'node_description': '',
}
]
},
]
})
NODE_FILE = "BioxelNodes_4.1"
custom_nodes = CustomNodes(
menu_items=MENU_ITEMS,
nodes_file=Path(__file__).parent/"assets/Nodes/BioxelNodes.blend",
nodes_file=Path(__file__).parent / f"assets/Nodes/{NODE_FILE}.blend",
root_label='Bioxel Nodes',
root_icon="FILE_VOLUME",
)
def register():
custom_nodes.register()
def unregister():
custom_nodes.unregister()

View File

@@ -7,16 +7,30 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferen
bl_idname = __package__
cache_dir: bpy.props.StringProperty(
name="VDB Cache Directory",
name="Set Cache Directory",
subtype='DIR_PATH',
default=str(Path(Path.home(), '.bioxelnodes'))
) # type: ignore
do_add_segmentnode: bpy.props.BoolProperty(
name="Add Segment Node",
default=True,
) # type: ignore
do_change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting",
default=True,
) # type: ignore
def draw(self, context):
layout = self.layout
layout.label(text="Configuration")
layout.prop(self, 'cache_dir', text='Set Cache Directory')
# ExternalPackagePreferences Config
self.requirements_dir = str(Path(__file__).parent)
super().draw(context)
layout.label(text="Configuration")
layout.prop(self, 'cache_dir')
layout.prop(self, "do_change_render_setting")
layout.prop(self, "do_add_segmentnode")

5
bioxelnodes/props.py Normal file
View File

@@ -0,0 +1,5 @@
import bpy
class BIOXELNODES_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore

View File

@@ -2,10 +2,13 @@ import bpy
import mathutils
def get_node_by_type(nodes, type_name: str):
return [node for node in nodes if type(node).__name__ == type_name]
def get_type(cls):
return type(cls).__name__
def get_node_by_type(nodes, type_name: str):
return [node for node in nodes if get_type(node) == type_name]
def show_message(message="", title="Message Box", icon='INFO'):
def draw(self, context):
@@ -81,6 +84,7 @@ def get_bioxels_obj(current_obj):
return bioxels_obj
def get_all_bioxels_objs():
bioxels_objs = []
@@ -88,10 +92,11 @@ def get_all_bioxels_objs():
if obj.parent:
if obj.parent.get('bioxels_container') and obj.get('bioxels'):
bioxels_objs.append(obj)
return list(set(bioxels_objs))
def extract_last_number(text):
def get_text_index(text):
# Initialize an empty string to store the digits
digits = ""
@@ -110,7 +115,7 @@ def extract_last_number(text):
# Reverse the digits string to get the correct order
last_number = digits[::-1]
return int(last_number)
return int(last_number) if last_number else None
def add_driver(target_prop, var_sources, expression):