7 Commits

Author SHA1 Message Date
Ma Nan
21e0b85662 Merge pull request #38 from OmooLab/fix-import
fix: color ramp not fit
2024-08-07 09:50:54 +08:00
38fb888f00 fix: color ramp not fit
fix: skip DICOM series option not reset.
fix: combine label only combine 2 layers
2024-08-07 09:42:25 +08:00
Ma Nan
2429eef5e0 Merge pull request #36 from OmooLab/feature/dev
Feature/dev
2024-08-05 15:54:19 +08:00
2563f398da chore: update version 2024-08-05 15:53:03 +08:00
0cad35e2da fix: fail to read all sequence in folder with same name prefix 2024-08-05 14:13:48 +08:00
65edf40164 feat: better drag and drop 2024-08-05 13:48:24 +08:00
c3f88b7846 fix: layer cache cannot be saved via save stage data 2024-08-05 11:27:50 +08:00
8 changed files with 101 additions and 84 deletions

View File

@@ -3,6 +3,7 @@ import numpy as np
from . import scipy from . import scipy
from . import skimage as ski from . import skimage as ski
from . import scipy as ndi
# 3rd-party # 3rd-party
import transforms3d import transforms3d
@@ -97,36 +98,43 @@ class Layer():
_mask = np.expand_dims(_mask, axis=-1) _mask = np.expand_dims(_mask, axis=-1)
self.data = _mask * value + (1-_mask) * self.data self.data = _mask * value + (1-_mask) * self.data
def resize(self, shape:tuple, progress_callback=None): def resize(self, shape: tuple, progress_callback=None):
if len(shape) != 3: if len(shape) != 3:
raise Exception("Shape must be 3 dim") raise Exception("Shape must be 3 dim")
data = self.data data = self.data
order = 0 if self.dtype == bool else 1
# TXYZC > TXYZ # TXYZC > TXYZ
if self.kind in ['label', 'scalar']: if self.kind in ['label', 'scalar']:
data = np.amax(data, -1) data = np.amax(data, -1)
if self.kind in ['scalar']: # if self.kind in ['scalar']:
dtype = data.dtype # dtype = data.dtype
data = data.astype(np.float32) # data = data.astype(np.float32)
data_frames = () data_frames = ()
for f in range(self.frame_count): for f in range(self.frame_count):
if progress_callback: if progress_callback:
progress_callback(f, self.frame_count) progress_callback(f, self.frame_count)
frame = ski.resize(data[f, :, :, :], # frame = ski.resize(data[f, :, :, :],
shape, # shape,
preserve_range=True, # preserve_range=True,
anti_aliasing=data.dtype.kind != "b") # anti_aliasing=data.dtype.kind != "b")
factors = np.divide(self.shape, shape)
zoom_factors = [1 / f for f in factors]
frame = ndi.zoom(data[f, :, :, :],
zoom_factors,
order=order)
data_frames += (frame,) data_frames += (frame,)
data = np.stack(data_frames) data = np.stack(data_frames)
if self.kind in ['scalar']: # if self.kind in ['scalar']:
data = data.astype(dtype) # data = data.astype(dtype)
# TXYZ > TXYZC # TXYZ > TXYZC
if self.kind in ['label', 'scalar']: if self.kind in ['label', 'scalar']:

View File

@@ -14,22 +14,23 @@ Convert any volumetric data to 3D numpy array with order TXYZC
""" """
SUPPORT_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA', SUPPORT_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA',
'.ome.tiff', '.ome.tif',
'.tif', '.TIF', '.tiff', '.TIFF',
'.mrc', '.mrc.gz', '.map', '.map.gz',
'.bmp', '.BMP', '.bmp', '.BMP',
'.png', '.PNG',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.PIC', '.pic', '.PIC', '.pic',
'.gipl', '.gipl.gz', '.gipl', '.gipl.gz',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.lsm', '.LSM', '.lsm', '.LSM',
'.tif', '.TIF', '.tiff', '.TIFF',
'.mnc', '.MNC', '.mnc', '.MNC',
'.mrc', '.rec', '.mrc', '.rec',
'.mha', '.mhd', '.mha', '.mhd',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5', '.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz', '.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.nrrd', '.nhdr', '.nrrd', '.nhdr',
'.png', '.PNG',
'.vtk', '.vtk',
'.ome.tiff', '.ome.tif', '.gz']
'.mrc', '.mrc.gz', '.map', '.map.gz']
OME_EXTS = ['.ome.tiff', '.ome.tif', OME_EXTS = ['.ome.tiff', '.ome.tif',
'.tif', '.TIF', '.tiff', '.TIFF'] '.tif', '.TIF', '.tiff', '.TIFF']
@@ -68,7 +69,7 @@ def get_file_name(filepath: Path):
return filepath.name.removesuffix(ext).replace(" ", "-") return filepath.name.removesuffix(ext).replace(" ", "-")
def get_file_index(filepath: Path): def get_file_number(filepath: Path) -> str:
name = get_file_name(filepath) name = get_file_name(filepath)
digits = "" digits = ""
@@ -85,15 +86,18 @@ def get_file_index(filepath: Path):
break break
# Reverse the digits string to get the correct order # Reverse the digits string to get the correct order
last_number = digits[::-1] return digits[::-1]
return int(last_number) if last_number != "" else 0
def get_sequence_name(filepath: Path) -> str: def get_sequence_name(filepath: Path) -> str:
name = get_file_name(filepath) name = get_file_name(filepath)
index = get_file_index(filepath) number = get_file_number(filepath)
return name.removesuffix(str(index)) return name.removesuffix(number)
def get_sequence_index(filepath: Path) -> int:
number = get_file_number(filepath)
return int(number) if number != "" else 0
def collect_sequence(filepath: Path): def collect_sequence(filepath: Path):
@@ -102,9 +106,10 @@ def collect_sequence(filepath: Path):
if f.is_file() \ if f.is_file() \
and get_ext(filepath) == get_ext(f) \ and get_ext(filepath) == get_ext(f) \
and get_sequence_name(filepath) == get_sequence_name(f): and get_sequence_name(filepath) == get_sequence_name(f):
index = get_file_index(f) index = get_sequence_index(f)
file_dict[index] = f file_dict[index] = f
# reomve isolated seq file
for key in file_dict.copy().keys(): for key in file_dict.copy().keys():
if not file_dict.get(key+1) \ if not file_dict.get(key+1) \
and not file_dict.get(key-1): and not file_dict.get(key-1):

View File

@@ -1,7 +1,7 @@
schema_version = "1.0.0" schema_version = "1.0.0"
id = "bioxelnodes" id = "bioxelnodes"
version = "0.3.1" version = "0.3.3"
name = "Bioxel Nodes" name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender" tagline = "For scientific volumetric data visualization in Blender"
maintainer = "Ma Nan <icrdr2010@outlook.com>" maintainer = "Ma Nan <icrdr2010@outlook.com>"

View File

@@ -24,19 +24,6 @@ from .utils import (change_render_setting, get_cache_dir, get_preferences,
import SimpleITK as sitk import SimpleITK as sitk
import transforms3d import transforms3d
FH_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA',
'.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'
'.ome.tiff', '.ome.tif',
'.mrc', '.mrc.gz', '.map', '.map.gz']
def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple): def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple):
shape = (int(orig_shape[0] / bioxel_size * orig_spacing[0]), shape = (int(orig_shape[0] / bioxel_size * orig_spacing[0]),
@@ -69,24 +56,20 @@ class ImportVolumetricData():
bl_options = {'UNDO'} 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
read_as = "scalar" read_as = "scalar"
def execute(self, context): def execute(self, context):
container_objs = get_container_objs_from_selection() data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported format.")
return {'CANCELLED'}
if len(container_objs) > 0: bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT',
bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT', filepath=self.filepath,
filepath=self.filepath, skip_read_as=True,
directory=self.directory, read_as=self.read_as)
container_obj_name=container_objs[0].name,
read_as=self.read_as)
else:
bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT',
filepath=self.filepath,
directory=self.directory,
read_as=self.read_as)
return {'FINISHED'} return {'FINISHED'}
@@ -113,7 +96,7 @@ class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportVolumetricData" bl_idname = "BIOXELNODES_FH_ImportVolumetricData"
bl_label = "File handler for dicom import" bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.parse_volumetric_data" bl_import_operator = "bioxelnodes.parse_volumetric_data"
bl_file_extensions = ";".join(FH_EXTS) bl_file_extensions = ";".join(SUPPORT_EXTS)
@classmethod @classmethod
def poll_drop(cls, context): def poll_drop(cls, context):
@@ -135,21 +118,28 @@ def get_series_ids(self, context):
class ParseVolumetricData(bpy.types.Operator): class ParseVolumetricData(bpy.types.Operator):
bl_idname = "bioxelnodes.parse_volumetric_data" bl_idname = "bioxelnodes.parse_volumetric_data"
bl_label = "Import Volumetric Data" bl_label = "Import Volumetric Data (BioxelNodes)"
bl_description = "Import Volumetric Data as Layer" bl_description = "Import Volumetric Data as Layer"
bl_options = {'UNDO'} bl_options = {'UNDO'}
meta = None meta = None
thread = None thread = None
_timer = None _timer = None
container_obj_name = ""
progress: bpy.props.FloatProperty(name="Progress", progress: bpy.props.FloatProperty(name="Progress",
options={"SKIP_SAVE"}, options={"SKIP_SAVE"},
default=1) # type: ignore default=1) # type: ignore
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
container_obj_name: bpy.props.StringProperty() # type: ignore skip_read_as: bpy.props.BoolProperty(name="Skip Read As",
default=False,
options={"SKIP_SAVE"}) # type: ignore
skip_series_select: bpy.props.BoolProperty(name="Skip Sries Select",
default=True,
options={"SKIP_SAVE"}) # type: ignore
read_as: bpy.props.EnumProperty(name="Read as", read_as: bpy.props.EnumProperty(name="Read as",
default="scalar", default="scalar",
@@ -166,7 +156,7 @@ class ParseVolumetricData(bpy.types.Operator):
data_path = Path(self.filepath).resolve() data_path = Path(self.filepath).resolve()
ext = get_ext(data_path) ext = get_ext(data_path)
if ext not in SUPPORT_EXTS: if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported extension.") self.report({"WARNING"}, "Not supported format.")
return {'CANCELLED'} return {'CANCELLED'}
print("Collecting Meta Data...") print("Collecting Meta Data...")
@@ -297,12 +287,19 @@ class ParseVolumetricData(bpy.types.Operator):
return {'FINISHED'} return {'FINISHED'}
def invoke(self, context, event): def invoke(self, context, event):
if not self.filepath and not self.directory: if not self.filepath:
return {'CANCELLED'} return {'CANCELLED'}
data_path = Path(self.filepath).resolve() data_path = Path(self.filepath).resolve()
ext = get_ext(data_path) ext = get_ext(data_path)
container_objs = get_container_objs_from_selection()
if len(container_objs) > 0:
self.container_obj_name = container_objs[0].name
title = f"Add to **{self.container_obj_name}**" \
if self.container_obj_name != "" else f"Init a Container"
# Series Selection # Series Selection
if ext in DICOM_EXTS: if ext in DICOM_EXTS:
data_dirpath = data_path.parent data_dirpath = data_path.parent
@@ -358,9 +355,10 @@ class ParseVolumetricData(bpy.types.Operator):
series_item.label = value series_item.label = value
if len(series_items.keys()) > 1: if len(series_items.keys()) > 1:
self.skip_series_select = False
context.window_manager.invoke_props_dialog(self, context.window_manager.invoke_props_dialog(self,
width=400, width=400,
title="Which series to import?") title=title)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
elif len(series_items.keys()) == 1: elif len(series_items.keys()) == 1:
self.series_id = list(series_items.keys())[0] self.series_id = list(series_items.keys())[0]
@@ -368,27 +366,37 @@ class ParseVolumetricData(bpy.types.Operator):
self.report({"ERROR"}, "Get no vaild series.") self.report({"ERROR"}, "Get no vaild series.")
return {'CANCELLED'} return {'CANCELLED'}
self.execute(context) if self.skip_read_as:
return {'RUNNING_MODAL'} self.execute(context)
return {'RUNNING_MODAL'}
else:
context.window_manager.invoke_props_dialog(self,
width=400,
title=title)
return {'RUNNING_MODAL'}
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
layout.label( if not self.skip_read_as:
text='Detect multi-series in DICOM, pick one') layout.label(
layout.prop(self, "series_id") text='What kind is the data read as?')
layout.prop(self, "read_as")
if not self.skip_series_select:
layout.label(text='Which series to import?')
layout.prop(self, "series_id")
def get_sequence_sources(self, context): def get_sequence_sources(self, context):
items = [("-1", "None (Static)", "")] items = [("-1", "None (1 frame)", "")]
orig_shape = tuple(self.orig_shape) orig_shape = tuple(self.orig_shape)
if self.frame_count > 1: if self.frame_count > 1:
items.append(("0", f"Frame (Get {self.frame_count} frames)", "")) items.append(("0", f"Frame ({self.frame_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count > 1: elif self.frame_count == 1 and self.channel_count > 1:
items.append(("4", f"Channel (Get {self.channel_count} frames)", "")) items.append(("4", f"Channel ({self.channel_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count == 1: elif self.frame_count == 1 and self.channel_count == 1:
items.append(("1", f"X (Get {orig_shape[0]} frames)", "")) items.append(("1", f"X ({orig_shape[0]} frames)", ""))
items.append(("2", f"Y (Get {orig_shape[1]} frames)", "")) items.append(("2", f"Y ({orig_shape[1]} frames)", ""))
items.append(("3", f"Z (Get {orig_shape[2]} frames)", "")) items.append(("3", f"Z ({orig_shape[2]} frames)", ""))
return items return items
@@ -681,12 +689,9 @@ class ImportVolumetricDataDialog(bpy.types.Operator):
return {'FINISHED'} return {'FINISHED'}
def invoke(self, context, event): def invoke(self, context, event):
if self.read_as == "label": layer_kind = self.read_as.capitalize()
volume_dtype = "Label" title = f"Add to **{self.container_obj_name}**, As {layer_kind}" \
elif self.read_as == "scalar": if self.container_obj_name != "" else f"Init a Container, As {layer_kind}"
volume_dtype = "Scalar"
title = f"As {volume_dtype} Opitons (Add to Container: {self.container_obj_name})" \
if self.container_obj_name != "" else f"As {volume_dtype} Options (Init a Container)"
context.window_manager.invoke_props_dialog(self, context.window_manager.invoke_props_dialog(self,
width=500, width=500,
title=title) title=title)

View File

@@ -232,7 +232,6 @@ class CombineLabels(bpy.types.Operator, LabelOperator):
return {'FINISHED'} return {'FINISHED'}
base_obj = label_objs[0] base_obj = label_objs[0]
label_objs = label_objs[1:] label_objs = label_objs[1:]
container_obj = base_obj.parent
base_layer = obj_to_layer(base_obj) base_layer = obj_to_layer(base_obj)
modified_layer = base_layer.copy() modified_layer = base_layer.copy()
@@ -241,7 +240,7 @@ class CombineLabels(bpy.types.Operator, LabelOperator):
for label_obj in label_objs: for label_obj in label_objs:
label_layer = obj_to_layer(label_obj) label_layer = obj_to_layer(label_obj)
label_layer.resize(base_layer.shape) label_layer.resize(base_layer.shape)
modified_layer.data = np.maximum(base_layer.data, label_layer.data) modified_layer.data = np.maximum(modified_layer.data, label_layer.data)
label_names.append(label_layer.name) label_names.append(label_layer.name)
modified_layer.name = f"{'-'.join(label_names)}-Combined" modified_layer.name = f"{'-'.join(label_names)}-Combined"

View File

@@ -61,8 +61,8 @@ class SaveStagedData(bpy.types.Operator):
bl_label = "Save Staged Data" bl_label = "Save Staged Data"
bl_description = "Save all staged data in this file for sharing" bl_description = "Save all staged data in this file for sharing"
save_layer: bpy.props.BoolProperty( save_cache: bpy.props.BoolProperty(
name="Save Layer VDB Cache", name="Save Layer Caches",
default=True, default=True,
) # type: ignore ) # type: ignore
@@ -112,11 +112,11 @@ class SaveStagedData(bpy.types.Operator):
self.report({"INFO"}, f"Successfully saved to {output_path}") self.report({"INFO"}, f"Successfully saved to {output_path}")
if self.save_layer: if self.save_cache:
fails = [] fails = []
for layer in get_all_layer_objs(): for layer in get_all_layer_objs():
try: try:
save_layer(layer, self.cache_dir) save_layer_cache(layer, self.cache_dir)
except: except:
fails.append(layer) fails.append(layer)
@@ -140,7 +140,7 @@ class SaveStagedData(bpy.types.Operator):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
panel = layout.box() panel = layout.box()
panel.prop(self, "save_layer") panel.prop(self, "save_cache")
panel.prop(self, "cache_dir") panel.prop(self, "cache_dir")
panel = layout.box() panel = layout.box()
panel.prop(self, "save_lib") panel.prop(self, "save_lib")
@@ -148,7 +148,7 @@ class SaveStagedData(bpy.types.Operator):
class SaveCaches(bpy.types.Operator): class SaveCaches(bpy.types.Operator):
bl_idname = "bioxelnodes.save_layers" bl_idname = "bioxelnodes.save_caches"
bl_label = "Save Caches" bl_label = "Save Caches"
bl_description = "Save Container's caches to directory." bl_description = "Save Container's caches to directory."

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bioxelnodes" name = "bioxelnodes"
version = "0.3.1" version = "0.3.3"
description = "" description = ""
authors = ["Ma Nan <icrdr2010@outlook.com>"] authors = ["Ma Nan <icrdr2010@outlook.com>"]
license = "MIT" license = "MIT"