From 1990a1697268d51c97db52ca82afddb6e45b64a0 Mon Sep 17 00:00:00 2001 From: Ma Nan Date: Sat, 11 May 2024 10:43:00 +0800 Subject: [PATCH] feat: add more format support --- .github/workflows/upload-assets.yml | 56 +-- bioxelnodes/__init__.py | 18 +- .../assets/Nodes/BioxelNodes_4.0.blend | 3 + ...ioxelNodes.blend => BioxelNodes_4.1.blend} | 0 bioxelnodes/auto_load.py | 2 +- bioxelnodes/convert.py | 2 +- bioxelnodes/customnodes/__init__.py | 2 +- bioxelnodes/customnodes/{menu.py => menus.py} | 13 +- bioxelnodes/externalpackage/__init__.py | 3 +- bioxelnodes/externalpackage/package.py | 8 +- bioxelnodes/externalpackage/preferences.py | 27 +- bioxelnodes/io.py | 431 ++++++++++++------ bioxelnodes/io_points.py | 221 +++++++++ bioxelnodes/{menu.py => menus.py} | 13 +- bioxelnodes/nodes.py | 70 +-- bioxelnodes/preferences.py | 20 +- bioxelnodes/props.py | 5 + bioxelnodes/utils.py | 15 +- 18 files changed, 671 insertions(+), 238 deletions(-) create mode 100644 bioxelnodes/assets/Nodes/BioxelNodes_4.0.blend rename bioxelnodes/assets/Nodes/{BioxelNodes.blend => BioxelNodes_4.1.blend} (100%) rename bioxelnodes/customnodes/{menu.py => menus.py} (96%) create mode 100644 bioxelnodes/io_points.py rename bioxelnodes/{menu.py => menus.py} (90%) create mode 100644 bioxelnodes/props.py diff --git a/.github/workflows/upload-assets.yml b/.github/workflows/upload-assets.yml index 7c6898b..0f7e9d8 100644 --- a/.github/workflows/upload-assets.yml +++ b/.github/workflows/upload-assets.yml @@ -6,31 +6,8 @@ on: - "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 }} @@ -41,14 +18,37 @@ jobs: 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 + - name: Get release id: draft_release + uses: cardinalby/git-get-release-action@1.2.4 with: - name: ${{ steps.set_env.outputs.version }} - tag: ${{ steps.set_env.outputs.version }} + releaseName: Draft env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 diff --git a/bioxelnodes/__init__.py b/bioxelnodes/__init__.py index 22a7897..103e259 100644 --- a/bioxelnodes/__init__.py +++ b/bioxelnodes/__init__.py @@ -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 diff --git a/bioxelnodes/assets/Nodes/BioxelNodes_4.0.blend b/bioxelnodes/assets/Nodes/BioxelNodes_4.0.blend new file mode 100644 index 0000000..b938d23 --- /dev/null +++ b/bioxelnodes/assets/Nodes/BioxelNodes_4.0.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62d5d5bc44343f9bca7d73c6b447661720a5b2de0be4cd5fafeeb204dc32e886 +size 1635596 diff --git a/bioxelnodes/assets/Nodes/BioxelNodes.blend b/bioxelnodes/assets/Nodes/BioxelNodes_4.1.blend similarity index 100% rename from bioxelnodes/assets/Nodes/BioxelNodes.blend rename to bioxelnodes/assets/Nodes/BioxelNodes_4.1.blend diff --git a/bioxelnodes/auto_load.py b/bioxelnodes/auto_load.py index ba6d428..d4006c8 100644 --- a/bioxelnodes/auto_load.py +++ b/bioxelnodes/auto_load.py @@ -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 diff --git a/bioxelnodes/convert.py b/bioxelnodes/convert.py index d083800..42b3162 100644 --- a/bioxelnodes/convert.py +++ b/bioxelnodes/convert.py @@ -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 diff --git a/bioxelnodes/customnodes/__init__.py b/bioxelnodes/customnodes/__init__.py index f809877..bfad98c 100644 --- a/bioxelnodes/customnodes/__init__.py +++ b/bioxelnodes/customnodes/__init__.py @@ -1 +1 @@ -from .menu import CustomNodes \ No newline at end of file +from .menus import CustomNodes \ No newline at end of file diff --git a/bioxelnodes/customnodes/menu.py b/bioxelnodes/customnodes/menus.py similarity index 96% rename from bioxelnodes/customnodes/menu.py rename to bioxelnodes/customnodes/menus.py index 78c0fac..eac702f 100644 --- a/bioxelnodes/customnodes/menu.py +++ b/bioxelnodes/customnodes/menus.py @@ -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 diff --git a/bioxelnodes/externalpackage/__init__.py b/bioxelnodes/externalpackage/__init__.py index c00e7fb..a46ad0f 100644 --- a/bioxelnodes/externalpackage/__init__.py +++ b/bioxelnodes/externalpackage/__init__.py @@ -1 +1,2 @@ -from .preferences import ExternalPackagePreferences \ No newline at end of file +from .preferences import ExternalPackagePreferences +from .package import PackageInstaller diff --git a/bioxelnodes/externalpackage/package.py b/bioxelnodes/externalpackage/package.py index c55ba62..368a32c 100644 --- a/bioxelnodes/externalpackage/package.py +++ b/bioxelnodes/externalpackage/package.py @@ -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' diff --git a/bioxelnodes/externalpackage/preferences.py b/bioxelnodes/externalpackage/preferences.py index 3c99fed..f15aff8 100644 --- a/bioxelnodes/externalpackage/preferences.py +++ b/bioxelnodes/externalpackage/preferences.py @@ -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) diff --git a/bioxelnodes/io.py b/bioxelnodes/io.py index e93a7de..7aeadcc 100644 --- a/bioxelnodes/io.py +++ b/bioxelnodes/io.py @@ -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( diff --git a/bioxelnodes/io_points.py b/bioxelnodes/io_points.py new file mode 100644 index 0000000..083fdf8 --- /dev/null +++ b/bioxelnodes/io_points.py @@ -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'} diff --git a/bioxelnodes/menu.py b/bioxelnodes/menus.py similarity index 90% rename from bioxelnodes/menu.py rename to bioxelnodes/menus.py index 2bb5573..87fb317 100644 --- a/bioxelnodes/menu.py +++ b/bioxelnodes/menus.py @@ -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) diff --git a/bioxelnodes/nodes.py b/bioxelnodes/nodes.py index 51edcca..74729f2 100644 --- a/bioxelnodes/nodes.py +++ b/bioxelnodes/nodes.py @@ -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() diff --git a/bioxelnodes/preferences.py b/bioxelnodes/preferences.py index bb1036d..7972589 100644 --- a/bioxelnodes/preferences.py +++ b/bioxelnodes/preferences.py @@ -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") diff --git a/bioxelnodes/props.py b/bioxelnodes/props.py new file mode 100644 index 0000000..d46bfd6 --- /dev/null +++ b/bioxelnodes/props.py @@ -0,0 +1,5 @@ +import bpy + +class BIOXELNODES_Series(bpy.types.PropertyGroup): + id: bpy.props.StringProperty() # type: ignore + label: bpy.props.StringProperty() # type: ignore diff --git a/bioxelnodes/utils.py b/bioxelnodes/utils.py index b00b070..8b797b0 100644 --- a/bioxelnodes/utils.py +++ b/bioxelnodes/utils.py @@ -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):