From 7b9151046d6a603b56ae3057a81f14dd5bcaa15b Mon Sep 17 00:00:00 2001 From: nan Date: Tue, 7 Jul 2026 12:38:49 +0800 Subject: [PATCH] feat: Update version to 2.0.1, refactor asset library handling, and enhance layer import process --- pyproject.toml | 2 +- src/bioxelnodes/__init__.py | 36 +- src/bioxelnodes/asset_library.py | 73 ++++ src/bioxelnodes/blender_manifest.toml | 2 +- src/bioxelnodes/layer.py | 26 +- src/bioxelnodes/operators/io.py | 498 +++++++++---------------- src/bioxelnodes/operators/io_worker.py | 259 +++++++++++++ src/bioxelnodes/operators/misc.py | 22 ++ src/bioxelnodes/panels.py | 21 +- 9 files changed, 567 insertions(+), 372 deletions(-) create mode 100644 src/bioxelnodes/asset_library.py create mode 100644 src/bioxelnodes/operators/io_worker.py diff --git a/pyproject.toml b/pyproject.toml index 20d2e38..8e05e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/bioxelnodes/__init__.py b/src/bioxelnodes/__init__.py index d807684..d7046f0 100644 --- a/src/bioxelnodes/__init__.py +++ b/src/bioxelnodes/__init__.py @@ -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() diff --git a/src/bioxelnodes/asset_library.py b/src/bioxelnodes/asset_library.py new file mode 100644 index 0000000..b5ba70f --- /dev/null +++ b/src/bioxelnodes/asset_library.py @@ -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 diff --git a/src/bioxelnodes/blender_manifest.toml b/src/bioxelnodes/blender_manifest.toml index c51b76a..db7e630 100644 --- a/src/bioxelnodes/blender_manifest.toml +++ b/src/bioxelnodes/blender_manifest.toml @@ -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 " diff --git a/src/bioxelnodes/layer.py b/src/bioxelnodes/layer.py index a3926a0..049018b 100644 --- a/src/bioxelnodes/layer.py +++ b/src/bioxelnodes/layer.py @@ -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//. - - 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") diff --git a/src/bioxelnodes/operators/io.py b/src/bioxelnodes/operators/io.py index 96e1b5f..b930507 100644 --- a/src/bioxelnodes/operators/io.py +++ b/src/bioxelnodes/operators/io.py @@ -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") diff --git a/src/bioxelnodes/operators/io_worker.py b/src/bioxelnodes/operators/io_worker.py new file mode 100644 index 0000000..3041583 --- /dev/null +++ b/src/bioxelnodes/operators/io_worker.py @@ -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() diff --git a/src/bioxelnodes/operators/misc.py b/src/bioxelnodes/operators/misc.py index ea0efbe..a61541e 100644 --- a/src/bioxelnodes/operators/misc.py +++ b/src/bioxelnodes/operators/misc.py @@ -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)""" diff --git a/src/bioxelnodes/panels.py b/src/bioxelnodes/panels.py index eee3013..dd8308b 100644 --- a/src/bioxelnodes/panels.py +++ b/src/bioxelnodes/panels.py @@ -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 # 复用原操作的图标定义 )