From 5e8d12c564c6f4a5704e00ba5f4a57db9f8bd972 Mon Sep 17 00:00:00 2001 From: Ma Nan Date: Mon, 13 May 2024 12:09:11 +0800 Subject: [PATCH] fix: io --- bioxelnodes/__init__.py | 11 +- bioxelnodes/auto_load.py | 39 +++++-- bioxelnodes/io.py | 218 +++++++++++++++++++++++++-------------- bioxelnodes/menus.py | 8 +- bioxelnodes/utils.py | 4 +- 5 files changed, 183 insertions(+), 97 deletions(-) diff --git a/bioxelnodes/__init__.py b/bioxelnodes/__init__.py index 103e259..999d860 100644 --- a/bioxelnodes/__init__.py +++ b/bioxelnodes/__init__.py @@ -1,7 +1,7 @@ import bpy -from .nodes import custom_nodes from . import auto_load +from . import menus bl_info = { @@ -9,7 +9,7 @@ bl_info = { "author": "Ma Nan", "description": "", "blender": (4, 0, 0), - "version": (0, 1, 0), + "version": (0, 1, 1), "location": "File -> Import", "warning": "", "category": "Node" @@ -20,6 +20,7 @@ auto_load.init() def register(): auto_load.register() + menus.add() bpy.types.Scene.bioxels_dir = bpy.props.StringProperty( name="Bioxels Directory", subtype='DIR_PATH', @@ -28,7 +29,5 @@ def register(): def unregister(): - try: - auto_load.unregister() - except RuntimeError: - pass + menus.remove() + auto_load.unregister() diff --git a/bioxelnodes/auto_load.py b/bioxelnodes/auto_load.py index d4006c8..6b4eede 100644 --- a/bioxelnodes/auto_load.py +++ b/bioxelnodes/auto_load.py @@ -16,6 +16,7 @@ blender_version = bpy.app.version modules = None ordered_classes = None + def init(): global modules global ordered_classes @@ -23,6 +24,7 @@ def init(): modules = get_all_submodules(Path(__file__).parent) ordered_classes = get_ordered_classes_to_register(modules) + def register(): for cls in ordered_classes: bpy.utils.register_class(cls) @@ -33,15 +35,22 @@ def register(): if hasattr(module, "register"): module.register() + def unregister(): for cls in reversed(ordered_classes): - bpy.utils.unregister_class(cls) + try: + bpy.utils.unregister_class(cls) + except Exception as e: + print(f"Fail to unregister {cls}, skiped") for module in modules: - if module.__name__ == __name__: - continue - if hasattr(module, "unregister"): - module.unregister() + try: + if module.__name__ == __name__: + continue + if hasattr(module, "unregister"): + module.unregister() + except Exception as e: + print(f"Fail to unregister {module}, skiped") # Import modules @@ -50,10 +59,12 @@ def unregister(): def get_all_submodules(directory): return list(iter_submodules(directory, directory.name)) + def iter_submodules(path, package_name): for name in sorted(iter_submodule_names(path)): yield importlib.import_module("." + name, package_name) + def iter_submodule_names(path, root=""): for _, module_name, is_package in pkgutil.iter_modules([str(path)]): if is_package: @@ -70,19 +81,24 @@ def iter_submodule_names(path, root=""): def get_ordered_classes_to_register(modules): return toposort(get_register_deps_dict(modules)) + def get_register_deps_dict(modules): my_classes = set(iter_my_classes(modules)) - my_classes_by_idname = {cls.bl_idname : cls for cls in my_classes if hasattr(cls, "bl_idname")} + my_classes_by_idname = { + cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")} deps_dict = {} for cls in my_classes: - deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) + deps_dict[cls] = set(iter_my_register_deps( + cls, my_classes, my_classes_by_idname)) return deps_dict + def iter_my_register_deps(cls, my_classes, my_classes_by_idname): yield from iter_my_deps_from_annotations(cls, my_classes) yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) + def iter_my_deps_from_annotations(cls, my_classes): for value in typing.get_type_hints(cls, {}, {}).values(): dependency = get_dependency_from_annotation(value) @@ -90,6 +106,7 @@ def iter_my_deps_from_annotations(cls, my_classes): if dependency in my_classes: yield dependency + def get_dependency_from_annotation(value): if blender_version >= (2, 93): if isinstance(value, bpy.props._PropertyDeferred): @@ -100,6 +117,7 @@ def get_dependency_from_annotation(value): return value[1]["type"] return None + def iter_my_deps_from_parent_id(cls, my_classes_by_idname): if bpy.types.Panel in cls.__bases__: parent_idname = getattr(cls, "bl_parent_id", None) @@ -108,6 +126,7 @@ def iter_my_deps_from_parent_id(cls, my_classes_by_idname): if parent_cls is not None: yield parent_cls + def iter_my_classes(modules): base_types = get_register_base_types() for cls in get_classes_in_modules(modules): @@ -115,6 +134,7 @@ def iter_my_classes(modules): if not getattr(cls, "is_registered", False): yield cls + def get_classes_in_modules(modules): classes = set() for module in modules: @@ -122,11 +142,13 @@ def get_classes_in_modules(modules): classes.add(cls) return classes + def iter_classes_in_module(module): for value in module.__dict__.values(): if inspect.isclass(value): yield value + def get_register_base_types(): return set(getattr(bpy.types, name) for name in [ "Panel", "Operator", "PropertyGroup", "FileHandler", @@ -151,5 +173,6 @@ def toposort(deps_dict): sorted_values.add(value) else: unsorted.append(value) - deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted} + deps_dict = {value: deps_dict[value] - + sorted_values for value in unsorted} return sorted_list diff --git a/bioxelnodes/io.py b/bioxelnodes/io.py index 7aeadcc..3285caa 100644 --- a/bioxelnodes/io.py +++ b/bioxelnodes/io.py @@ -7,7 +7,7 @@ from pathlib import Path from uuid import uuid4 import mathutils import math -from .utils import calc_bbox_verts, get_text_index, get_bioxels_obj, get_node_by_type, show_message +from .utils import calc_bbox_verts, get_text_index_str, get_bioxels_obj, get_node_by_type, show_message from .nodes import custom_nodes from .props import BIOXELNODES_Series try: @@ -52,30 +52,16 @@ FH_EXTS = ['.dcm', '.DICOM', 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]), - ) + shape = (int(orig_shape[0] / bioxel_size * orig_spacing[0]), + int(orig_shape[1] / bioxel_size * orig_spacing[1]), + int(orig_shape[2] / bioxel_size * orig_spacing[2])) + + return (shape[0] if shape[0] > 0 else 1, + shape[1] if shape[1] > 0 else 1, + shape[2] if shape[2] > 0 else 1) -def collect_image_sequence(filepath: str): - file_path = Path(filepath).resolve() - - files = list(file_path.parent.iterdir()) - 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 get_index(f: Path): - return get_text_index(f.stem) - - files.sort(key=get_index) - sequence = [str(f) for f in files] - return sequence - - -def get_ext(filepath: str): +def get_ext(filepath: str) -> str: file_path = Path(filepath) if file_path.name.endswith(".nii.gz"): return ".nii.gz" @@ -87,11 +73,38 @@ def get_ext(filepath: str): return file_path.suffix +def get_sequence_name(filepath: str) -> str: + ext = get_ext(filepath) + filename = Path(filepath).name.removesuffix(ext) + index: str = get_text_index_str(filename) + return filename.removesuffix(index) + + +def get_sequence_index(filepath: str) -> int: + ext = get_ext(filepath) + filename = Path(filepath).name.removesuffix(ext) + index: str = get_text_index_str(filename) + return int(index) if index else 0 + + +def collect_image_sequence(filepath: str): + file_path = Path(filepath).resolve() + + files = list(file_path.parent.iterdir()) + files = [f for f in files if f.is_file() + and get_ext(file_path) == get_ext(f) + and get_sequence_name(file_path) == get_sequence_name(f)] + + files.sort(key=get_sequence_index) + sequence = [str(f) for f in files] + return sequence + + 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: + dir_path = Path(filepath).resolve().parent reader = sitk.ImageSeriesReader() reader.MetaDataDictionaryArrayUpdateOn() reader.LoadPrivateTagsOn() @@ -99,19 +112,36 @@ def read_image(filepath: str, series_id=""): 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: + name = dir_path.name + + elif ext in SEQUENCE_EXTS: image = sitk.ReadImage(filepath) if image.GetDimension() == 2: - image = sitk.ReadImage([filepath]) + sequence = collect_image_sequence(filepath) + image = sitk.ReadImage(sequence) + name = get_sequence_name(filepath) + else: + image = sitk.ReadImage(filepath) + name = Path(filepath).name.removesuffix(ext) + else: + image = sitk.ReadImage(filepath) + name = Path(filepath).name.removesuffix(ext) - return image + return image, name -class ImportImageDialog(bpy.types.Operator): - bl_idname = "bioxelnodes.import_image_dialog" +def rgb2gray(image): + # Convert sRGB image to gray scale and rescale results to [0,255] + channels = [sitk.VectorIndexSelectionCast( + image, i, sitk.sitkFloat32) for i in range(image.GetNumberOfComponentsPerPixel())] + # linear mapping + I = (0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2]) + + return sitk.Cast(I, sitk.sitkFloat32) + + +class ImportVolumeDataDialog(bpy.types.Operator): + bl_idname = "bioxelnodes.import_volume_data_dialog" bl_label = "Volume Data as Bioxels" bl_description = "Import Volume Data as Bioxels (VDB)" bl_options = {'UNDO'} @@ -123,10 +153,19 @@ class ImportImageDialog(bpy.types.Operator): series_id: bpy.props.StringProperty() # type: ignore + resample_method: bpy.props.EnumProperty( + name="Resample Method", + default="linear", + items=[("linear", "Linear", ""), + ("nearest_neighbor", "Nearest Neighbor", ""), + ("gaussian", "Gaussian", "")] + ) # type: ignore + read_as: bpy.props.EnumProperty( name="Read as", default="scalar", - items=[("scalar", "Scalar", ""), ("labels", "Labels", "")] + items=[("scalar", "Scalar", ""), + ("labels", "Labels", "")] ) # type: ignore label_index: bpy.props.IntProperty( @@ -169,11 +208,7 @@ class ImportImageDialog(bpy.types.Operator): ) # type: ignore def execute(self, context): - 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)) - - image = read_image(self.filepath, self.series_id) + image, bioxels_name = read_image(self.filepath, self.series_id) bioxel_size = self.bioxel_size orig_spacing = self.orig_spacing @@ -189,16 +224,37 @@ class ImportImageDialog(bpy.types.Operator): bioxels_shape = get_bioxels_shape( bioxel_size, image_shape, orig_spacing) - print("Resampling...") + if self.read_as == "labels": + image = sitk.Cast(image, sitk.sitkUInt16) + default_value = 0 + elif self.read_as == "scalar": + if "vector" in image.GetPixelIDTypeAsString(): + print("Convet to Grayscale...") + image = rgb2gray(image) + else: + image = sitk.Cast(image, sitk.sitkFloat32) + + stats = sitk.StatisticsImageFilter() + stats.Execute(image) + default_value = stats.GetMaximum() if self.invert_scalar else stats.GetMinimum() + + if self.resample_method == "linear": + interpolator = sitk.sitkLinear + elif self.resample_method == "nearest_neighbor": + interpolator = sitk.sitkNearestNeighbor + elif self.resample_method == "gaussian": + interpolator = sitk.sitkGaussian + + print(f"Resampling...") image = sitk.Resample( image1=image, size=bioxels_shape, transform=sitk.Transform(), - interpolator=sitk.sitkLinear, + interpolator=interpolator, outputOrigin=image.GetOrigin(), outputSpacing=bioxels_spacing, outputDirection=image.GetDirection(), - defaultPixelValue=0, + defaultPixelValue=default_value, outputPixelType=image.GetPixelID(), ) @@ -219,17 +275,20 @@ class ImportImageDialog(bpy.types.Operator): # A (nterior) j -> j -> y # S (uperior) k -> i -> z array = sitk.GetArrayFromImage(image) - array = np.transpose(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] + # if array.shape[0] == 3 and self.read_as == "scalar": + # # RGB -> Grayscale + # array = np.dot(array[..., :3], [0.2989, 0.5870, 0.1140]) + # else: + # array = array[:, :, :, 0] + array = array[:, :, :, 0] + + array = np.transpose(array) bioxels_shape = array.shape bioxels_offset = 0.0 @@ -260,16 +319,14 @@ class ImportImageDialog(bpy.types.Operator): bioxels_max = float(np.max(array)) bioxels_min = float(np.min(array)) - stat_table = [("Max", orig_max), - ("Min", orig_min), - ("Median", orig_median), - ("Percentile 80%", orig_percentile80), - ("Max (Offset)", bioxels_max), - ("Min (Offset)", bioxels_min)] + stats_table = [("Max", orig_max), + ("Min", orig_min), + ("Median", orig_median), + ("80%", orig_percentile80)] - print("Bioxels Value Stat: Max:") - for stat in stat_table: - print("| {: >20} | {: >20} |".format(*stat)) + print("Bioxels Value Stats:") + for stats in stats_table: + print("| {: >10} | {: >40} |".format(*stats)) # # Build VDB grid = vdb.BoolGrid() if self.read_as == "labels" else vdb.FloatGrid() @@ -436,6 +493,7 @@ class ImportImageDialog(bpy.types.Operator): text += "**TOO LARGE!**" panel = layout.box() + panel.prop(self, "resample_method") panel.prop(self, "bioxel_size") row = panel.row() row.prop(self, "orig_spacing") @@ -472,9 +530,8 @@ class ReadDICOM(bpy.types.Operator): bl_description = "Import Volume Data as Bioxels (VDB)" bl_options = {'UNDO'} - filepath: bpy.props.StringProperty( - subtype="FILE_PATH" - ) # type: ignore + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore + directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore series_id: bpy.props.EnumProperty( name="Select Series", @@ -490,20 +547,27 @@ class ReadDICOM(bpy.types.Operator): self.report({"WARNING"}, "Not supported extension.") return {'CANCELLED'} - image = read_image(self.filepath, self.series_id) - print("Collecting Meta Data...") - print("Original Shape:", image.GetSize()) - print("Original Spacing:", image.GetSpacing()) - print("Original Origin:", image.GetOrigin()) - print("Original Direction:", image.GetDirection()) + image, name = read_image(self.filepath, self.series_id) + stats_table = [("Shape", str(image.GetSize())), + ("Spacing", str(image.GetSpacing())), + ("Origin", str(image.GetOrigin())), + ("Direction", str(image.GetDirection())), + ("Data Type", image.GetPixelIDTypeAsString())] - bpy.ops.bioxelnodes.import_image_dialog( + print("Meta Data:") + for stats in stats_table: + print("| {: >10} | {: >40} |".format(*stats)) + + do_orient = ext not in SEQUENCE_EXTS or ext in DICOM_EXTS + + bpy.ops.bioxelnodes.import_volume_data_dialog( 'INVOKE_DEFAULT', filepath=self.filepath, orig_shape=image.GetSize(), orig_spacing=image.GetSpacing(), - series_id=self.series_id or "" + series_id=self.series_id or "", + do_orient=do_orient ) self.report({"INFO"}, "Successfully Readed.") @@ -515,7 +579,7 @@ class ReadDICOM(bpy.types.Operator): return {'FINISHED'} def invoke(self, context, event): - if not self.filepath: + if not self.filepath and not self.directory: return {'CANCELLED'} show_message('Reading image data, it may take a while...', @@ -551,20 +615,20 @@ class ReadDICOM(bpy.types.Operator): text="Please be patient...") -class ImportImage(bpy.types.Operator): - bl_idname = "bioxelnodes.import_image" +class ImportVolumeData(bpy.types.Operator): + bl_idname = "bioxelnodes.import_volume_data" bl_label = "Volume Data as Bioxels" bl_description = "Import Volume Data as Bioxels (VDB)" bl_options = {'UNDO'} - filepath: bpy.props.StringProperty( - subtype="FILE_PATH" - ) # type: ignore + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore + directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore def execute(self, context): bpy.ops.bioxelnodes.read_dicom( 'INVOKE_DEFAULT', - filepath=self.filepath + filepath=self.filepath, + directory=self.directory ) return {'FINISHED'} @@ -574,8 +638,8 @@ class ImportImage(bpy.types.Operator): try: - class BIOXELNODES_FH_ImportImage(bpy.types.FileHandler): - bl_idname = "BIOXELNODES_FH_ImportImage" + class BIOXELNODES_FH_ImportVolumeData(bpy.types.FileHandler): + bl_idname = "BIOXELNODES_FH_ImportVolumeData" bl_label = "File handler for dicom import" bl_import_operator = "bioxelnodes.read_dicom" bl_file_extensions = ";".join(FH_EXTS) diff --git a/bioxelnodes/menus.py b/bioxelnodes/menus.py index 87fb317..82d3c65 100644 --- a/bioxelnodes/menus.py +++ b/bioxelnodes/menus.py @@ -1,6 +1,6 @@ import bpy from .convert import ConvertToMesh -from .io import ExportVDB, ImportImage +from .io import ExportVDB, ImportVolumeData from .misc import SaveBioxels @@ -18,7 +18,7 @@ class ObjectMenu(bpy.types.Menu): def TOPBAR_FILE_IMPORT(self, context): layout = self.layout layout.separator() - layout.operator(ImportImage.bl_idname) + layout.operator(ImportVolumeData.bl_idname) def TOPBAR_FILE_EXPORT(self, context): @@ -33,13 +33,13 @@ def VIEW3D_OBJECT(self, context): layout.separator() -def register(): +def add(): 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 unregister(): +def remove(): 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) diff --git a/bioxelnodes/utils.py b/bioxelnodes/utils.py index 8b797b0..cbd5d0d 100644 --- a/bioxelnodes/utils.py +++ b/bioxelnodes/utils.py @@ -96,7 +96,7 @@ def get_all_bioxels_objs(): return list(set(bioxels_objs)) -def get_text_index(text): +def get_text_index_str(text): # Initialize an empty string to store the digits digits = "" @@ -115,7 +115,7 @@ def get_text_index(text): # Reverse the digits string to get the correct order last_number = digits[::-1] - return int(last_number) if last_number else None + return last_number def add_driver(target_prop, var_sources, expression):