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

@@ -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(