feat: Update version to 2.0.1, refactor asset library handling, and enhance layer import process

This commit is contained in:
2026-07-07 12:38:49 +08:00
parent 720233b14c
commit 7b9151046d
9 changed files with 567 additions and 372 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "bioxelnodes" name = "bioxelnodes"
version = "2.0.0" version = "2.0.1"
description = "" description = ""
authors = [{ name = "MaNan", email = "icrdr2010@outlook.com" }] authors = [{ name = "MaNan", email = "icrdr2010@outlook.com" }]
requires-python = ">=3.11.0,<3.12.dev0" requires-python = ">=3.11.0,<3.12.dev0"

View File

@@ -1,7 +1,7 @@
import bpy import bpy
import bpy.utils.previews as previews import bpy.utils.previews as previews
from .constants import NODE_LIB_DIRPATH, PREVIEW_COLLECTIONS from .constants import PREVIEW_COLLECTIONS
from .props import _bioxel_layer_items, _update_layer_gallery, _update_snapshot_z from .props import _bioxel_layer_items, _update_layer_gallery, _update_snapshot_z
from . import auto_load from . import auto_load
@@ -10,37 +10,6 @@ from . import menus
auto_load.init() auto_load.init()
def add_asset_library():
"""Add Bioxel asset library, ensuring only one exists by removing duplicates first."""
if not NODE_LIB_DIRPATH.exists():
print(f"Node library path does not exist - {NODE_LIB_DIRPATH}")
return
lib_path_str = str(NODE_LIB_DIRPATH)
prefs = bpy.context.preferences.filepaths.asset_libraries
# Add new library
new_lib = prefs.new()
new_lib.name = "O Bioxel"
new_lib.path = lib_path_str
new_lib.import_method = "PACK"
print(f"Add Bioxel Nodes library: {lib_path_str}")
def remove_asset_library_if_exists():
"""Remove the Bioxel asset library entry if it was added (by path or name)."""
lib_path_str = str(NODE_LIB_DIRPATH)
prefs = bpy.context.preferences.filepaths.asset_libraries
for lib in list(prefs):
try:
if "Bioxel" in lib.name or lib.path == lib_path_str:
prefs.remove(lib)
except Exception:
continue
def register(): def register():
pcoll = previews.new() pcoll = previews.new()
pcoll.layer_previews = () pcoll.layer_previews = ()
@@ -72,12 +41,9 @@ def register():
auto_load.register() auto_load.register()
menus.add() menus.add()
remove_asset_library_if_exists()
add_asset_library()
def unregister(): def unregister():
remove_asset_library_if_exists()
menus.remove() menus.remove()
auto_load.unregister() auto_load.unregister()

View File

@@ -0,0 +1,73 @@
import bpy
from pathlib import Path
from .constants import NODE_LIB_DIRPATH
ASSET_LIBRARY_NAME = "O Bioxel"
def _asset_library_path():
return str(NODE_LIB_DIRPATH)
def _normalized_path(path):
return Path(bpy.path.abspath(path)).resolve()
def _asset_libraries():
return bpy.context.preferences.filepaths.asset_libraries
def get_bioxel_asset_library():
lib_path = _normalized_path(_asset_library_path())
for lib in _asset_libraries():
try:
if _normalized_path(lib.path) == lib_path:
return lib
except Exception:
continue
return None
def has_bioxel_asset_library():
return get_bioxel_asset_library() is not None
def add_bioxel_asset_library():
if not NODE_LIB_DIRPATH.exists():
raise FileNotFoundError(f"Node library path does not exist: {NODE_LIB_DIRPATH}")
prefs = _asset_libraries()
lib = get_bioxel_asset_library()
if lib is None:
for item in prefs:
try:
if item.name == ASSET_LIBRARY_NAME:
lib = item
break
except Exception:
continue
if lib is None:
lib = prefs.new()
lib.name = ASSET_LIBRARY_NAME
lib.path = _asset_library_path()
lib.import_method = "PACK"
return lib
def remove_bioxel_asset_library_if_exists():
lib_path = _normalized_path(_asset_library_path())
prefs = _asset_libraries()
for lib in list(prefs):
try:
if "Bioxel" in lib.name or _normalized_path(lib.path) == lib_path:
prefs.remove(lib)
except Exception:
continue

View File

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

View File

@@ -227,20 +227,18 @@ def set_layer_caches(layers_data: List[Dict[str, Any]]):
layers_text.write(json.dumps(layers_data, indent=4)) layers_text.write(json.dumps(layers_data, indent=4))
def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]: def save_layers_to_cache(layers: List[Layer], cache_dir: str) -> List[Dict[str, Any]]:
""" """
Save multiple Layer objects into the internal layers text datablock. Save multiple Layer objects into cache folders.
For each layer: For each layer:
- Generates a unique cache id. - Generates a unique cache id.
- Writes VDB files and a low-resolution snapshot (.npy) plus PNG slices under cache_dir/<cache_id>/. - Writes VDB files and a low-resolution snapshot (.npy) plus PNG slices under cache_dir/<cache_id>/.
- Appends an entry describing the layer into the b i o x e l _ l a y e r s text datablock.
Returns: Returns:
- List of generated cache ids for the saved layers. - List of layer cache metadata dictionaries.
""" """
existing_data = get_layer_caches() cache_infos = []
added_ids = []
cache_dir_path = Path(cache_dir) cache_dir_path = Path(cache_dir)
cache_dir_path.mkdir(parents=True, exist_ok=True) cache_dir_path.mkdir(parents=True, exist_ok=True)
@@ -270,8 +268,22 @@ def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]:
"snapshot_z": 0.5, "snapshot_z": 0.5,
} }
cache_infos.append(cache_info)
return cache_infos
def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]:
"""
Save multiple Layer objects into cache folders and the internal layers text datablock.
"""
existing_data = get_layer_caches()
cache_infos = save_layers_to_cache(layers, cache_dir)
added_ids = []
for cache_info in cache_infos:
existing_data.append(cache_info) existing_data.append(cache_info)
added_ids.append(cache_id) added_ids.append(cache_info["id"])
set_layer_caches(existing_data) set_layer_caches(existing_data)
print(f"Successfully added {len(added_ids)} layers to the internal text datablock") print(f"Successfully added {len(added_ids)} layers to the internal text datablock")

View File

@@ -1,22 +1,22 @@
import json
import math import math
import shutil import shutil
import threading import subprocess
import tempfile
from pathlib import Path from pathlib import Path
import bpy import bpy
import numpy as np import numpy as np
import SimpleITK as sitk import SimpleITK as sitk
import transforms3d
# KeyboardInterrupt replaced with built-in KeyboardInterrupt # KeyboardInterrupt replaced with built-in KeyboardInterrupt
from ..props import BIOXEL_Series from ..props import BIOXEL_Series
from ..utils import get_layer_obj, wrapped_label from ..utils import get_layer_obj, wrapped_label
from ..bioxel.layer import Layer from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext
from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data
from ..utils import get_cache_dir, progress_update, progress_bar from ..utils import get_cache_dir, progress_update, progress_bar
from ..layer import get_layer_caches, save_layers_to_json from ..layer import get_layer_caches, set_layer_caches
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):
@@ -43,6 +43,90 @@ def get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0):
return size return size
def write_worker_json(path: Path, data):
path.write_text(json.dumps(data), encoding="utf-8")
def read_worker_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def remove_progress_bar_safe():
try:
bpy.types.STATUSBAR_HT_header.remove(progress_bar)
except Exception:
pass
def start_worker_process(owner, command: str, payload: dict):
job_dir = Path(tempfile.mkdtemp(prefix="bioxel_import_", dir=str(get_cache_dir())))
config_path = job_dir / "config.json"
progress_path = job_dir / "progress.json"
result_path = job_dir / "result.json"
cancel_path = job_dir / "cancel"
log_path = job_dir / "worker.log"
config = {
**payload,
"command": command,
"progress_path": str(progress_path),
"result_path": str(result_path),
"cancel_path": str(cancel_path),
}
write_worker_json(config_path, config)
write_worker_json(progress_path, {"factor": 0.0, "text": "Starting..."})
worker_path = Path(__file__).with_name("io_worker.py")
cmd = [
bpy.app.binary_path,
"--background",
"--factory-startup",
"--python",
str(worker_path),
"--",
str(config_path),
]
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
log_file = log_path.open("w", encoding="utf-8")
try:
owner.process = subprocess.Popen(
cmd,
stdout=log_file,
stderr=subprocess.STDOUT,
creationflags=creationflags,
)
finally:
log_file.close()
owner.job_dir = job_dir
owner.progress_path = progress_path
owner.result_path = result_path
owner.cancel_path = cancel_path
owner.log_path = log_path
def cancel_worker_process(owner, context):
owner.is_cancelled = True
try:
owner.cancel_path.write_text("cancel", encoding="utf-8")
except Exception:
pass
progress_update(context, 0.0, "Canceling...")
def update_worker_progress(owner, context):
progress = read_worker_json(owner.progress_path)
if progress:
progress_update(
context,
float(progress.get("factor", 0.0)),
progress.get("text", ""),
)
""" """
ImportData -> ParseVolumetricData -> ImportDataDialog ImportData -> ParseVolumetricData -> ImportDataDialog
start import parse data execute import start import parse data execute import
@@ -167,7 +251,12 @@ class ParseVolumetricData(bpy.types.Operator):
meta = None meta = None
label_count = 0 label_count = 0
dtype = None dtype = None
thread = None process = None
job_dir = None
progress_path = None
result_path = None
cancel_path = None
log_path = None
_timer = None _timer = None
progress: bpy.props.FloatProperty( progress: bpy.props.FloatProperty(
@@ -202,97 +291,64 @@ class ParseVolumetricData(bpy.types.Operator):
def execute(self, context): def execute(self, context):
print("Collecting Meta Data...") print("Collecting Meta Data...")
def parse_volumetric_data_func(self, context, cancel):
def progress_callback(factor, text):
if cancel():
raise KeyboardInterrupt("Cancelled by user")
progress_update(context, factor, text)
try:
series_id = self.series_id if self.series_id != "empty" else ""
data, meta = parse_volumetric_data(
data_file=self.filepath,
series_id=series_id,
progress_callback=progress_callback,
)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
if cancel():
return
self.meta = meta
self.label_count = int(np.max(data))
self.dtype = data.dtype
# Init cancel flag
self.is_cancelled = False self.is_cancelled = False
self.has_error = None self.has_error = None
self.meta = None
self.label_count = 0
self.dtype = None
# Create the thread start_worker_process(
self.thread = threading.Thread( self,
target=parse_volumetric_data_func, "read_meta",
args=(self, context, lambda: self.is_cancelled), {
"filepath": self.filepath,
"series_id": self.series_id,
},
) )
# Start the thread
self.thread.start()
# Add a timmer for modal
self._timer = context.window_manager.event_timer_add( self._timer = context.window_manager.event_timer_add(
time_step=0.1, window=context.window time_step=0.1, window=context.window
) )
# Append progress bar to status bar
bpy.types.STATUSBAR_HT_header.append(progress_bar) bpy.types.STATUSBAR_HT_header.append(progress_bar)
# Start modal handler
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
def modal(self, context, event): def modal(self, context, event):
# Check if user press 'ESC'
if event.type == "ESC": if event.type == "ESC":
self.is_cancelled = True cancel_worker_process(self, context)
progress_update(context, 0.0, "Canceling...")
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
# Check if is the timer time
if event.type != "TIMER": if event.type != "TIMER":
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
# Force update status bar
bpy.context.workspace.status_text_set_internal(None) bpy.context.workspace.status_text_set_internal(None)
update_worker_progress(self, context)
# Check if thread is still running if self.process.poll() is None:
if self.thread.is_alive():
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
# Release the thread
self.thread.join()
# Remove the timer
context.window_manager.event_timer_remove(self._timer) context.window_manager.event_timer_remove(self._timer)
# Remove the progress bar from status bar remove_progress_bar_safe()
bpy.types.STATUSBAR_HT_header.remove(progress_bar)
progress_update(context, 1.0) progress_update(context, 1.0)
# Check if thread is cancelled by user result = read_worker_json(self.result_path)
if self.is_cancelled: if self.is_cancelled or (result and result.get("cancelled")):
self.report({"WARNING"}, "Canncelled by user.") self.report({"WARNING"}, "Canncelled by user.")
return {"CANCELLED"} return {"CANCELLED"}
# Check if thread is cancelled by user if not result:
if self.has_error: self.report({"ERROR"}, f"Import worker failed. See log: {self.log_path}")
raise self.has_error
# Check if has return
if self.meta is None:
self.report({"ERROR"}, "Some thing went wrong.")
return {"CANCELLED"} return {"CANCELLED"}
# If not canncelled... if not result.get("ok"):
print(result.get("traceback", ""))
self.report({"ERROR"}, result.get("error", "Import worker failed."))
return {"CANCELLED"}
self.meta = result["meta"]
self.label_count = int(result["label_count"])
self.dtype = np.dtype(result["dtype"])
for key, value in self.meta.items(): for key, value in self.meta.items():
print(f"{key}: {value}") print(f"{key}: {value}")
@@ -310,8 +366,6 @@ class ParseVolumetricData(bpy.types.Operator):
min_log10 = math.floor(math.log10(min(*orig_spacing))) min_log10 = math.floor(math.log10(min(*orig_spacing)))
max_log10 = math.floor(math.log10(max(*orig_spacing))) max_log10 = math.floor(math.log10(max(*orig_spacing)))
# min_space = min(*orig_spacing)
# max_space = max(*orig_spacing)
if orig_spacing[2] == 1 and min_log10 < -1: if orig_spacing[2] == 1 and min_log10 < -1:
orig_spacing = ( orig_spacing = (
@@ -493,8 +547,14 @@ class ImportDataDialog(bpy.types.Operator):
bl_description = "Import Volumetric Data as Layer" bl_description = "Import Volumetric Data as Layer"
bl_options = {"UNDO"} bl_options = {"UNDO"}
layers = None cache_infos = None
thread = None added_ids = None
process = None
job_dir = None
progress_path = None
result_path = None
cancel_path = None
log_path = None
_timer = None _timer = None
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
@@ -546,253 +606,31 @@ class ImportDataDialog(bpy.types.Operator):
) # type: ignore ) # type: ignore
def execute(self, context): def execute(self, context):
def import_volumetric_data_func(self, context, cancel):
progress_update(context, 0.0, "Parsing Volumetirc Data...")
def progress_callback(factor, text):
if cancel():
raise KeyboardInterrupt("Cancelled by user")
progress_update(context, factor * 0.2, text)
def progress_callback_factory(layer_name, progress, progress_step):
def progress_callback(frame, total):
if cancel():
raise KeyboardInterrupt("Cancelled by user")
sub_progress_step = progress_step / total
sub_progress = progress + frame * sub_progress_step
progress_update(
context,
sub_progress,
f"Processing {layer_name} Frame {frame+1}...",
)
print(f"Processing {layer_name} Frame {frame+1}...")
return progress_callback
try:
data, meta = parse_volumetric_data(
data_file=self.filepath,
series_id=self.series_id,
progress_callback=progress_callback,
)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
if cancel():
return
shape = get_layer_shape(
self.bioxel_size, self.orig_shape, self.orig_spacing
)
mat_scale = transforms3d.zooms.zfdir2aff(self.bioxel_size)
affine = np.dot(meta["affine"], mat_scale)
kind = self.read_as.lower()
if cancel():
return
# change shape as sequence or not
if self.frame_source == "-1":
data = data[0:1, :, :, :, :]
elif self.frame_source == "0":
# frame as frame
pass
elif self.frame_source == "1":
# X as frame
data = data.transpose(1, 0, 2, 3, 4)
shape = (1, shape[1], shape[2])
elif self.frame_source == "2":
# Y as frame
data = data.transpose(2, 1, 0, 3, 4)
shape = (shape[0], 1, shape[2])
elif self.frame_source == "3":
# Z as frame
data = data.transpose(3, 1, 2, 0, 4)
shape = (shape[0], shape[1], 1)
else:
# channel as frame
data = data.transpose(4, 1, 2, 3, 0)
layers = []
if kind == "label":
name = self.layer_name or "Label"
data = data.astype(int)
label_count = int(np.max(data))
progress_step = 0.7 / label_count
for i in range(label_count):
if cancel():
return
name_i = f"{name}_{i+1}"
progress = 0.2 + i * progress_step
progress_update(context, progress, f"Processing {name_i}...")
progress_callback = progress_callback_factory(
name_i, progress, progress_step
)
label_data = data == np.full_like(data, i + 1)
# label_data = label_data.astype(np.float32)
try:
layer = Layer(data=label_data, name=name_i, kind=kind)
layer.resize(
shape=shape,
smooth=self.smooth,
progress_callback=progress_callback,
)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
if kind == "color":
if np.issubdtype(np.uint8, data.dtype):
data = np.multiply(data, 1.0 / 256, dtype=np.float32)
elif data.dtype.kind in ["u", "i"]:
# Convert the normalized array to float dtype
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
# Avoid division by zero if all values are the same
if max_val != min_val:
# Normalize the array to the range (0,1)
data = (data - min_val) / (max_val - min_val)
else:
# If all values are the same, the normalized array will be all zeros
data = np.zeros_like(data, dtype=np.float32)
else:
data = data.astype(np.float32)
# Gamma Correct
# data = data ** 2.2
name = self.layer_name or "Color"
if data.shape[4] == 1:
data = np.repeat(data, repeats=3, axis=4)
elif data.shape[4] == 2:
d_shape = list(data.shape)
d_shape = d_shape[:4] + [1]
zore = np.zeros(tuple(d_shape), dtype=np.float32)
data = np.concatenate((data, zore), axis=-1)
elif data.shape[4] > 3:
data = data[:, :, :, :, :3]
if cancel():
return
progress_update(context, 0.2, f"Processing {name}...")
progress_callback = progress_callback_factory(name, 0.2, 0.7)
try:
layer = Layer(data=data, name=name, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
elif kind == "scalar":
name = self.layer_name or "Scalar"
if self.remap:
# Convert the normalized array to float dtype
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
# Avoid division by zero if all values are the same
if max_val != min_val:
# Normalize the array to the range (0,1)
data = (data - min_val) / (max_val - min_val)
else:
# If all values are the same, the normalized array will be all zeros
data = np.zeros_like(data, dtype=np.float32)
if self.split_channel:
progress_step = 0.7 / self.channel_count
for i in range(self.channel_count):
if cancel():
return
name_i = f"{name}_{i+1}"
progress = 0.2 + i * progress_step
progress_update(context, progress, f"Processing {name_i}...")
progress_callback = progress_callback_factory(
name_i, progress, progress_step
)
try:
layer = Layer(
data=data[:, :, :, :, i : i + 1], name=name_i, kind=kind
)
layer.resize(
shape=shape, progress_callback=progress_callback
)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
else:
if cancel():
return
progress_update(context, 0.2, f"Processing {name}...")
progress_callback = progress_callback_factory(name, 0.2, 0.7)
try:
layer = Layer(data=data, name=name, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
if cancel():
return
self.layers = layers
progress_update(context, 0.9, "Creating Layers...")
self.is_cancelled = False self.is_cancelled = False
self.has_error = None self.has_error = None
self.cache_infos = None
self.added_ids = None
self.thread = threading.Thread( start_worker_process(
target=import_volumetric_data_func, self,
args=(self, context, lambda: self.is_cancelled), "import_layers",
{
"filepath": self.filepath,
"series_id": self.series_id,
"cache_dir": str(get_cache_dir() / "layers"),
"layer_name": self.layer_name,
"orig_shape": list(self.orig_shape),
"orig_spacing": list(self.orig_spacing),
"bioxel_size": self.bioxel_size,
"read_as": self.read_as,
"frame_source": self.frame_source,
"smooth": self.smooth,
"remap": self.remap,
"split_channel": self.split_channel,
"channel_count": self.channel_count,
},
) )
self.thread.start()
self._timer = context.window_manager.event_timer_add( self._timer = context.window_manager.event_timer_add(
time_step=0.1, window=context.window time_step=0.1, window=context.window
) )
@@ -803,41 +641,49 @@ class ImportDataDialog(bpy.types.Operator):
def modal(self, context, event): def modal(self, context, event):
if event.type == "ESC": if event.type == "ESC":
self.is_cancelled = True cancel_worker_process(self, context)
progress_update(context, 0.0, "Canceling...")
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
if event.type != "TIMER": if event.type != "TIMER":
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
bpy.context.workspace.status_text_set_internal(None) bpy.context.workspace.status_text_set_internal(None)
if self.thread.is_alive(): update_worker_progress(self, context)
if self.process.poll() is None:
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
self.thread.join()
context.window_manager.event_timer_remove(self._timer) context.window_manager.event_timer_remove(self._timer)
bpy.types.STATUSBAR_HT_header.remove(progress_bar) remove_progress_bar_safe()
progress_update(context, 1.0) progress_update(context, 1.0)
if self.is_cancelled: result = read_worker_json(self.result_path)
if self.is_cancelled or (result and result.get("cancelled")):
self.report({"WARNING"}, "Canncelled by user.") self.report({"WARNING"}, "Canncelled by user.")
return {"CANCELLED"} return {"CANCELLED"}
# Check if thread is cancelled by user if not result:
if self.has_error: self.report({"ERROR"}, f"Import worker failed. See log: {self.log_path}")
raise self.has_error return {"CANCELLED"}
# Check if has return if not result.get("ok"):
if self.layers is None: print(result.get("traceback", ""))
self.report({"ERROR"}, result.get("error", "Import worker failed."))
return {"CANCELLED"}
self.cache_infos = result.get("cache_infos")
self.added_ids = result.get("added_ids")
if not self.cache_infos or not self.added_ids:
self.report({"ERROR"}, "Some thing went wrong.") self.report({"ERROR"}, "Some thing went wrong.")
return {"CANCELLED"} return {"CANCELLED"}
is_first_import = len(get_layer_caches()) == 0 is_first_import = len(get_layer_caches()) == 0
ids = save_layers_to_json(self.layers, cache_dir=get_cache_dir() / "layers") existing_data = get_layer_caches()
existing_data.extend(self.cache_infos)
set_layer_caches(existing_data)
setattr(context.window_manager, "bioxel_layer_library", ids[-1]) setattr(context.window_manager, "bioxel_layer_library", self.added_ids[-1])
# Change render setting for better result
if is_first_import: if is_first_import:
bpy.ops.bioxel.render_setting_preset("EXEC_DEFAULT", preset="balance") bpy.ops.bioxel.render_setting_preset("EXEC_DEFAULT", preset="balance")

View File

@@ -0,0 +1,259 @@
import json
import sys
import traceback
from pathlib import Path
PACKAGE_PARENT = Path(__file__).resolve().parents[2]
if str(PACKAGE_PARENT) not in sys.path:
sys.path.insert(0, str(PACKAGE_PARENT))
import numpy as np
import transforms3d
from bioxelnodes.bioxel.layer import Layer
from bioxelnodes.bioxel.parse import parse_volumetric_data
from bioxelnodes.layer import save_layers_to_cache
def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple):
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 write_json(path: Path, data):
temp_path = path.with_suffix(path.suffix + ".tmp")
temp_path.write_text(json.dumps(data), encoding="utf-8")
temp_path.replace(path)
def check_cancel(cancel_path: Path):
if cancel_path.exists():
raise KeyboardInterrupt("Cancelled by user")
def make_progress_writer(progress_path: Path, cancel_path: Path, scale=1.0, offset=0.0):
def progress_callback(factor, text):
check_cancel(cancel_path)
write_json(
progress_path,
{
"factor": offset + factor * scale,
"text": text,
},
)
return progress_callback
def progress_callback_factory(progress_path: Path, cancel_path: Path, layer_name, progress, progress_step):
def progress_callback(frame, total):
check_cancel(cancel_path)
sub_progress_step = progress_step / total
sub_progress = progress + frame * sub_progress_step
text = f"Processing {layer_name} Frame {frame+1}..."
write_json(progress_path, {"factor": sub_progress, "text": text})
print(text)
return progress_callback
def read_meta(config, progress_path: Path, cancel_path: Path):
progress_callback = make_progress_writer(progress_path, cancel_path)
series_id = config["series_id"] if config["series_id"] != "empty" else ""
data, meta = parse_volumetric_data(
data_file=config["filepath"],
series_id=series_id,
progress_callback=progress_callback,
)
check_cancel(cancel_path)
return {
"meta": {
**meta,
"spacing": list(meta["spacing"]),
"affine": meta["affine"].tolist(),
"xyz_shape": list(meta["xyz_shape"]),
},
"label_count": int(np.max(data)),
"dtype": data.dtype.str,
"dtype_kind": data.dtype.kind,
}
def build_layers(config, progress_path: Path, cancel_path: Path):
write_json(progress_path, {"factor": 0.0, "text": "Parsing Volumetirc Data..."})
progress_callback = make_progress_writer(progress_path, cancel_path, scale=0.2)
data, meta = parse_volumetric_data(
data_file=config["filepath"],
series_id=config["series_id"],
progress_callback=progress_callback,
)
check_cancel(cancel_path)
orig_shape = tuple(config["orig_shape"])
orig_spacing = tuple(config["orig_spacing"])
shape = get_layer_shape(config["bioxel_size"], orig_shape, orig_spacing)
mat_scale = transforms3d.zooms.zfdir2aff(config["bioxel_size"])
affine = np.dot(meta["affine"], mat_scale)
kind = config["read_as"].lower()
check_cancel(cancel_path)
frame_source = config["frame_source"]
if frame_source == "-1":
data = data[0:1, :, :, :, :]
elif frame_source == "0":
pass
elif frame_source == "1":
data = data.transpose(1, 0, 2, 3, 4)
shape = (1, shape[1], shape[2])
elif frame_source == "2":
data = data.transpose(2, 1, 0, 3, 4)
shape = (shape[0], 1, shape[2])
elif frame_source == "3":
data = data.transpose(3, 1, 2, 0, 4)
shape = (shape[0], shape[1], 1)
else:
data = data.transpose(4, 1, 2, 3, 0)
layers = []
if kind == "label":
name = config["layer_name"] or "Label"
data = data.astype(int)
label_count = int(np.max(data))
progress_step = 0.7 / label_count
for i in range(label_count):
check_cancel(cancel_path)
name_i = f"{name}_{i+1}"
progress = 0.2 + i * progress_step
write_json(progress_path, {"factor": progress, "text": f"Processing {name_i}..."})
progress_callback = progress_callback_factory(
progress_path, cancel_path, name_i, progress, progress_step
)
label_data = data == np.full_like(data, i + 1)
layer = Layer(data=label_data, name=name_i, kind=kind)
layer.resize(
shape=shape,
smooth=config["smooth"],
progress_callback=progress_callback,
)
layer.affine = affine
layers.append(layer)
if kind == "color":
if np.issubdtype(np.uint8, data.dtype):
data = np.multiply(data, 1.0 / 256, dtype=np.float32)
elif data.dtype.kind in ["u", "i"]:
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
if max_val != min_val:
data = (data - min_val) / (max_val - min_val)
else:
data = np.zeros_like(data, dtype=np.float32)
else:
data = data.astype(np.float32)
name = config["layer_name"] or "Color"
if data.shape[4] == 1:
data = np.repeat(data, repeats=3, axis=4)
elif data.shape[4] == 2:
d_shape = list(data.shape)
d_shape = d_shape[:4] + [1]
zore = np.zeros(tuple(d_shape), dtype=np.float32)
data = np.concatenate((data, zore), axis=-1)
elif data.shape[4] > 3:
data = data[:, :, :, :, :3]
check_cancel(cancel_path)
write_json(progress_path, {"factor": 0.2, "text": f"Processing {name}..."})
progress_callback = progress_callback_factory(progress_path, cancel_path, name, 0.2, 0.7)
layer = Layer(data=data, name=name, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
elif kind == "scalar":
name = config["layer_name"] or "Scalar"
if config["remap"]:
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
if max_val != min_val:
data = (data - min_val) / (max_val - min_val)
else:
data = np.zeros_like(data, dtype=np.float32)
if config["split_channel"]:
progress_step = 0.7 / config["channel_count"]
for i in range(config["channel_count"]):
check_cancel(cancel_path)
name_i = f"{name}_{i+1}"
progress = 0.2 + i * progress_step
write_json(progress_path, {"factor": progress, "text": f"Processing {name_i}..."})
progress_callback = progress_callback_factory(
progress_path, cancel_path, name_i, progress, progress_step
)
layer = Layer(data=data[:, :, :, :, i : i + 1], name=name_i, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
else:
check_cancel(cancel_path)
write_json(progress_path, {"factor": 0.2, "text": f"Processing {name}..."})
progress_callback = progress_callback_factory(progress_path, cancel_path, name, 0.2, 0.7)
layer = Layer(data=data, name=name, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
check_cancel(cancel_path)
write_json(progress_path, {"factor": 0.9, "text": "Creating Layers..."})
cache_infos = save_layers_to_cache(layers, config["cache_dir"])
return {"cache_infos": cache_infos, "added_ids": [item["id"] for item in cache_infos]}
def main():
config_path = Path(sys.argv[-1])
config = json.loads(config_path.read_text(encoding="utf-8"))
progress_path = Path(config["progress_path"])
result_path = Path(config["result_path"])
cancel_path = Path(config["cancel_path"])
try:
if config["command"] == "read_meta":
result = read_meta(config, progress_path, cancel_path)
elif config["command"] == "import_layers":
result = build_layers(config, progress_path, cancel_path)
else:
raise ValueError(f"Unknown command: {config['command']}")
write_json(result_path, {"ok": True, **result})
write_json(progress_path, {"factor": 1.0, "text": ""})
except KeyboardInterrupt:
write_json(result_path, {"ok": False, "cancelled": True})
except Exception as e:
write_json(
result_path,
{
"ok": False,
"error": str(e),
"traceback": traceback.format_exc(),
},
)
if __name__ == "__main__":
main()

View File

@@ -5,6 +5,7 @@ import bpy
from pathlib import Path from pathlib import Path
import shutil import shutil
from ..asset_library import add_bioxel_asset_library
from ..constants import LATEST_NODE_LIB_PATH from ..constants import LATEST_NODE_LIB_PATH
from ..utils import ( from ..utils import (
get_all_layer_objs, get_all_layer_objs,
@@ -120,6 +121,27 @@ class Help(bpy.types.Operator):
return {"FINISHED"} return {"FINISHED"}
class AddAssetLibrary(bpy.types.Operator):
bl_idname = "bioxel.add_asset_library"
bl_label = "Add Nodes Library"
bl_description = "Add the bundled O Bioxel asset library"
def execute(self, context):
try:
add_bioxel_asset_library()
except FileNotFoundError as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type == "NODE_EDITOR":
area.tag_redraw()
self.report({"INFO"}, "O Bioxel asset library is ready.")
return {"FINISHED"}
# 定义虚无化切换操作 # 定义虚无化切换操作
class TogglePhantom(bpy.types.Operator): class TogglePhantom(bpy.types.Operator):
"""Toggle object phantom state (ray invisible, no shadows, semi-transparent wireframe)""" """Toggle object phantom state (ray invisible, no shadows, semi-transparent wireframe)"""

View File

@@ -1,11 +1,12 @@
from pathlib import Path from pathlib import Path
import bpy import bpy
from .asset_library import has_bioxel_asset_library
from .node import get_layer_nodes, get_main_node_group from .node import get_layer_nodes, get_main_node_group
from .utils import load_icon from .utils import load_icon
from .layer import get_layer_caches from .layer import get_layer_caches
from .operators.io import ImportAsColor, ImportAsLabel, ImportAsScalar, ImportData from .operators.io import ImportAsColor, ImportAsLabel, ImportAsScalar, ImportData
from .operators.misc import Help, RenderSettingPreset from .operators.misc import AddAssetLibrary, Help, RenderSettingPreset
from .operators.layer import ( from .operators.layer import (
AddLayerNode, AddLayerNode,
DeleteLayer, DeleteLayer,
@@ -54,22 +55,38 @@ class BioxelPanelBase:
bl_region_type = "UI" bl_region_type = "UI"
bl_context = "geometry_node" bl_context = "geometry_node"
bl_category = "Bioxel Nodes" bl_category = "Bioxel Nodes"
requires_asset_library = True
# try to make Bioxel panels order early (may be respected by Blender) # try to make Bioxel panels order early (may be respected by Blender)
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
node_group = get_main_node_group(context) node_group = get_main_node_group(context)
return node_group is not None if node_group is None:
return False
if not getattr(cls, "requires_asset_library", True):
return True
return has_bioxel_asset_library()
class HeaderPanel(bpy.types.Panel, BioxelPanelBase): class HeaderPanel(bpy.types.Panel, BioxelPanelBase):
bl_label = "Bioxel Nodes" bl_label = "Bioxel Nodes"
bl_idname = "BIOXEL_PT_header_panel" bl_idname = "BIOXEL_PT_header_panel"
bl_order = 0 bl_order = 0
requires_asset_library = False
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
if not has_bioxel_asset_library():
layout.operator(
AddAssetLibrary.bl_idname,
text="Add Nodes Library",
icon="ASSET_MANAGER",
)
return
layout.operator( layout.operator(
Help.bl_idname, text="Help", icon=Help.bl_icon # 复用原操作的图标定义 Help.bl_idname, text="Help", icon=Help.bl_icon # 复用原操作的图标定义
) )