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]
name = "bioxelnodes"
version = "2.0.0"
version = "2.0.1"
description = ""
authors = [{ name = "MaNan", email = "icrdr2010@outlook.com" }]
requires-python = ">=3.11.0,<3.12.dev0"

View File

@@ -1,7 +1,7 @@
import bpy
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 . import auto_load
@@ -10,37 +10,6 @@ from . import menus
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():
pcoll = previews.new()
pcoll.layer_previews = ()
@@ -72,12 +41,9 @@ def register():
auto_load.register()
menus.add()
remove_asset_library_if_exists()
add_asset_library()
def unregister():
remove_asset_library_if_exists()
menus.remove()
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"
id = "bioxelnodes"
version = "2.0.0"
version = "2.0.1"
name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender"
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))
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:
- Generates a unique 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:
- List of generated cache ids for the saved layers.
- List of layer cache metadata dictionaries.
"""
existing_data = get_layer_caches()
added_ids = []
cache_infos = []
cache_dir_path = Path(cache_dir)
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,
}
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)
added_ids.append(cache_id)
added_ids.append(cache_info["id"])
set_layer_caches(existing_data)
print(f"Successfully added {len(added_ids)} layers to the internal text datablock")

View File

@@ -1,22 +1,22 @@
import json
import math
import shutil
import threading
import subprocess
import tempfile
from pathlib import Path
import bpy
import numpy as np
import SimpleITK as sitk
import transforms3d
# KeyboardInterrupt replaced with built-in KeyboardInterrupt
from ..props import BIOXEL_Series
from ..utils import get_layer_obj, wrapped_label
from ..bioxel.layer import Layer
from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data
from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext
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):
@@ -43,6 +43,90 @@ def get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0):
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
start import parse data execute import
@@ -167,7 +251,12 @@ class ParseVolumetricData(bpy.types.Operator):
meta = None
label_count = 0
dtype = None
thread = None
process = None
job_dir = None
progress_path = None
result_path = None
cancel_path = None
log_path = None
_timer = None
progress: bpy.props.FloatProperty(
@@ -202,97 +291,64 @@ class ParseVolumetricData(bpy.types.Operator):
def execute(self, context):
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.has_error = None
self.meta = None
self.label_count = 0
self.dtype = None
# Create the thread
self.thread = threading.Thread(
target=parse_volumetric_data_func,
args=(self, context, lambda: self.is_cancelled),
start_worker_process(
self,
"read_meta",
{
"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(
time_step=0.1, window=context.window
)
# Append progress bar to status bar
bpy.types.STATUSBAR_HT_header.append(progress_bar)
# Start modal handler
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context, event):
# Check if user press 'ESC'
if event.type == "ESC":
self.is_cancelled = True
progress_update(context, 0.0, "Canceling...")
cancel_worker_process(self, context)
return {"PASS_THROUGH"}
# Check if is the timer time
if event.type != "TIMER":
return {"PASS_THROUGH"}
# Force update status bar
bpy.context.workspace.status_text_set_internal(None)
update_worker_progress(self, context)
# Check if thread is still running
if self.thread.is_alive():
if self.process.poll() is None:
return {"PASS_THROUGH"}
# Release the thread
self.thread.join()
# Remove the timer
context.window_manager.event_timer_remove(self._timer)
# Remove the progress bar from status bar
bpy.types.STATUSBAR_HT_header.remove(progress_bar)
remove_progress_bar_safe()
progress_update(context, 1.0)
# Check if thread is cancelled by user
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.")
return {"CANCELLED"}
# Check if thread is cancelled by user
if self.has_error:
raise self.has_error
# Check if has return
if self.meta is None:
self.report({"ERROR"}, "Some thing went wrong.")
if not result:
self.report({"ERROR"}, f"Import worker failed. See log: {self.log_path}")
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():
print(f"{key}: {value}")
@@ -310,8 +366,6 @@ class ParseVolumetricData(bpy.types.Operator):
min_log10 = math.floor(math.log10(min(*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:
orig_spacing = (
@@ -493,8 +547,14 @@ class ImportDataDialog(bpy.types.Operator):
bl_description = "Import Volumetric Data as Layer"
bl_options = {"UNDO"}
layers = None
thread = None
cache_infos = None
added_ids = None
process = None
job_dir = None
progress_path = None
result_path = None
cancel_path = None
log_path = None
_timer = None
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
@@ -546,253 +606,31 @@ class ImportDataDialog(bpy.types.Operator):
) # type: ignore
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.has_error = None
self.cache_infos = None
self.added_ids = None
self.thread = threading.Thread(
target=import_volumetric_data_func,
args=(self, context, lambda: self.is_cancelled),
start_worker_process(
self,
"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(
time_step=0.1, window=context.window
)
@@ -803,41 +641,49 @@ class ImportDataDialog(bpy.types.Operator):
def modal(self, context, event):
if event.type == "ESC":
self.is_cancelled = True
progress_update(context, 0.0, "Canceling...")
cancel_worker_process(self, context)
return {"PASS_THROUGH"}
if event.type != "TIMER":
return {"PASS_THROUGH"}
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"}
self.thread.join()
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)
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.")
return {"CANCELLED"}
# Check if thread is cancelled by user
if self.has_error:
raise self.has_error
if not result:
self.report({"ERROR"}, f"Import worker failed. See log: {self.log_path}")
return {"CANCELLED"}
# Check if has return
if self.layers is None:
if not result.get("ok"):
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.")
return {"CANCELLED"}
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:
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
import shutil
from ..asset_library import add_bioxel_asset_library
from ..constants import LATEST_NODE_LIB_PATH
from ..utils import (
get_all_layer_objs,
@@ -120,6 +121,27 @@ class Help(bpy.types.Operator):
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):
"""Toggle object phantom state (ray invisible, no shadows, semi-transparent wireframe)"""

View File

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