feat: Update version to 2.0.1, refactor asset library handling, and enhance layer import process
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
73
src/bioxelnodes/asset_library.py
Normal file
73
src/bioxelnodes/asset_library.py
Normal 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
|
||||
@@ -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>"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
259
src/bioxelnodes/operators/io_worker.py
Normal file
259
src/bioxelnodes/operators/io_worker.py
Normal 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()
|
||||
@@ -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)"""
|
||||
|
||||
@@ -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 # 复用原操作的图标定义
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user