42 Commits

Author SHA1 Message Date
Ma Nan
2429eef5e0 Merge pull request #36 from OmooLab/feature/dev
Feature/dev
2024-08-05 15:54:19 +08:00
2563f398da chore: update version 2024-08-05 15:53:03 +08:00
0cad35e2da fix: fail to read all sequence in folder with same name prefix 2024-08-05 14:13:48 +08:00
65edf40164 feat: better drag and drop 2024-08-05 13:48:24 +08:00
c3f88b7846 fix: layer cache cannot be saved via save stage data 2024-08-05 11:27:50 +08:00
Ma Nan
088d9c1edb Merge pull request #35 from OmooLab/feature/dev
fix: macos-arm64 cannot install
2024-08-02 11:35:18 +08:00
a6ec769a9b fix: macos-arm64 cannot install
fix: better layer fill smooth filter
2024-08-02 11:34:35 +08:00
Ma Nan
7430fbf3ef Merge pull request #34 from OmooLab/feature/dev
BREAKING CHANGE: not support 4.1 any more
2024-08-01 15:51:48 +08:00
ce397b35aa feat: more time sequence choice
fix: contrast formulation cause leak of high value
fix: empty series_id cause fail to read
chore: restructure folder
chore!: change layer vars
BREAKING CHANGE: not support 4.1 any more
2024-08-01 15:50:41 +08:00
Ma Nan
caf184c327 Merge pull request #33 from OmooLab/feature/dev
chore: update version
2024-07-23 12:32:06 +08:00
488b05f062 chore: update version 2024-07-23 12:31:33 +08:00
Ma Nan
2447a47269 Merge pull request #32 from OmooLab/feature/dev
docs: update discord link
2024-07-23 12:29:36 +08:00
9380342b4f docs: update discord link 2024-07-23 12:27:52 +08:00
Ma Nan
9b36e9d911 Merge pull request #31 from OmooLab/feature/dev
Feature/dev
2024-07-23 12:05:18 +08:00
c9a961d5e8 fix some import issue 2024-07-23 12:04:30 +08:00
77878888ca fix: missing add .ima to DICOM_EXTS 2024-07-22 09:56:20 +08:00
Ma Nan
50e8564027 Merge pull request #30 from OmooLab/feature/dev
docs: update README
2024-07-22 09:06:50 +08:00
f07ac1c70c docs: update README 2024-07-22 09:05:52 +08:00
Ma Nan
b6e7ae0c8b Merge pull request #29 from OmooLab/feature/dev
feat: support more format
2024-07-22 08:51:59 +08:00
854af4c3bc feat: support more format
feat: support .mrc .map
feat: support .ome.tif
feat: improved user experience for importing
feat: better contrast algorithm
2024-07-22 08:51:12 +08:00
Ma Nan
0eaba42f27 Merge pull request #28 from OmooLab/feature/dev
feat: support 4d time data
2024-07-15 14:26:54 +08:00
b2791e3aae fix: eevee specular too high
feat: add pie cutter
feat: support 4d time data
chora: change operators name
docs: update README.md
2024-07-15 14:25:51 +08:00
Ma Nan
2eb2358589 Merge pull request #27 from OmooLab/feature/dev
feat: add primitive cutter
2024-07-10 12:45:43 +08:00
8b862b3fe0 feat: add primitive cutter
fix: eevee shadow
fix: save node toggle not work
fix: color over 1.0 fail to render
2024-07-10 12:45:04 +08:00
Ma Nan
5922269f7e Merge pull request #26 from OmooLab/feature/dev
feat: dramatic reduction in rendering time 🚀
2024-07-09 15:22:20 +08:00
99ce5c28d0 feat: dramatic reduction in rendering time 🚀
feat: add more support to  eevee render
fix: fail to add utils node inflate
fix: file import button missing
chore: change operator names
BREAKING CHANGE: remove support to blender 4.0
BREAKING CHANGE: remove support to gpu render with cuda
2024-07-09 15:20:26 +08:00
Ma Nan
637fc758a7 Merge pull request #25 from OmooLab/feature/dev
chore: change version
2024-07-04 15:06:10 +08:00
f90bf5c532 chore: change version 2024-07-04 15:05:22 +08:00
Ma Nan
f2d1d02c48 Merge pull request #24 from OmooLab/feature/dev
fix: misspell of "scalar"
2024-07-04 15:03:58 +08:00
d04f100dfd fix: misspell of "scalar"
fix: not change raw value of data
2024-07-04 15:02:17 +08:00
Ma Nan
734c4d1cd9 Merge pull request #23 from OmooLab/feature/dev
fix: fail to import membrane shader
2024-07-04 13:50:11 +08:00
78aba5394f fix: fail to import membrane shader 2024-07-04 13:49:18 +08:00
Ma Nan
4fc89beaa0 Merge pull request #22 from OmooLab/feature/dev
fix: fail to import nd_image
2024-07-04 13:43:17 +08:00
fb2a3c9c23 fix: fail to import nd_image
fix: membrane shader
2024-07-04 13:41:52 +08:00
Ma Nan
bc6f88996c Merge pull request #21 from OmooLab/feature/dev
feat: rewrite resize
2024-07-04 13:06:24 +08:00
1db63dd3db feat: rewrite resize
add more nodes
2024-07-04 13:05:46 +08:00
Ma Nan
467d368a76 Merge pull request #19 from OmooLab/feature/dev
feat: support eevee
2024-06-12 15:24:06 +08:00
134d8e382b feat: support eevee 2024-06-12 15:23:09 +08:00
Ma Nan
fefc9285e1 Merge pull request #18 from OmooLab/feature/dev
Feature/dev
2024-06-05 09:40:33 +08:00
322b84cc70 docs: update cover img
add extension description
2024-06-05 09:39:15 +08:00
f3be5d8660 feat: support blender 4.2 extension 2024-06-05 09:11:22 +08:00
470cdab4f6 fix: logging utf-8 cannot decode 2024-06-03 12:36:23 +08:00
87 changed files with 7427 additions and 3597 deletions

View File

@@ -22,7 +22,7 @@ jobs:
# id: draft_release
# uses: cardinalby/git-get-release-action@1.2.4
# with:
# releaseName: Draft
# releaseName: Upload
# env:
# GITHUB_TOKEN: ${{ github.token }}
@@ -50,17 +50,26 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload_blender_addon:
name: Upload Blender Add-on
build_blender_add-on:
name: Build Blender Extension
needs: draft_release
runs-on: ubuntu-latest
strategy:
matrix:
platform: ["windows-x64", "linux-x64", "macos-arm64", "macos-x64"]
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
lfs: 'true'
- name: Zip Add-on
lfs: "true"
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Build Add-on
run: |
pip install tomlkit
python build.py ${{ matrix.platform }}
zip -r package.zip bioxelnodes
- name: Upload Release Asset
id: upload-release-asset
@@ -70,5 +79,5 @@ jobs:
with:
upload_url: ${{ needs.draft_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
asset_path: ./package.zip
asset_name: BioxelNodes_${{ needs.draft_release.outputs.version }}.zip
asset_name: BioxelNodes_${{ needs.draft_release.outputs.version }}_${{ matrix.platform }}.zip
asset_content_type: application/zip

4
.gitignore vendored
View File

@@ -131,4 +131,6 @@ blendcache_*
*.ipynb
.secrets
.secrets
!scipy_ndimage/*/**

View File

@@ -2,57 +2,75 @@
# Bioxel Nodes
![Static Badge](https://img.shields.io/badge/Blender-orange?style=for-the-badge&logo=blender&logoColor=white)
![GitHub License](https://img.shields.io/github/license/OmooLab/BioxelNodes?style=for-the-badge)
![GitHub Release](https://img.shields.io/github/v/release/OmooLab/BioxelNodes?style=for-the-badge)
![GitHub Repo stars](https://img.shields.io/github/stars/OmooLab/BioxelNodes?style=for-the-badge)
[![For Blender](https://img.shields.io/badge/Blender-orange?style=for-the-badge&logo=blender&logoColor=white&color=black)](https://blender.org/)
![GitHub License](https://img.shields.io/github/license/OmooLab/BioxelNodes?style=for-the-badge&labelColor=black)
![GitHub Release](https://img.shields.io/github/v/release/OmooLab/BioxelNodes?style=for-the-badge&labelColor=black)
![GitHub Repo stars](https://img.shields.io/github/stars/OmooLab/BioxelNodes?style=for-the-badge&labelColor=black)
Bioxel Nodes is a Blender add-on for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes | Cycles to process and render volumetric data.
[![Discord](https://img.shields.io/discord/1265129134397587457?style=for-the-badge&logo=discord&label=Discord&labelColor=white&color=black)](https://discord.gg/pYkNyq2TjE)
## About
Bioxel Nodes is a Blender addon for scientific volumetric data visualization. It using Blender's powerful **Geometry Nodes** and **Cycles** to process and render volumetric data.
Before us, there have been many tutorials and add-ons for importing volumetric data into Blender. However, we found that there were many details that were not addressed in place, some scientific facts were ignored, and the volume rendering was not pretty enough. With Bioxel Nodes, you can easily import the volumetric data into Blender, and more importantly, it can quickly make a beautiful realistic rendering of it.
![cover](https://omoolab.github.io/BioxelNodes/latest/assets/cover.png)
Below are some examples with Bioxel Nodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail:
- Realistic rendering result, also support EEVEE NEXT.
- Support multiple formats.
- Support 4D volumetric data.
- All kinds of cutters.
- Simple and powerful nodes.
- Based on blender natively, can work without addon.
![gallery](docs/assets/gallery.png)
**Read the [getting started](https://omoolab.github.io/BioxelNodes/latest/installation) to begin your journey into visualizing volumetric data!**
The "Bioxel" in "Bioxel Nodes", is a combination of the words "Bio-" and "Voxel". Bioxel is a voxel that stores biological data. We are developing a toolkit around Bioxel for better biological data visualization. but before its release, we made this Blender version of bioxels toolkit first, in order to let more people to have fun with volumetric data. [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started)
Welcome to our [discord server](https://discord.gg/pYkNyq2TjE), if you have any problems with this add-on.
## Supported Format
## Support Multiple Formats
| Format | EXT | Test |
| ------ | ---------------------------------------- | ------- |
| DICOM | .dcm, .DICOM | ✅ pass |
| DICOM | .dcm, .DCM, .DICOM, .ima, .IMA | ✅ pass |
| BMP | .bmp, .BMP | ✅ pass |
| JPEG | .jpg, .JPG, .jpeg, .JPEG | ✅ pass |
| PNG | .png, .PNG | ✅ pass |
| TIFF | .tif, .TIF, .tiff, .TIFF | ✅ pass |
| Nifti | .nia, .nii, .nii.gz, .hdr, .img, .img.gz | ✅ pass |
| Nrrd | .nrrd, .nhdr | ✅ pass |
| Meta | .mha, .mhd | yet |
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
| VTK | .vtk | yet |
| BioRad | .PIC, .pic | yet |
| Gipl | .gipl, .gipl.gz | yet |
| LSM | .lsm, .LSM | yet |
| MINC | .mnc, .MNC | yet |
| MRC | .mrc, .rec | yet |
| OME | .ome.tiff, .ome.tif | ✅ pass |
| MRC | .mrc, .mrc.gz, .map, .map.gz | ✅ pass |
## Support 4D volumetric data
![4d](https://omoolab.github.io/BioxelNodes/latest/assets/4d-time.gif)
🥰 4D volumetric data can also be imported into Blender.
## Support EEVEE NEXT
![eevee](https://omoolab.github.io/BioxelNodes/latest/assets/eevee.gif)
👍 EEVEE NEXT is absolutely AWESOME! Bioxel Nodes is fully support EEVEE NEXT now! However, there are some limitations:
1. Only one cutter supported.
2. EEVEE result is not that great as Cycles does.
## Known Limitations
- Sections cannot be generated (will be supported soon)
- Time sequence volume not supported (will be supported soon)
- Only works with Cycles CPU , Cycles GPU (OptiX), EEVEE
- Section surface cannot be generated when convert to mesh (will be supported soon)
## To Upgrade Add-on
## Compatible to Newer Version
To upgrade from an older version of the add-on to the latest, you need to do the following:
**v0.3.x is not compatible to v0.2.x, Updating this addon may break old files. Read the following carefully before upgradation**
1. Remove the old version of Bioxel Nodes at Preferences > Add-ons
2. Add the new version and restart Blender.
Before upgradation, you need to ask yourself whether this project file will be modified again or not, if it's an archived project file, I would recommend that you run **Bioxel Nodes > Save Staged Data** to make the addon nodes permanent. In this way, there will be no potential problem with the nodes not functioning due to the addon update.
It is not support editing the same blender file across add-on versions. In order to make sure that the previous file works properly. You need to save the staged data before upgrading ( read the last section of [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started/#share-your-file) ).
After the addon update, your old project files may not work either, this may be because you had executed **Save Staged Data**. If so, you need to execute **Bioxel Nodes > Relink Nodes to Addon** to relink them to make sure that the addon's new functionality and the addon nodes are synchronized.
But even then, there is still no guarantee that the new version of the add-on will work on the old blender file. Therefore, it is highly recommended to open a new blender file to start the creating, not based on the old one.
Also, the older shaders are not based on OSL, so if you find that you can't render volumes, you need to turn on **Open Shading Language (OSL)** in the Render Settings.
Alternatively, objects from the old file that have nothing to do with Bioxel Nodes could be append to the new blender file.
## Roadmap
- Better multi-format import experience
- One-click bake model with texture
- AI Segmentation to Generate Labels

View File

@@ -4,28 +4,14 @@ from . import auto_load
from . import menus
bl_info = {
"name": "Bioxel Nodes",
"author": "Ma Nan",
"description": "",
"blender": (4, 0, 0),
"version": (0, 2, 0),
"location": "File -> Import",
"warning": "",
"category": "Node"
}
auto_load.init()
def register():
auto_load.register()
bpy.types.WindowManager.bioxelnodes_progress_factor = bpy.props.FloatProperty()
bpy.types.WindowManager.bioxelnodes_progress_text = bpy.props.StringProperty()
menus.add()
bpy.types.Scene.bioxel_layer_dir = bpy.props.StringProperty(
name="Bioxel Layers Directory",
subtype='DIR_PATH',
default="//"
)
def unregister():

Binary file not shown.

View File

@@ -57,7 +57,7 @@ def unregister():
#################################################
def get_all_submodules(directory):
return list(iter_submodules(directory, directory.name))
return list(iter_submodules(directory, __package__))
def iter_submodules(path, package_name):

View File

View File

@@ -0,0 +1,9 @@
from .layer import Layer
class Container():
def __init__(self,
name,
layers: list[Layer] = []) -> None:
self.name = name
self.layers = layers

43
bioxelnodes/bioxel/io.py Normal file
View File

@@ -0,0 +1,43 @@
from pathlib import Path
import uuid
# 3rd-party
import h5py
from .container import Container
from .layer import Layer
def load_container(load_file: str):
load_path = Path(load_file).resolve()
with h5py.File(load_path, 'r') as file:
layers = []
for key, layer_dset in file['layers'].items():
layers.append(Layer(data=layer_dset[:],
name=layer_dset.attrs['name'],
kind=layer_dset.attrs['kind'],
affine=layer_dset.attrs['affine']))
container = Container(name=file.attrs['name'],
layers=layers)
return container
def save_container(container: Container, save_file: str, overwrite=False):
save_path = Path(save_file).resolve()
if overwrite:
if save_path.is_file():
save_path.unlink()
with h5py.File(save_path, "w") as file:
file.attrs['name'] = container.name
layer_group = file.create_group("layers")
for layer in container.layers:
layer_key = uuid.uuid4().hex[:8]
layer_dset = layer_group.create_dataset(name=layer_key,
data=layer.data)
layer_dset.attrs['name'] = layer.name
layer_dset.attrs['kind'] = layer.kind
layer_dset.attrs['affine'] = layer.affine

135
bioxelnodes/bioxel/layer.py Normal file
View File

@@ -0,0 +1,135 @@
import copy
import numpy as np
from . import scipy
from . import skimage as ski
# 3rd-party
import transforms3d
# TODO: turn to dataclasses
class Layer():
def __init__(self,
data: np.ndarray,
name: str,
kind="scalar",
affine=np.identity(4)) -> None:
if data.ndim != 5:
raise Exception("Data shape order should be TXYZC")
affine = np.array(affine)
if affine.shape != (4, 4):
raise Exception("affine shape should be (4,4)")
self.data = data
self.name = name
self.kind = kind
self.affine = affine
@property
def bioxel_size(self):
t, r, z, s = transforms3d.affines.decompose44(self.affine)
return z.tolist()
@property
def shape(self):
return self.data.shape[1:4]
@property
def dtype(self):
return self.data.dtype
@property
def origin(self):
t, r, z, s = transforms3d.affines.decompose44(self.affine)
return t.tolist()
@property
def euler(self):
t, r, z, s = transforms3d.affines.decompose44(self.affine)
return list(transforms3d.euler.mat2euler(r))
@property
def min(self):
return float(np.min(self.data))
@property
def frame_count(self):
return self.data.shape[0]
@property
def channel_count(self):
return self.data.shape[-1]
@property
def max(self):
return float(np.max(self.data))
def copy(self):
return copy.deepcopy(self)
def fill(self, value: float, mask: np.ndarray):
mask_frames = ()
if mask.ndim == 4:
if mask.shape[0] != self.frame_count:
raise Exception("Mask frame count is not same as ")
for f in range(self.frame_count):
mask_frame = mask[f, :, :, :]
mask_frame = scipy.minimum_filter(
mask_frame.astype(np.float32), size=3)
# mask_frame = scipy.median_filter(
# mask_frame.astype(np.float32), size=2)
mask_frames += (mask_frame,)
elif mask.ndim == 3:
for f in range(self.frame_count):
mask_frame = mask[:, :, :]
mask_frame = scipy.minimum_filter(
mask_frame.astype(np.float32), size=3)
# mask_frame = scipy.median_filter(
# mask_frame.astype(np.float32), size=2)
mask_frames += (mask_frame,)
else:
raise Exception("Mask shape order should be TXYZ or XYZ")
_mask = np.stack(mask_frames)
_mask = np.expand_dims(_mask, axis=-1)
self.data = _mask * value + (1-_mask) * self.data
def resize(self, shape:tuple, progress_callback=None):
if len(shape) != 3:
raise Exception("Shape must be 3 dim")
data = self.data
# TXYZC > TXYZ
if self.kind in ['label', 'scalar']:
data = np.amax(data, -1)
if self.kind in ['scalar']:
dtype = data.dtype
data = data.astype(np.float32)
data_frames = ()
for f in range(self.frame_count):
if progress_callback:
progress_callback(f, self.frame_count)
frame = ski.resize(data[f, :, :, :],
shape,
preserve_range=True,
anti_aliasing=data.dtype.kind != "b")
data_frames += (frame,)
data = np.stack(data_frames)
if self.kind in ['scalar']:
data = data.astype(dtype)
# TXYZ > TXYZC
if self.kind in ['label', 'scalar']:
data = np.expand_dims(data, axis=-1) # expend channel
self.data = data

393
bioxelnodes/bioxel/parse.py Normal file
View File

@@ -0,0 +1,393 @@
from pathlib import Path
import numpy as np
from .layer import Layer
# 3rd-party
import SimpleITK as sitk
from pyometiff import OMETIFFReader
import mrcfile
import transforms3d
"""
Convert any volumetric data to 3D numpy array with order TXYZC
"""
SUPPORT_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA',
'.ome.tiff', '.ome.tif',
'.tif', '.TIF', '.tiff', '.TIFF',
'.mrc', '.mrc.gz', '.map', '.map.gz',
'.bmp', '.BMP',
'.png', '.PNG',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.PIC', '.pic',
'.gipl', '.gipl.gz',
'.lsm', '.LSM',
'.mnc', '.MNC',
'.mrc', '.rec',
'.mha', '.mhd',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.nrrd', '.nhdr',
'.vtk',
'.gz']
OME_EXTS = ['.ome.tiff', '.ome.tif',
'.tif', '.TIF', '.tiff', '.TIFF']
MRC_EXTS = ['.mrc', '.mrc.gz', '.map', '.map.gz']
DICOM_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA']
SEQUENCE_EXTS = ['.bmp', '.BMP',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.tif', '.TIF', '.tiff', '.TIFF',
'.png', '.PNG']
def get_ext(filepath: Path) -> str:
if filepath.name.endswith(".nii.gz"):
return ".nii.gz"
elif filepath.name.endswith(".img.gz"):
return ".img.gz"
elif filepath.name.endswith(".gipl.gz"):
return ".gipl.gz"
elif filepath.name.endswith(".ome.tiff"):
return ".ome.tiff"
elif filepath.name.endswith(".ome.tif"):
return ".ome.tif"
elif filepath.name.endswith(".mrc.gz"):
return ".mrc.gz"
elif filepath.name.endswith(".map.gz"):
return ".map.gz"
else:
return filepath.suffix
def get_file_name(filepath: Path):
ext = get_ext(filepath)
return filepath.name.removesuffix(ext).replace(" ", "-")
def get_file_number(filepath: Path) -> str:
name = get_file_name(filepath)
digits = ""
# Iterate through the characters in reverse order
started = False
for char in name[::-1]:
if char.isdigit():
started = True
# If the character is a digit, add it to the digits string
digits += char
else:
if started:
# If a non-digit character is encountered, stop the loop
break
# Reverse the digits string to get the correct order
return digits[::-1]
def get_sequence_name(filepath: Path) -> str:
name = get_file_name(filepath)
number = get_file_number(filepath)
return name.removesuffix(number)
def get_sequence_index(filepath: Path) -> int:
number = get_file_number(filepath)
return int(number) if number != "" else 0
def collect_sequence(filepath: Path):
file_dict = {}
for f in filepath.parent.iterdir():
if f.is_file() \
and get_ext(filepath) == get_ext(f) \
and get_sequence_name(filepath) == get_sequence_name(f):
index = get_sequence_index(f)
file_dict[index] = f
# reomve isolated seq file
for key in file_dict.copy().keys():
if not file_dict.get(key+1) \
and not file_dict.get(key-1):
del file_dict[key]
file_dict = dict(sorted(file_dict.items()))
sequence = [str(f) for f in file_dict.values()]
if len(sequence) == 0:
sequence = [str(filepath)]
return sequence
def parse_volumetric_data(data_file: str, series_id="", progress_callback=None) -> Layer:
"""Parse any volumetric data to numpy with shap (T,X,Y,Z,C)
Args:
data_file (str): file path
series_id (str, optional): DICOM series id. Defaults to "".
Returns:
_type_: _description_
"""
data_path = Path(data_file).resolve()
ext = get_ext(data_path)
if progress_callback:
progress_callback(0, "Reading the Data...")
is_sequence = False
if ext in SEQUENCE_EXTS:
sequence = collect_sequence(data_path)
if len(sequence) > 1:
is_sequence = True
data = None
name = "",
description = ""
affine = np.identity(4)
spacing = (1, 1, 1)
origin = (0, 0, 0)
direction = (1, 0, 0, 0, 1, 0, 0, 0, 1)
# Parsing with mrcfile
if data is None and ext in MRC_EXTS and not is_sequence:
print("Parsing with mrcfile...")
# TODO: much to do with mrc
with mrcfile.open(data_path, 'r') as mrc:
data = mrc.data
# mrc.print_header()
# print(data.shape)
# print(mrc.voxel_size)
if mrc.is_single_image():
data = np.expand_dims(data, axis=0) # expend frame
data = np.expand_dims(data, axis=-1) # expend Z
data = np.expand_dims(data, axis=-1) # expend channel
elif mrc.is_image_stack():
data = np.expand_dims(data, axis=-1) # expend Z
data = np.expand_dims(data, axis=-1) # expend channel
elif mrc.is_volume():
data = np.expand_dims(data, axis=0) # expend frame
data = np.expand_dims(data, axis=-1) # expend channel
elif mrc.is_volume_stack():
data = np.expand_dims(data, axis=-1) # expend channel
name = get_file_name(data_path)
spacing = (mrc.voxel_size.x,
mrc.voxel_size.y,
mrc.voxel_size.z)
# Parsing with OMETIFFReader
if data is None and ext in OME_EXTS and not is_sequence:
print("Parsing with OMETIFFReader...")
reader = OMETIFFReader(fpath=data_path)
ome_image, metadata, xml_metadata = reader.read()
# TODO: some old bio-format tiff the header is not the same.
if progress_callback:
progress_callback(0.5, "Transpose to 'TXYZC'...")
try:
# print(ome_image.shape)
# for key in metadata:
# print(f"{key},{metadata[key]}")
ome_order = metadata['DimOrder BF Array']
if ome_image.ndim == 2:
ome_order = ome_order.replace("T", "")\
.replace("C", "").replace("Z", "")
bioxel_order = (ome_order.index('X'),
ome_order.index('Y'))
data = np.transpose(ome_image, bioxel_order)
data = np.expand_dims(data, axis=0) # expend frame
data = np.expand_dims(data, axis=-1) # expend Z
data = np.expand_dims(data, axis=-1) # expend channel
elif ome_image.ndim == 3:
# -> XYZC
ome_order = ome_order.replace("T", "").replace("C", "")
bioxel_order = (ome_order.index('X'),
ome_order.index('Y'),
ome_order.index('Z'))
data = np.transpose(ome_image, bioxel_order)
data = np.expand_dims(data, axis=0) # expend frame
data = np.expand_dims(data, axis=-1) # expend channel
elif ome_image.ndim == 4:
# -> XYZC
ome_order = ome_order.replace("T", "")
bioxel_order = (ome_order.index('X'),
ome_order.index('Y'),
ome_order.index('Z'),
ome_order.index('C'))
data = np.transpose(ome_image, bioxel_order)
data = np.expand_dims(data, axis=0) # expend frame
elif ome_image.ndim == 5:
# -> TXYZC
bioxel_order = (ome_order.index('T'),
ome_order.index('X'),
ome_order.index('Y'),
ome_order.index('Z'),
ome_order.index('C'))
data = np.transpose(ome_image, bioxel_order)
try:
spacing = (metadata['PhysicalSizeX'],
metadata['PhysicalSizeY'],
metadata['PhysicalSizeZ'])
except:
...
name = get_file_name(data_path)
except:
...
# Parsing with SimpleITK
if data is None:
print("Parsing with SimpleITK...")
if ext in DICOM_EXTS:
data_dirpath = data_path.parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_files = reader.GetGDCMSeriesFileNames(
str(data_dirpath), series_id)
reader.SetFileNames(series_files)
itk_image = reader.Execute()
# for k in reader.GetMetaDataKeys(0):
# v = reader.GetMetaData(0, k)
# print(f'({k}) = = "{v}"')
def get_meta(key):
try:
stirng = reader.GetMetaData(0, key).removesuffix(" ")
if stirng in ["No study description",
"No series description",
""]:
return None
else:
return stirng
except:
return None
study_description = get_meta("0008|1030")
series_description = get_meta("0008|103e")
series_modality = get_meta("0008|0060")
name = study_description or data_dirpath.name
if series_description and series_modality:
description = f"{series_description}-{series_modality}"
elif series_description:
description = series_description
elif series_modality:
description = series_modality
else:
description = ""
name = name.replace(" ", "-")
description = description.replace(" ", "-")
elif ext in SEQUENCE_EXTS and is_sequence:
itk_image = sitk.ReadImage(sequence)
name = get_sequence_name(data_path)
else:
itk_image = sitk.ReadImage(data_path)
name = get_file_name(data_path)
# for key in itk_image.GetMetaDataKeys():
# print(f"{key},{itk_image.GetMetaData(key)}")
if progress_callback:
progress_callback(0.5, "Transpose to 'TXYZC'...")
if itk_image.GetDimension() == 2:
data = sitk.GetArrayFromImage(itk_image)
if data.ndim == 3:
data = np.transpose(data, (1, 0, 2))
data = np.expand_dims(data, axis=-2) # expend Z
else:
data = np.transpose(data)
data = np.expand_dims(data, axis=-1) # expend Z
data = np.expand_dims(data, axis=-1) # expend channel
data = np.expand_dims(data, axis=0) # expend frame
elif itk_image.GetDimension() == 3:
if ext not in SEQUENCE_EXTS:
itk_image = sitk.DICOMOrient(itk_image, 'RAS')
# After sitk.DICOMOrient(), origin and direction will also orient base on LPS
# so we need to convert them into RAS
# affine = axis_conversion(from_forward='-Z',
# from_up='-Y',
# to_forward='-Z',
# to_up='Y').to_4x4()
affine = np.array([[-1.0000, 0.0000, 0.0000, 0.0000],
[0.0000, -1.0000, 0.0000, 0.0000],
[0.0000, 0.0000, 1.0000, 0.0000],
[0.0000, 0.0000, 0.0000, 1.0000]])
spacing = tuple(itk_image.GetSpacing())
origin = tuple(itk_image.GetOrigin())
direction = tuple(itk_image.GetDirection())
data = sitk.GetArrayFromImage(itk_image)
# transpose ijk to kji
if data.ndim == 4:
data = np.transpose(data, (2, 1, 0, 3))
else:
data = np.transpose(data)
data = np.expand_dims(data, axis=-1) # expend channel
data = np.expand_dims(data, axis=0) # expend frame
elif itk_image.GetDimension() == 4:
spacing = tuple(itk_image.GetSpacing()[:3])
origin = tuple(itk_image.GetOrigin()[:3])
# FIXME: not sure...
direction = np.array(itk_image.GetDirection())
direction = direction.reshape(3, 3) if itk_image.GetDimension() == 3 \
else direction.reshape(4, 4)
direction = direction[1:, 1:]
direction = tuple(direction.flatten())
data = sitk.GetArrayFromImage(itk_image)
if data.ndim == 5:
data = np.transpose(data, (0, 3, 2, 1, 4))
else:
data = np.transpose(data, (0, 3, 2, 1))
data = np.expand_dims(data, axis=-1)
if itk_image.GetDimension() > 5:
raise Exception
t = origin
r = np.array(direction).reshape((3, 3))
affine = np.dot(affine,
transforms3d.affines.compose(t, r, [1, 1, 1]))
meta = {
"name": name,
"description": description,
"spacing": spacing,
"affine": affine,
"xyz_shape": data.shape[1:4],
"frame_count": data.shape[0],
"channel_count": data.shape[-1],
}
return data, meta

View File

@@ -0,0 +1,5 @@
from ._interpolation import zoom
from ._filters import (gaussian_filter,
median_filter,
maximum_filter,
minimum_filter)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
import numpy as np
import warnings
from . import _ni_support
from . import _nd_image
from ._utils import normalize_axis_index
def _prepad_for_spline_filter(input, mode, cval):
if mode in ['nearest', 'grid-constant']:
npad = 12
if mode == 'grid-constant':
padded = np.pad(input, npad, mode='constant',
constant_values=cval)
elif mode == 'nearest':
padded = np.pad(input, npad, mode='edge')
else:
# other modes have exact boundary conditions implemented so
# no prepadding is needed
npad = 0
padded = input
return padded, npad
def spline_filter1d(input, order=3, axis=-1, output=np.float64,
mode='mirror'):
"""
Calculate a 1-D spline filter along the given axis.
The lines of the array along the given axis are filtered by a
spline filter. The order of the spline must be >= 2 and <= 5.
Parameters
----------
%(input)s
order : int, optional
The order of the spline, default is 3.
axis : int, optional
The axis along which the spline filter is applied. Default is the last
axis.
output : ndarray or dtype, optional
The array in which to place the output, or the dtype of the returned
array. Default is ``numpy.float64``.
%(mode_interp_mirror)s
Returns
-------
spline_filter1d : ndarray
The filtered input.
See Also
--------
spline_filter : Multidimensional spline filter.
Notes
-----
All of the interpolation functions in `ndimage` do spline interpolation of
the input image. If using B-splines of `order > 1`, the input image
values have to be converted to B-spline coefficients first, which is
done by applying this 1-D filter sequentially along all
axes of the input. All functions that require B-spline coefficients
will automatically filter their inputs, a behavior controllable with
the `prefilter` keyword argument. For functions that accept a `mode`
parameter, the result will only be correct if it matches the `mode`
used when filtering.
For complex-valued `input`, this function processes the real and imaginary
components independently.
.. versionadded:: 1.6.0
Complex-valued support added.
Examples
--------
We can filter an image using 1-D spline along the given axis:
>>> from scipy.ndimage import spline_filter1d
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> orig_img = np.eye(20) # create an image
>>> orig_img[10, :] = 1.0
>>> sp_filter_axis_0 = spline_filter1d(orig_img, axis=0)
>>> sp_filter_axis_1 = spline_filter1d(orig_img, axis=1)
>>> f, ax = plt.subplots(1, 3, sharex=True)
>>> for ind, data in enumerate([[orig_img, "original image"],
... [sp_filter_axis_0, "spline filter (axis=0)"],
... [sp_filter_axis_1, "spline filter (axis=1)"]]):
... ax[ind].imshow(data[0], cmap='gray_r')
... ax[ind].set_title(data[1])
>>> plt.tight_layout()
>>> plt.show()
"""
if order < 0 or order > 5:
raise RuntimeError('spline order not supported')
input = np.asarray(input)
complex_output = np.iscomplexobj(input)
output = _ni_support._get_output(output, input,
complex_output=complex_output)
if complex_output:
spline_filter1d(input.real, order, axis, output.real, mode)
spline_filter1d(input.imag, order, axis, output.imag, mode)
return output
if order in [0, 1]:
output[...] = np.array(input)
else:
mode = _ni_support._extend_mode_to_code(mode)
axis = normalize_axis_index(axis, input.ndim)
_nd_image.spline_filter1d(input, order, axis, output, mode)
return output
def spline_filter(input, order=3, output=np.float64, mode='mirror'):
"""
Multidimensional spline filter.
Parameters
----------
%(input)s
order : int, optional
The order of the spline, default is 3.
output : ndarray or dtype, optional
The array in which to place the output, or the dtype of the returned
array. Default is ``numpy.float64``.
%(mode_interp_mirror)s
Returns
-------
spline_filter : ndarray
Filtered array. Has the same shape as `input`.
See Also
--------
spline_filter1d : Calculate a 1-D spline filter along the given axis.
Notes
-----
The multidimensional filter is implemented as a sequence of
1-D spline filters. The intermediate arrays are stored
in the same data type as the output. Therefore, for output types
with a limited precision, the results may be imprecise because
intermediate results may be stored with insufficient precision.
For complex-valued `input`, this function processes the real and imaginary
components independently.
.. versionadded:: 1.6.0
Complex-valued support added.
Examples
--------
We can filter an image using multidimentional splines:
>>> from scipy.ndimage import spline_filter
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> orig_img = np.eye(20) # create an image
>>> orig_img[10, :] = 1.0
>>> sp_filter = spline_filter(orig_img, order=3)
>>> f, ax = plt.subplots(1, 2, sharex=True)
>>> for ind, data in enumerate([[orig_img, "original image"],
... [sp_filter, "spline filter"]]):
... ax[ind].imshow(data[0], cmap='gray_r')
... ax[ind].set_title(data[1])
>>> plt.tight_layout()
>>> plt.show()
"""
if order < 2 or order > 5:
raise RuntimeError('spline order not supported')
input = np.asarray(input)
complex_output = np.iscomplexobj(input)
output = _ni_support._get_output(output, input,
complex_output=complex_output)
if complex_output:
spline_filter(input.real, order, output.real, mode)
spline_filter(input.imag, order, output.imag, mode)
return output
if order not in [0, 1] and input.ndim > 0:
for axis in range(input.ndim):
spline_filter1d(input, order, axis, output=output, mode=mode)
input = output
else:
output[...] = input[...]
return output
def zoom(input, zoom, output=None, order=3, mode='constant', cval=0.0,
prefilter=True, *, grid_mode=False):
"""
Zoom an array.
The array is zoomed using spline interpolation of the requested order.
Parameters
----------
%(input)s
zoom : float or sequence
The zoom factor along the axes. If a float, `zoom` is the same for each
axis. If a sequence, `zoom` should contain one value for each axis.
%(output)s
order : int, optional
The order of the spline interpolation, default is 3.
The order has to be in the range 0-5.
%(mode_interp_constant)s
%(cval)s
%(prefilter)s
grid_mode : bool, optional
If False, the distance from the pixel centers is zoomed. Otherwise, the
distance including the full pixel extent is used. For example, a 1d
signal of length 5 is considered to have length 4 when `grid_mode` is
False, but length 5 when `grid_mode` is True. See the following
visual illustration:
.. code-block:: text
| pixel 1 | pixel 2 | pixel 3 | pixel 4 | pixel 5 |
|<-------------------------------------->|
vs.
|<----------------------------------------------->|
The starting point of the arrow in the diagram above corresponds to
coordinate location 0 in each mode.
Returns
-------
zoom : ndarray
The zoomed input.
Notes
-----
For complex-valued `input`, this function zooms the real and imaginary
components independently.
.. versionadded:: 1.6.0
Complex-valued support added.
Examples
--------
>>> from scipy import ndimage, datasets
>>> import matplotlib.pyplot as plt
>>> fig = plt.figure()
>>> ax1 = fig.add_subplot(121) # left side
>>> ax2 = fig.add_subplot(122) # right side
>>> ascent = datasets.ascent()
>>> result = ndimage.zoom(ascent, 3.0)
>>> ax1.imshow(ascent, vmin=0, vmax=255)
>>> ax2.imshow(result, vmin=0, vmax=255)
>>> plt.show()
>>> print(ascent.shape)
(512, 512)
>>> print(result.shape)
(1536, 1536)
"""
if order < 0 or order > 5:
raise RuntimeError('spline order not supported')
input = np.asarray(input)
if input.ndim < 1:
raise RuntimeError('input and output rank must be > 0')
zoom = _ni_support._normalize_sequence(zoom, input.ndim)
output_shape = tuple(
[int(round(ii * jj)) for ii, jj in zip(input.shape, zoom)])
complex_output = np.iscomplexobj(input)
output = _ni_support._get_output(output, input, shape=output_shape,
complex_output=complex_output)
if complex_output:
# import under different name to avoid confusion with zoom parameter
from scipy.ndimage._interpolation import zoom as _zoom
kwargs = dict(order=order, mode=mode, prefilter=prefilter)
_zoom(input.real, zoom, output=output.real,
cval=np.real(cval), **kwargs)
_zoom(input.imag, zoom, output=output.imag,
cval=np.imag(cval), **kwargs)
return output
if prefilter and order > 1:
padded, npad = _prepad_for_spline_filter(input, mode, cval)
filtered = spline_filter(padded, order, output=np.float64, mode=mode)
else:
npad = 0
filtered = input
if grid_mode:
# warn about modes that may have surprising behavior
suggest_mode = None
if mode == 'constant':
suggest_mode = 'grid-constant'
elif mode == 'wrap':
suggest_mode = 'grid-wrap'
if suggest_mode is not None:
warnings.warn(
(f"It is recommended to use mode = {suggest_mode} instead of {mode} "
f"when grid_mode is True."),
stacklevel=2
)
mode = _ni_support._extend_mode_to_code(mode)
zoom_div = np.array(output_shape)
zoom_nominator = np.array(input.shape)
if not grid_mode:
zoom_div -= 1
zoom_nominator -= 1
# Zooming to infinite values is unpredictable, so just choose
# zoom factor 1 instead
zoom = np.divide(zoom_nominator, zoom_div,
out=np.ones_like(input.shape, dtype=np.float64),
where=zoom_div != 0)
zoom = np.ascontiguousarray(zoom)
_nd_image.zoom_shift(filtered, zoom, None, output, order, mode, cval, npad,
grid_mode)
return output

View File

@@ -0,0 +1,119 @@
# Copyright (C) 2003-2005 Peter J. Verveer
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# 3. The name of the author may not be used to endorse or promote
# products derived from this software without specific prior
# written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from collections.abc import Iterable
import operator
import warnings
import numpy as np
def _extend_mode_to_code(mode):
"""Convert an extension mode to the corresponding integer code.
"""
if mode == 'nearest':
return 0
elif mode == 'wrap':
return 1
elif mode in ['reflect', 'grid-mirror']:
return 2
elif mode == 'mirror':
return 3
elif mode == 'constant':
return 4
elif mode == 'grid-wrap':
return 5
elif mode == 'grid-constant':
return 6
else:
raise RuntimeError('boundary mode not supported')
def _normalize_sequence(input, rank):
"""If input is a scalar, create a sequence of length equal to the
rank by duplicating the input. If input is a sequence,
check if its length is equal to the length of array.
"""
is_str = isinstance(input, str)
if not is_str and isinstance(input, Iterable):
normalized = list(input)
if len(normalized) != rank:
err = "sequence argument must have length equal to input rank"
raise RuntimeError(err)
else:
normalized = [input] * rank
return normalized
def _get_output(output, input, shape=None, complex_output=False):
if shape is None:
shape = input.shape
if output is None:
if not complex_output:
output = np.zeros(shape, dtype=input.dtype.name)
else:
complex_type = np.promote_types(input.dtype, np.complex64)
output = np.zeros(shape, dtype=complex_type)
elif isinstance(output, (type, np.dtype)):
# Classes (like `np.float32`) and dtypes are interpreted as dtype
if complex_output and np.dtype(output).kind != 'c':
warnings.warn("promoting specified output dtype to complex", stacklevel=3)
output = np.promote_types(output, np.complex64)
output = np.zeros(shape, dtype=output)
elif isinstance(output, str):
output = np.dtype(output)
if complex_output and output.kind != 'c':
raise RuntimeError("output must have complex dtype")
elif not issubclass(output.type, np.number):
raise RuntimeError("output must have numeric dtype")
output = np.zeros(shape, dtype=output)
elif output.shape != shape:
raise RuntimeError("output shape not correct")
elif complex_output and output.dtype.kind != 'c':
raise RuntimeError("output must have complex dtype")
return output
def _check_axes(axes, ndim):
if axes is None:
return tuple(range(ndim))
elif np.isscalar(axes):
axes = (operator.index(axes),)
elif isinstance(axes, Iterable):
for ax in axes:
axes = tuple(operator.index(ax) for ax in axes)
if ax < -ndim or ax > ndim - 1:
raise ValueError(f"specified axis: {ax} is out of range")
axes = tuple(ax % ndim if ax < 0 else ax for ax in axes)
else:
message = "axes must be an integer, iterable of integers, or None"
raise ValueError(message)
if len(tuple(set(axes))) != len(axes):
raise ValueError("axes must be unique")
return axes

View File

@@ -0,0 +1,10 @@
def normalize_axis_index(axis, ndim):
# Check if `axis` is in the correct range and normalize it
if axis < -ndim or axis >= ndim:
msg = f"axis {axis} is out of bounds for array of dimension {ndim}"
raise Exception(msg)
if axis < 0:
axis = axis + ndim
return axis

View File

@@ -0,0 +1 @@
from ._warps import resize

View File

@@ -0,0 +1,37 @@
import numpy as np
def convert_to_float(image, preserve_range):
"""Convert input image to float image with the appropriate range.
Parameters
----------
image : ndarray
Input image.
preserve_range : bool
Determines if the range of the image should be kept or transformed
using img_as_float. Also see
https://scikit-image.org/docs/dev/user_guide/data_types.html
Notes
-----
* Input images with `float32` data type are not upcast.
Returns
-------
image : ndarray
Transformed version of the input.
"""
if image.dtype == np.float16:
return image.astype(np.float32)
if preserve_range:
# Convert image to double only if it is not single or double
# precision float
if image.dtype.char not in 'df':
image = image.astype(float)
else:
from .dtype import img_as_float
image = img_as_float(image)
return image

View File

@@ -0,0 +1,318 @@
import warnings
import numpy as np
from .. import scipy as ndi
from ._utils import convert_to_float
def _clip_warp_output(input_image, output_image, mode, cval, clip):
"""Clip output image to range of values of input image.
Note that this function modifies the values of `output_image` in-place
and it is only modified if ``clip=True``.
Parameters
----------
input_image : ndarray
Input image.
output_image : ndarray
Output image, which is modified in-place.
Other parameters
----------------
mode : {'constant', 'edge', 'symmetric', 'reflect', 'wrap'}
Points outside the boundaries of the input are filled according
to the given mode. Modes match the behaviour of `numpy.pad`.
cval : float
Used in conjunction with mode 'constant', the value outside
the image boundaries.
clip : bool
Whether to clip the output to the range of values of the input image.
This is enabled by default, since higher order interpolation may
produce values outside the given input range.
"""
if clip:
min_val = np.min(input_image)
if np.isnan(min_val):
# NaNs detected, use NaN-safe min/max
min_func = np.nanmin
max_func = np.nanmax
min_val = min_func(input_image)
else:
min_func = np.min
max_func = np.max
max_val = max_func(input_image)
# Check if cval has been used such that it expands the effective input
# range
preserve_cval = (
mode == 'constant'
and not min_val <= cval <= max_val
and min_func(output_image) <= cval <= max_func(output_image)
)
# expand min/max range to account for cval
if preserve_cval:
# cast cval to the same dtype as the input image
cval = input_image.dtype.type(cval)
min_val = min(min_val, cval)
max_val = max(max_val, cval)
# Convert array-like types to ndarrays (gh-7159)
min_val, max_val = np.asarray(min_val), np.asarray(max_val)
np.clip(output_image, min_val, max_val, out=output_image)
def _validate_interpolation_order(image_dtype, order):
"""Validate and return spline interpolation's order.
Parameters
----------
image_dtype : dtype
Image dtype.
order : int, optional
The order of the spline interpolation. The order has to be in
the range 0-5. See `skimage.transform.warp` for detail.
Returns
-------
order : int
if input order is None, returns 0 if image_dtype is bool and 1
otherwise. Otherwise, image_dtype is checked and input order
is validated accordingly (order > 0 is not supported for bool
image dtype)
"""
if order is None:
return 0 if image_dtype == bool else 1
if order < 0 or order > 5:
raise ValueError(
"Spline interpolation order has to be in the " "range 0-5.")
if image_dtype == bool and order != 0:
raise ValueError(
"Input image dtype is bool. Interpolation is not defined "
"with bool data type. Please set order to 0 or explicitly "
"cast input image to another data type."
)
return order
def _preprocess_resize_output_shape(image, output_shape):
"""Validate resize output shape according to input image.
Parameters
----------
image: ndarray
Image to be resized.
output_shape: iterable
Size of the generated output image `(rows, cols[, ...][, dim])`. If
`dim` is not provided, the number of channels is preserved.
Returns
-------
image: ndarray
The input image, but with additional singleton dimensions appended in
the case where ``len(output_shape) > input.ndim``.
output_shape: tuple
The output image converted to tuple.
Raises
------
ValueError:
If output_shape length is smaller than the image number of
dimensions
Notes
-----
The input image is reshaped if its number of dimensions is not
equal to output_shape_length.
"""
output_shape = tuple(output_shape)
output_ndim = len(output_shape)
input_shape = image.shape
if output_ndim > image.ndim:
# append dimensions to input_shape
input_shape += (1,) * (output_ndim - image.ndim)
image = np.reshape(image, input_shape)
elif output_ndim == image.ndim - 1:
# multichannel case: append shape of last axis
output_shape = output_shape + (image.shape[-1],)
elif output_ndim < image.ndim:
raise ValueError(
"output_shape length cannot be smaller than the "
"image number of dimensions"
)
return image, output_shape
def _to_ndimage_mode(mode):
"""Convert from `numpy.pad` mode name to the corresponding ndimage mode."""
mode_translation_dict = dict(
constant='constant',
edge='nearest',
symmetric='reflect',
reflect='mirror',
wrap='wrap',
)
if mode not in mode_translation_dict:
raise ValueError(
f"Unknown mode: '{mode}', or cannot translate mode. The "
f"mode should be one of 'constant', 'edge', 'symmetric', "
f"'reflect', or 'wrap'. See the documentation of numpy.pad for "
f"more info."
)
return _fix_ndimage_mode(mode_translation_dict[mode])
def _fix_ndimage_mode(mode):
# SciPy 1.6.0 introduced grid variants of constant and wrap which
# have less surprising behavior for images. Use these when available
grid_modes = {'constant': 'grid-constant', 'wrap': 'grid-wrap'}
return grid_modes.get(mode, mode)
def resize(
image,
output_shape,
order=None,
mode='reflect',
cval=0,
clip=True,
preserve_range=False,
anti_aliasing=None,
anti_aliasing_sigma=None,
):
"""Resize image to match a certain size.
Performs interpolation to up-size or down-size N-dimensional images. Note
that anti-aliasing should be enabled when down-sizing images to avoid
aliasing artifacts. For downsampling with an integer factor also see
`skimage.transform.downscale_local_mean`.
Parameters
----------
image : ndarray
Input image.
output_shape : iterable
Size of the generated output image `(rows, cols[, ...][, dim])`. If
`dim` is not provided, the number of channels is preserved. In case the
number of input channels does not equal the number of output channels a
n-dimensional interpolation is applied.
Returns
-------
resized : ndarray
Resized version of the input.
Other parameters
----------------
order : int, optional
The order of the spline interpolation, default is 0 if
image.dtype is bool and 1 otherwise. The order has to be in
the range 0-5. See `skimage.transform.warp` for detail.
mode : {'constant', 'edge', 'symmetric', 'reflect', 'wrap'}, optional
Points outside the boundaries of the input are filled according
to the given mode. Modes match the behaviour of `numpy.pad`.
cval : float, optional
Used in conjunction with mode 'constant', the value outside
the image boundaries.
clip : bool, optional
Whether to clip the output to the range of values of the input image.
This is enabled by default, since higher order interpolation may
produce values outside the given input range.
preserve_range : bool, optional
Whether to keep the original range of values. Otherwise, the input
image is converted according to the conventions of `img_as_float`.
Also see https://scikit-image.org/docs/dev/user_guide/data_types.html
anti_aliasing : bool, optional
Whether to apply a Gaussian filter to smooth the image prior
to downsampling. It is crucial to filter when downsampling
the image to avoid aliasing artifacts. If not specified, it is set to
True when downsampling an image whose data type is not bool.
It is also set to False when using nearest neighbor interpolation
(``order`` == 0) with integer input data type.
anti_aliasing_sigma : {float, tuple of floats}, optional
Standard deviation for Gaussian filtering used when anti-aliasing.
By default, this value is chosen as (s - 1) / 2 where s is the
downsampling factor, where s > 1. For the up-size case, s < 1, no
anti-aliasing is performed prior to rescaling.
Notes
-----
Modes 'reflect' and 'symmetric' are similar, but differ in whether the edge
pixels are duplicated during the reflection. As an example, if an array
has values [0, 1, 2] and was padded to the right by four values using
symmetric, the result would be [0, 1, 2, 2, 1, 0, 0], while for reflect it
would be [0, 1, 2, 1, 0, 1, 2].
Examples
--------
>>> from skimage import data
>>> from skimage.transform import resize
>>> image = data.camera()
>>> resize(image, (100, 100)).shape
(100, 100)
"""
image, output_shape = _preprocess_resize_output_shape(image, output_shape)
input_shape = image.shape
input_type = image.dtype
if input_type == np.float16:
image = image.astype(np.float32)
if anti_aliasing is None:
anti_aliasing = (
not input_type == bool
and not (np.issubdtype(input_type, np.integer) and order == 0)
and any(x < y for x, y in zip(output_shape, input_shape))
)
if input_type == bool and anti_aliasing:
raise ValueError("anti_aliasing must be False for boolean images")
factors = np.divide(input_shape, output_shape)
order = _validate_interpolation_order(input_type, order)
if order > 0:
image = convert_to_float(image, preserve_range)
# Translate modes used by np.pad to those used by scipy.ndimage
ndi_mode = _to_ndimage_mode(mode)
if anti_aliasing:
if anti_aliasing_sigma is None:
anti_aliasing_sigma = np.maximum(0, (factors - 1) / 2)
else:
anti_aliasing_sigma = np.atleast_1d(anti_aliasing_sigma) * np.ones_like(
factors
)
if np.any(anti_aliasing_sigma < 0):
raise ValueError(
"Anti-aliasing standard deviation must be "
"greater than or equal to zero"
)
elif np.any((anti_aliasing_sigma > 0) & (factors <= 1)):
warnings.warn(
"Anti-aliasing standard deviation greater than zero but "
"not down-sampling along all axes",
stacklevel=2
)
filtered = ndi.gaussian_filter(
image, anti_aliasing_sigma, cval=cval, mode=ndi_mode
)
else:
filtered = image
zoom_factors = [1 / f for f in factors]
out = ndi.zoom(
filtered, zoom_factors, order=order, mode=ndi_mode, cval=cval, grid_mode=True
)
_clip_warp_output(image, out, mode, cval, clip)
return out

View File

@@ -0,0 +1,600 @@
import warnings
from warnings import warn
import numpy as np
__all__ = [
'img_as_float32',
'img_as_float64',
'img_as_float',
'img_as_int',
'img_as_uint',
'img_as_ubyte',
'img_as_bool',
'dtype_limits',
]
# Some of these may or may not be aliases depending on architecture & platform
_integer_types = (
np.int8,
np.byte,
np.int16,
np.short,
np.int32,
np.int64,
np.longlong,
np.int_,
np.intp,
np.intc,
int,
np.uint8,
np.ubyte,
np.uint16,
np.ushort,
np.uint32,
np.uint64,
np.ulonglong,
np.uint,
np.uintp,
np.uintc,
)
_integer_ranges = {t: (np.iinfo(t).min, np.iinfo(t).max) for t in _integer_types}
dtype_range = {
bool: (False, True),
np.bool_: (False, True),
float: (-1, 1),
np.float16: (-1, 1),
np.float32: (-1, 1),
np.float64: (-1, 1),
}
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning)
# np.bool8 is a deprecated alias of np.bool_
if hasattr(np, 'bool8'):
dtype_range[np.bool8] = (False, True)
dtype_range.update(_integer_ranges)
_supported_types = list(dtype_range.keys())
def dtype_limits(image, clip_negative=False):
"""Return intensity limits, i.e. (min, max) tuple, of the image's dtype.
Parameters
----------
image : ndarray
Input image.
clip_negative : bool, optional
If True, clip the negative range (i.e. return 0 for min intensity)
even if the image dtype allows negative values.
Returns
-------
imin, imax : tuple
Lower and upper intensity limits.
"""
imin, imax = dtype_range[image.dtype.type]
if clip_negative:
imin = 0
return imin, imax
def _dtype_itemsize(itemsize, *dtypes):
"""Return first of `dtypes` with itemsize greater than `itemsize`
Parameters
----------
itemsize: int
The data type object element size.
Other Parameters
----------------
*dtypes:
Any Object accepted by `np.dtype` to be converted to a data
type object
Returns
-------
dtype: data type object
First of `dtypes` with itemsize greater than `itemsize`.
"""
return next(dt for dt in dtypes if np.dtype(dt).itemsize >= itemsize)
def _dtype_bits(kind, bits, itemsize=1):
"""Return dtype of `kind` that can store a `bits` wide unsigned int
Parameters:
kind: str
Data type kind.
bits: int
Desired number of bits.
itemsize: int
The data type object element size.
Returns
-------
dtype: data type object
Data type of `kind` that can store a `bits` wide unsigned int
"""
s = next(
i
for i in (itemsize,) + (2, 4, 8)
if bits < (i * 8) or (bits == (i * 8) and kind == 'u')
)
return np.dtype(kind + str(s))
def _scale(a, n, m, copy=True):
"""Scale an array of unsigned/positive integers from `n` to `m` bits.
Numbers can be represented exactly only if `m` is a multiple of `n`.
Parameters
----------
a : ndarray
Input image array.
n : int
Number of bits currently used to encode the values in `a`.
m : int
Desired number of bits to encode the values in `out`.
copy : bool, optional
If True, allocates and returns new array. Otherwise, modifies
`a` in place.
Returns
-------
out : array
Output image array. Has the same kind as `a`.
"""
kind = a.dtype.kind
if n > m and a.max() < 2**m:
mnew = int(np.ceil(m / 2) * 2)
if mnew > m:
dtype = f'int{mnew}'
else:
dtype = f'uint{mnew}'
n = int(np.ceil(n / 2) * 2)
warn(
f'Downcasting {a.dtype} to {dtype} without scaling because max '
f'value {a.max()} fits in {dtype}',
stacklevel=3,
)
return a.astype(_dtype_bits(kind, m))
elif n == m:
return a.copy() if copy else a
elif n > m:
# downscale with precision loss
if copy:
b = np.empty(a.shape, _dtype_bits(kind, m))
np.floor_divide(a, 2 ** (n - m), out=b, dtype=a.dtype, casting='unsafe')
return b
else:
a //= 2 ** (n - m)
return a
elif m % n == 0:
# exact upscale to a multiple of `n` bits
if copy:
b = np.empty(a.shape, _dtype_bits(kind, m))
np.multiply(a, (2**m - 1) // (2**n - 1), out=b, dtype=b.dtype)
return b
else:
a = a.astype(_dtype_bits(kind, m, a.dtype.itemsize), copy=False)
a *= (2**m - 1) // (2**n - 1)
return a
else:
# upscale to a multiple of `n` bits,
# then downscale with precision loss
o = (m // n + 1) * n
if copy:
b = np.empty(a.shape, _dtype_bits(kind, o))
np.multiply(a, (2**o - 1) // (2**n - 1), out=b, dtype=b.dtype)
b //= 2 ** (o - m)
return b
else:
a = a.astype(_dtype_bits(kind, o, a.dtype.itemsize), copy=False)
a *= (2**o - 1) // (2**n - 1)
a //= 2 ** (o - m)
return a
def _convert(image, dtype, force_copy=False, uniform=False):
"""
Convert an image to the requested data-type.
Warnings are issued in case of precision loss, or when negative values
are clipped during conversion to unsigned integer types (sign loss).
Floating point values are expected to be normalized and will be clipped
to the range [0.0, 1.0] or [-1.0, 1.0] when converting to unsigned or
signed integers respectively.
Numbers are not shifted to the negative side when converting from
unsigned to signed integer types. Negative values will be clipped when
converting to unsigned integers.
Parameters
----------
image : ndarray
Input image.
dtype : dtype
Target data-type.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
uniform : bool, optional
Uniformly quantize the floating point range to the integer range.
By default (uniform=False) floating point values are scaled and
rounded to the nearest integers, which minimizes back and forth
conversion errors.
.. versionchanged:: 0.15
``_convert`` no longer warns about possible precision or sign
information loss. See discussions on these warnings at:
https://github.com/scikit-image/scikit-image/issues/2602
https://github.com/scikit-image/scikit-image/issues/543#issuecomment-208202228
https://github.com/scikit-image/scikit-image/pull/3575
References
----------
.. [1] DirectX data conversion rules.
https://msdn.microsoft.com/en-us/library/windows/desktop/dd607323%28v=vs.85%29.aspx
.. [2] Data Conversions. In "OpenGL ES 2.0 Specification v2.0.25",
pp 7-8. Khronos Group, 2010.
.. [3] Proper treatment of pixels as integers. A.W. Paeth.
In "Graphics Gems I", pp 249-256. Morgan Kaufmann, 1990.
.. [4] Dirty Pixels. J. Blinn. In "Jim Blinn's corner: Dirty Pixels",
pp 47-57. Morgan Kaufmann, 1998.
"""
image = np.asarray(image)
dtypeobj_in = image.dtype
if dtype is np.floating:
dtypeobj_out = np.dtype('float64')
else:
dtypeobj_out = np.dtype(dtype)
dtype_in = dtypeobj_in.type
dtype_out = dtypeobj_out.type
kind_in = dtypeobj_in.kind
kind_out = dtypeobj_out.kind
itemsize_in = dtypeobj_in.itemsize
itemsize_out = dtypeobj_out.itemsize
# Below, we do an `issubdtype` check. Its purpose is to find out
# whether we can get away without doing any image conversion. This happens
# when:
#
# - the output and input dtypes are the same or
# - when the output is specified as a type, and the input dtype
# is a subclass of that type (e.g. `np.floating` will allow
# `float32` and `float64` arrays through)
if np.issubdtype(dtype_in, dtype):
if force_copy:
image = image.copy()
return image
if not (dtype_in in _supported_types and dtype_out in _supported_types):
raise ValueError(f'Cannot convert from {dtypeobj_in} to ' f'{dtypeobj_out}.')
if kind_in in 'ui':
imin_in = np.iinfo(dtype_in).min
imax_in = np.iinfo(dtype_in).max
if kind_out in 'ui':
imin_out = np.iinfo(dtype_out).min
imax_out = np.iinfo(dtype_out).max
# any -> binary
if kind_out == 'b':
return image > dtype_in(dtype_range[dtype_in][1] / 2)
# binary -> any
if kind_in == 'b':
result = image.astype(dtype_out)
if kind_out != 'f':
result *= dtype_out(dtype_range[dtype_out][1])
return result
# float -> any
if kind_in == 'f':
if kind_out == 'f':
# float -> float
return image.astype(dtype_out)
if np.min(image) < -1.0 or np.max(image) > 1.0:
raise ValueError("Images of type float must be between -1 and 1.")
# floating point -> integer
# use float type that can represent output integer type
computation_type = _dtype_itemsize(
itemsize_out, dtype_in, np.float32, np.float64
)
if not uniform:
if kind_out == 'u':
image_out = np.multiply(image, imax_out, dtype=computation_type)
else:
image_out = np.multiply(
image, (imax_out - imin_out) / 2, dtype=computation_type
)
image_out -= 1.0 / 2.0
np.rint(image_out, out=image_out)
np.clip(image_out, imin_out, imax_out, out=image_out)
elif kind_out == 'u':
image_out = np.multiply(image, imax_out + 1, dtype=computation_type)
np.clip(image_out, 0, imax_out, out=image_out)
else:
image_out = np.multiply(
image, (imax_out - imin_out + 1.0) / 2.0, dtype=computation_type
)
np.floor(image_out, out=image_out)
np.clip(image_out, imin_out, imax_out, out=image_out)
return image_out.astype(dtype_out)
# signed/unsigned int -> float
if kind_out == 'f':
# use float type that can exactly represent input integers
computation_type = _dtype_itemsize(
itemsize_in, dtype_out, np.float32, np.float64
)
if kind_in == 'u':
# using np.divide or np.multiply doesn't copy the data
# until the computation time
image = np.multiply(image, 1.0 / imax_in, dtype=computation_type)
# DirectX uses this conversion also for signed ints
# if imin_in:
# np.maximum(image, -1.0, out=image)
elif kind_in == 'i':
# From DirectX conversions:
# The most negative value maps to -1.0f
# Every other value is converted to a float (call it c)
# and then result = c * (1.0f / (2⁽ⁿ⁻¹⁾-1)).
image = np.multiply(image, 1.0 / imax_in, dtype=computation_type)
np.maximum(image, -1.0, out=image)
else:
image = np.add(image, 0.5, dtype=computation_type)
image *= 2 / (imax_in - imin_in)
return np.asarray(image, dtype_out)
# unsigned int -> signed/unsigned int
if kind_in == 'u':
if kind_out == 'i':
# unsigned int -> signed int
image = _scale(image, 8 * itemsize_in, 8 * itemsize_out - 1)
return image.view(dtype_out)
else:
# unsigned int -> unsigned int
return _scale(image, 8 * itemsize_in, 8 * itemsize_out)
# signed int -> unsigned int
if kind_out == 'u':
image = _scale(image, 8 * itemsize_in - 1, 8 * itemsize_out)
result = np.empty(image.shape, dtype_out)
np.maximum(image, 0, out=result, dtype=image.dtype, casting='unsafe')
return result
# signed int -> signed int
if itemsize_in > itemsize_out:
return _scale(image, 8 * itemsize_in - 1, 8 * itemsize_out - 1)
image = image.astype(_dtype_bits('i', itemsize_out * 8))
image -= imin_in
image = _scale(image, 8 * itemsize_in, 8 * itemsize_out, copy=False)
image += imin_out
return image.astype(dtype_out)
def convert(image, dtype, force_copy=False, uniform=False):
warn(
"The use of this function is discouraged as its behavior may change "
"dramatically in scikit-image 1.0. This function will be removed "
"in scikit-image 1.0.",
FutureWarning,
stacklevel=2,
)
return _convert(image=image, dtype=dtype, force_copy=force_copy, uniform=uniform)
if _convert.__doc__ is not None:
convert.__doc__ = (
_convert.__doc__
+ """
Warns
-----
FutureWarning:
.. versionadded:: 0.17
The use of this function is discouraged as its behavior may change
dramatically in scikit-image 1.0. This function will be removed
in scikit-image 1.0.
"""
)
def img_as_float32(image, force_copy=False):
"""Convert an image to single-precision (32-bit) floating point format.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of float32
Output image.
Notes
-----
The range of a floating point image is [0.0, 1.0] or [-1.0, 1.0] when
converting from unsigned or signed datatypes, respectively.
If the input image has a float type, intensity values are not modified
and can be outside the ranges [0.0, 1.0] or [-1.0, 1.0].
"""
return _convert(image, np.float32, force_copy)
def img_as_float64(image, force_copy=False):
"""Convert an image to double-precision (64-bit) floating point format.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of float64
Output image.
Notes
-----
The range of a floating point image is [0.0, 1.0] or [-1.0, 1.0] when
converting from unsigned or signed datatypes, respectively.
If the input image has a float type, intensity values are not modified
and can be outside the ranges [0.0, 1.0] or [-1.0, 1.0].
"""
return _convert(image, np.float64, force_copy)
def img_as_float(image, force_copy=False):
"""Convert an image to floating point format.
This function is similar to `img_as_float64`, but will not convert
lower-precision floating point arrays to `float64`.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of float
Output image.
Notes
-----
The range of a floating point image is [0.0, 1.0] or [-1.0, 1.0] when
converting from unsigned or signed datatypes, respectively.
If the input image has a float type, intensity values are not modified
and can be outside the ranges [0.0, 1.0] or [-1.0, 1.0].
"""
return _convert(image, np.floating, force_copy)
def img_as_uint(image, force_copy=False):
"""Convert an image to 16-bit unsigned integer format.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of uint16
Output image.
Notes
-----
Negative input values will be clipped.
Positive values are scaled between 0 and 65535.
"""
return _convert(image, np.uint16, force_copy)
def img_as_int(image, force_copy=False):
"""Convert an image to 16-bit signed integer format.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of int16
Output image.
Notes
-----
The values are scaled between -32768 and 32767.
If the input data-type is positive-only (e.g., uint8), then
the output image will still only have positive values.
"""
return _convert(image, np.int16, force_copy)
def img_as_ubyte(image, force_copy=False):
"""Convert an image to 8-bit unsigned integer format.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of ubyte (uint8)
Output image.
Notes
-----
Negative input values will be clipped.
Positive values are scaled between 0 and 255.
"""
return _convert(image, np.uint8, force_copy)
def img_as_bool(image, force_copy=False):
"""Convert an image to boolean format.
Parameters
----------
image : ndarray
Input image.
force_copy : bool, optional
Force a copy of the data, irrespective of its current dtype.
Returns
-------
out : ndarray of bool (`bool_`)
Output image.
Notes
-----
The upper half of the input dtype's positive range is True, and the lower
half is False. All negative values (if present) are False.
"""
return _convert(image, bool, force_copy)

View File

View File

@@ -0,0 +1,180 @@
import bpy
from ..bioxel.container import Container
from bpy_extras.io_utils import axis_conversion
from mathutils import Matrix, Vector
from .layer import Layer, get_container_layer_objs, layer_to_obj, obj_to_layer
from ..nodes import custom_nodes
from .node import get_nodes_by_type, move_node_to_node
NODE_TYPE = {
"label": "BioxelNodes_MaskByLabel",
"scalar": "BioxelNodes_MaskByThreshold"
}
def calc_bbox_verts(origin: tuple, size: tuple):
bbox_origin = Vector(
(origin[0], origin[1], origin[2]))
bbox_size = Vector(
(size[0], size[1], size[2]))
bbox_verts = [
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2],
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0,
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2],
),
]
return bbox_verts
def get_container_objs_from_selection():
container_objs = []
for obj in bpy.context.selected_objects:
if get_container_obj(obj):
container_objs.append(obj)
return list(set(container_objs))
def get_container_obj(current_obj):
if current_obj:
if current_obj.get('bioxel_container'):
return current_obj
elif current_obj.get('bioxel_layer'):
parent = current_obj.parent
return parent if parent.get('bioxel_container') else None
return None
def add_layers(layers: list[Layer],
container_obj: bpy.types.Object,
cache_dir: str):
container_node_group = container_obj.modifiers[0].node_group
for i, layer in enumerate(layers):
layer_obj = layer_to_obj(layer, container_obj, cache_dir)
mask_node = custom_nodes.add_node(container_node_group,
NODE_TYPE[layer.kind])
mask_node.label = layer_obj.name
mask_node.inputs[0].default_value = layer_obj
# Connect to output if no output linked
output_node = get_nodes_by_type(container_node_group,
'NodeGroupOutput')[0]
if len(output_node.inputs[0].links) == 0:
container_node_group.links.new(mask_node.outputs[0],
output_node.inputs[0])
move_node_to_node(mask_node, output_node, (-300, 0))
else:
move_node_to_node(mask_node, output_node, (0, -100 * (i+1)))
return container_obj
def obj_to_container(container_obj: bpy.types.Object):
layer_objs = get_container_layer_objs(container_obj)
layers = [obj_to_layer(obj) for obj in layer_objs]
container = Container(name=container_obj.name,
layers=layers)
return container
def container_to_obj(container: Container,
scene_scale: float,
cache_dir: str):
# Wrapper a Container
# Make transformation
# (S)uperior -Z -> Y
# (A)osterior Y -> Z
mat_ras2blender = axis_conversion(from_forward='-Z',
from_up='Y',
to_forward='Y',
to_up='Z').to_4x4()
mat_scene_scale = Matrix.Scale(scene_scale,
4)
bpy.ops.mesh.primitive_cube_add(enter_editmode=False,
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
container_obj = bpy.context.active_object
bbox_verts = calc_bbox_verts((0, 0, 0), container.layers[0].shape)
for i, vert in enumerate(container_obj.data.vertices):
transform = Matrix(container.layers[0].affine)
vert.co = transform @ Vector(bbox_verts[i])
container_obj.matrix_world = mat_ras2blender @ mat_scene_scale
container_obj.name = container.name
container_obj.data.name = container.name
container_obj.show_in_front = True
container_obj['bioxel_container'] = True
bpy.ops.node.new_geometry_nodes_modifier()
container_node_group = container_obj.modifiers[0].node_group
input_node = get_nodes_by_type(container_node_group,
'NodeGroupInput')[0]
container_node_group.links.remove(
input_node.outputs[0].links[0])
for i, layer in enumerate(container.layers):
layer_obj = layer_to_obj(layer, container_obj, cache_dir)
mask_node = custom_nodes.add_node(container_node_group,
NODE_TYPE[layer.kind])
mask_node.label = layer_obj.name
mask_node.inputs[0].default_value = layer_obj
# Connect to output if no output linked
output_node = get_nodes_by_type(container_node_group,
'NodeGroupOutput')[0]
if len(output_node.inputs[0].links) == 0:
container_node_group.links.new(mask_node.outputs[0],
output_node.inputs[0])
move_node_to_node(mask_node, output_node, (-300, 0))
else:
move_node_to_node(mask_node, output_node, (0, -100 * (i+1)))
return container_obj

View File

@@ -0,0 +1,232 @@
import random
import re
import bpy
import numpy as np
import pyopenvdb as vdb
from pathlib import Path
from uuid import uuid4
from ..nodes import custom_nodes
from ..bioxel.layer import Layer
from .node import get_nodes_by_type, move_node_between_nodes
def get_layer_obj(current_obj: bpy.types.Object):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layer_objs(container_obj: bpy.types.Object):
layer_objs = []
for obj in bpy.context.scene.objects:
if obj.parent == container_obj and get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def get_all_layer_objs():
layer_objs = []
for obj in bpy.context.scene.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def obj_to_layer(layer_obj: bpy.types.Object):
cache_filepath = Path(bpy.path.abspath(layer_obj.data.filepath)).resolve()
is_sequence = re.search(r'\.\d{4}\.',
cache_filepath.name) is not None
if is_sequence:
cache_path = cache_filepath.parent
data_frames = ()
for f in cache_path.iterdir():
if not f.is_file() or f.suffix != ".vdb":
continue
grids, base_metadata = vdb.readAll(str(f))
grid = grids[0]
metadata = grid.metadata
data_frame = np.ndarray(grid["data_shape"], np.float32)
grid.copyToArray(data_frame)
data_frames += (data_frame,)
data = np.stack(data_frames)
else:
grids, base_metadata = vdb.readAll(str(cache_filepath))
grid = grids[0]
metadata = grid.metadata
data = np.ndarray(grid["data_shape"], np.float32)
grid.copyToArray(data)
data = np.expand_dims(data, axis=0) # expend frame
name = metadata["layer_name"]
kind = metadata["layer_kind"]
affine = metadata["layer_affine"]
dtype = metadata.get("data_dtype") or "float32"
offset = metadata.get("data_offset") or 0
data = data - np.full_like(data, offset)
data = data.astype(dtype)
if kind in ["scalar", "label"]:
data = np.expand_dims(data, axis=-1) # expend channel
layer = Layer(data=data,
name=name,
kind=kind,
affine=affine)
return layer
def layer_to_obj(layer: Layer,
container_obj: bpy.types.Object,
cache_dir: str):
data = layer.data
# TXYZC > TXYZ
if layer.kind in ['label', 'scalar']:
data = np.amax(data, -1)
offset = 0
if layer.kind in ['scalar']:
data = data.astype(np.float32)
orig_min = float(np.min(data))
if orig_min < 0:
offset = -orig_min
data = data + np.full_like(data, offset)
metadata = {
"layer_name": layer.name,
"layer_kind": layer.kind,
"layer_affine": layer.affine.tolist(),
"data_shape": layer.shape,
"data_dtype": layer.data.dtype.str,
"data_offset": offset
}
if layer.frame_count > 1:
print(f"Saving the Cache of {layer.name}...")
vdb_name = str(uuid4())
sequence_path = Path(cache_dir, vdb_name)
sequence_path.mkdir(parents=True, exist_ok=True)
cache_filepaths = []
for f in range(layer.frame_count):
grid = vdb.FloatGrid()
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.metadata = metadata
grid.name = layer.kind
cache_filepath = Path(sequence_path,
f"{vdb_name}.{str(f+1).zfill(4)}.vdb")
vdb.write(str(cache_filepath), grids=[grid])
cache_filepaths.append(cache_filepath)
print(f"Loading the Cache of {layer.name}...")
files = [{"name": str(cache_filepath.name), "name": str(cache_filepath.name)}
for cache_filepath in cache_filepaths]
bpy.ops.object.volume_import(filepath=str(cache_filepaths[0]),
directory=str(cache_filepaths[0].parent),
files=files, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
else:
grid = vdb.FloatGrid()
grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32))
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.metadata = metadata
grid.name = layer.kind
print(f"Saving the Cache of {layer.name}...")
cache_filepath = Path(cache_dir, f"{uuid4()}.vdb")
vdb.write(str(cache_filepath), grids=[grid])
cache_filepaths = [cache_filepath]
print(f"Loading the Cache of {layer.name}...")
bpy.ops.object.volume_import(filepath=str(cache_filepaths[0]),
align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
layer_obj = bpy.context.active_object
layer_obj.data.sequence_mode = 'REPEAT'
# Set props to VDB object
layer_obj.name = f"{container_obj.name}_{layer.name}"
layer_obj.data.name = f"{container_obj.name}_{layer.name}"
layer_obj.lock_location[0] = True
layer_obj.lock_location[1] = True
layer_obj.lock_location[2] = True
layer_obj.lock_rotation[0] = True
layer_obj.lock_rotation[1] = True
layer_obj.lock_rotation[2] = True
layer_obj.lock_scale[0] = True
layer_obj.lock_scale[1] = True
layer_obj.lock_scale[2] = True
layer_obj.visible_camera = False
layer_obj.visible_diffuse = False
layer_obj.visible_glossy = False
layer_obj.visible_transmission = False
layer_obj.visible_volume_scatter = False
layer_obj.visible_shadow = False
layer_obj.hide_select = True
layer_obj.hide_render = True
layer_obj.hide_viewport = True
layer_obj['bioxel_layer'] = True
layer_obj.parent = container_obj
for collection in layer_obj.users_collection:
collection.objects.unlink(layer_obj)
for collection in container_obj.users_collection:
collection.objects.link(layer_obj)
print(f"Creating Node for {layer.name}...")
bpy.ops.node.new_geometry_nodes_modifier()
node_group = layer_obj.modifiers[0].node_group
layer_node = custom_nodes.add_node(node_group,
"BioxelNodes__Layer")
layer_node.inputs['name'].default_value = layer.name
layer_node.inputs['shape'].default_value = layer.shape
layer_node.inputs['kind'].default_value = layer.kind
for i in range(layer.affine.shape[1]):
for j in range(layer.affine.shape[0]):
affine_key = f"affine{i}{j}"
layer_node.inputs[affine_key].default_value = layer.affine[j, i]
layer_node.inputs['id'].default_value = random.randint(-200000000,
200000000)
layer_node.inputs['bioxel_size'].default_value = layer.bioxel_size[0]
layer_node.inputs['dtype'].default_value = layer.dtype.str
layer_node.inputs['dtype_num'].default_value = layer.dtype.num
layer_node.inputs['offset'].default_value = max(0, -layer.min)
layer_node.inputs['min'].default_value = layer.min
layer_node.inputs['max'].default_value = layer.max
input_node = get_nodes_by_type(node_group,
'NodeGroupInput')[0]
output_node = get_nodes_by_type(node_group,
'NodeGroupOutput')[0]
node_group.links.new(input_node.outputs[0],
layer_node.inputs[0])
node_group.links.new(layer_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(
layer_node, [input_node, output_node])
return layer_obj

View File

@@ -0,0 +1,26 @@
def move_node_to_node(node, target_node, offset=(0, 0)):
node.location.x = target_node.location.x + offset[0]
node.location.y = target_node.location.y + offset[1]
def move_node_between_nodes(node, target_nodes, offset=(0, 0)):
xs = []
ys = []
for target_node in target_nodes:
xs.append(target_node.location.x)
ys.append(target_node.location.y)
node.location.x = sum(xs) / len(xs) + offset[0]
node.location.y = sum(ys) / len(ys) + offset[1]
def get_node_type(node):
node_type = type(node).__name__
if node_type == "GeometryNodeGroup":
node_type = node.node_tree.name
return node_type
def get_nodes_by_type(node_group, type_name: str):
return [node for node in node_group.nodes if get_node_type(node) == type_name]

View File

@@ -0,0 +1,38 @@
schema_version = "1.0.0"
id = "bioxelnodes"
version = "0.3.2"
name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender"
maintainer = "Ma Nan <icrdr2010@outlook.com>"
type = "add-on"
website = "https://omoolab.github.io/BioxelNodes/latest"
tags = ["Geometry Nodes", "Render", "Import-Export"]
blender_version_min = "4.2.0"
license = ["SPDX:MIT"]
copyright = ["2024 OmooLab"]
platforms = ["windows-x64", "linux-x64", "macos-arm64", "macos-x64"]
wheels = [
"./wheels/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-macosx_11_0_arm64.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/lxml-5.2.2-cp311-cp311-win_amd64.whl",
"./wheels/lxml-5.2.2-cp311-cp311-macosx_10_9_arm64.whl",
"./wheels/lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/h5py-3.11.0-cp311-cp311-win_amd64.whl",
"./wheels/h5py-3.11.0-cp311-cp311-macosx_11_0_arm64.whl",
"./wheels/h5py-3.11.0-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/h5py-3.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/tifffile-2024.7.24-py3-none-any.whl",
"./wheels/pyometiff-1.0.0-py3-none-any.whl",
"./wheels/mrcfile-1.5.1-py2.py3-none-any.whl",
"./wheels/transforms3d-0.4.2-py3-none-any.whl",
]
[permissions]
files = "Import/export volume data from/to disk"

View File

@@ -1,65 +1,14 @@
import shutil
import bpy
from pathlib import Path
from .nodes import AddCustomNode
class SaveAllNodes(bpy.types.Operator):
bl_idname = "customnodes.save_all_nodes"
bl_label = "Save All Custom Nodes"
bl_description = "Save All Custom Nodes to Directory."
bl_options = {'UNDO'}
def execute(self, context):
files = []
for classname in dir(bpy.types):
if "CUSTOMNODES_MT_NODES_" in classname:
cls = getattr(bpy.types, classname)
files.append(cls.nodes_file)
files = list(set(files))
for file in files:
file_name = Path(file).name
# "//"
custom_nodes_dir = bpy.path.abspath(context.scene.custom_nodes_dir)
output_path: Path = Path(custom_nodes_dir, file_name).resolve()
source_path: Path = Path(file).resolve()
if output_path != source_path:
shutil.copy(source_path, output_path)
for lib in bpy.data.libraries:
lib_path = str(Path(bpy.path.abspath(lib.filepath)).resolve())
if lib_path == file:
blend_path = Path(bpy.path.abspath("//")).resolve()
lib.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
self.report({"INFO"}, f"Successfully saved to {output_path}")
return {'FINISHED'}
class CUSTOMNODES_PT_CustomNodes(bpy.types.Panel):
bl_idname = "CUSTOMNODES_PT_CustomNodes"
bl_label = "Custom Nodes"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, 'custom_nodes_dir')
layout.operator(SaveAllNodes.bl_idname)
class CustomNodes():
def __init__(
self,
menu_items,
nodes_file,
class_prefix="CUSTOMNODES_MT_NODES",
root_label='CustomNodes',
root_icon='NONE'
) -> None:
@@ -68,9 +17,10 @@ class CustomNodes():
self.menu_items = menu_items
self.nodes_file = str(Path(nodes_file).resolve())
self.class_prefix = class_prefix
self.root_label = root_label
self.root_icon = root_icon
self.class_prefix = class_prefix
menu_classes = []
self._create_menu_class(
@@ -80,7 +30,7 @@ class CustomNodes():
)
self.menu_classes = menu_classes
idname = f"CUSTOMNODES_MT_NODES_{root_label.replace(' ', '').upper()}"
idname = f"{class_prefix}_{root_label.replace(' ', '').upper()}"
def add_node_menu(self, context):
if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type):
@@ -92,7 +42,7 @@ class CustomNodes():
def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None):
nodes_file = self.nodes_file
idname_namespace = idname_namespace or "CUSTOMNODES_MT_NODES"
idname_namespace = idname_namespace or self.class_prefix
idname = f"{idname_namespace}_{label.replace(' ', '').upper()}"
# create submenu class if item is menu.
@@ -161,7 +111,7 @@ class CustomNodes():
self._find_item(found_items, self.menu_items, node_type)
return found_items[0] if len(found_items) > 0 else None
def add_node(self, node_tree, node_type: str):
def add_node(self, node_group, node_type: str):
item = self.find_item(node_type)
op = AddCustomNode()
op.nodes_file = self.nodes_file
@@ -174,15 +124,15 @@ class CustomNodes():
op.node_label = ""
op.node_link = True
op.node_callback = ""
return op.add_node(node_tree)
return op.add_node(node_group)
def register(self):
for cls in self.menu_classes:
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(self.add_node_menu)
bpy.types.Scene.custom_nodes_dir = bpy.props.StringProperty(
name="Nodes Directory",
bpy.types.Scene.customnodes_node_dir = bpy.props.StringProperty(
name="Node Directory",
subtype='DIR_PATH',
default="//"
)

View File

@@ -85,10 +85,16 @@ class AddCustomNode():
except:
...
def add_node(self, node_tree):
def add_node(self, node_group):
# Deselect all nodes first
for node in node_group.nodes:
if node.select:
node.select = False
self.get_node_tree(self.node_type, self.node_link)
node = node_tree.new("GeometryNodeGroup")
node = node_group.nodes.new("GeometryNodeGroup")
self.assign_node_tree(node)
node.show_options = False
return node
@@ -116,5 +122,6 @@ class CUSTOMNODES_OT_Add_Custom_Node(bpy.types.Operator, AddCustomNode):
node = bpy.context.active_node
self.assign_node_tree(node)
node.show_options = False
return {"FINISHED"}

View File

@@ -0,0 +1,4 @@
class CancelledByUser(Exception):
def __init__(self):
message = 'Cancelled by user'
super().__init__(message)

View File

@@ -1,2 +0,0 @@
from .preferences import ExternalPackagePreferences
from .package import PackageInstaller

View File

@@ -1,389 +0,0 @@
"""
Handling installation of external python packages inside of Blender's bundled python.
"""
import subprocess
import sys
import logging
from importlib.metadata import version as get_version, PackageNotFoundError
import bpy
from pathlib import Path
ADDON_NAME = __package__.split(".")[0]
PYPI_MIRROR = {
# the original.
'Default': '',
# two mirrors in China Mainland to help those poor victims under GFW.
'BFSU (Beijing)': 'https://mirrors.bfsu.edu.cn/pypi/web/simple',
'TUNA (Beijing)': 'https://pypi.tuna.tsinghua.edu.cn/simple',
# append more if necessary.
}
class InstallationError(Exception):
"""
Exception raised when there is an error installing a package.
Attributes
----------
package_name : str
The name of the package that failed to install.
error_message : str
The error message returned by pip.
"""
def __init__(self, package_name, error_message):
self.package_name = package_name
self.error_message = error_message
super().__init__(f"Failed to install {package_name}: {error_message}")
class PackageInstaller():
def __init__(
self,
requirements_dir: str,
log_dir: str = None,
pypi_mirror_provider='Default',
) -> None:
self.pypi_mirror_provider = pypi_mirror_provider
self.log_path: Path = Path(log_dir) if log_dir \
else Path.home() / '.externalpackage' / 'logs'
self.requirements_path: Path = Path(requirements_dir)
def start_logging(self, logfile_name: str = 'side-packages-install') -> logging.Logger:
"""
Configure and start logging to a file.
Parameters
----------
logfile_name : str, optional
The name of the log file. Defaults to 'side-packages-install'.
Returns
-------
logging.Logger
A Logger object that can be used to write log messages.
This function sets up a logging configuration with a specified log file name and logging level.
The log file will be created in the `ADDON_DIR/logs` directory. If the directory
does not exist, it will be created. The function returns a Logger object that can be used to
write log messages.
"""
# Create the logs directory if it doesn't exist
self.log_path.mkdir(parents=True, exist_ok=True)
# Set up logging configuration
logfile_path = Path(self.log_path, f"{logfile_name}.log")
logging.basicConfig(filename=logfile_path, level=logging.INFO)
# Return logger object
return logging.getLogger()
@property
def pypi_mirror_url(self) -> str:
"""
Process a PyPI mirror provider and return the corresponding URL.
Parameters
----------
pypi_mirror_provider : str
The PyPI mirror provider to process.
Returns
-------
str
The URL of the PyPI mirror.
Raises
------
ValueError
If the provided PyPI mirror provider is invalid.
"""
if self.pypi_mirror_provider.startswith('https:'):
return self.pypi_mirror_provider
elif self.pypi_mirror_provider in PYPI_MIRROR.keys():
return PYPI_MIRROR[self.pypi_mirror_provider]
else:
raise ValueError(
f"Invalid PyPI mirror provider: {self.pypi_mirror_provider}")
@property
def packages(self) -> dict:
"""
Read a requirements file and extract package information into a dictionary.
Parameters
----------
requirements : str, optional
The path to the requirements file. If not provided, the function looks for a `requirements.txt`
file in the same directory as the script.
Returns
-------
dict
A dictionary containing package information. Each element of the dictionary is a dictionary containing the package name, version, and description.
Example
-------
Given the following requirements file:
```python
Flask==1.1.2 # A micro web framework for Python
pandas==1.2.3 # A fast, powerful, flexible, and easy-to-use data analysis and manipulation tool
numpy==1.20.1 # Fundamental package for scientific computing
```
The function would return the following dictionary:
```python
[
{
"package": "Flask",
"version": "1.1.2",
"description": "A micro web framework for Python"
},
{
"package": "pandas",
"version": "1.2.3",
"description": "A fast, powerful, flexible, and easy-to-use data analysis and manipulation tool"
},
{
"package": "numpy",
"version": "1.20.1",
"description": "Fundamental package for scientific computing"
}
]
```
"""
requirements_filepath = self.requirements_path / "requirements.txt"
if requirements_filepath.is_file():
with requirements_filepath.open('r') as f:
lines = f.read().splitlines()
packages = {}
for line in lines:
try:
package, description = line.split('#')
package_meta = package.split('==')
name = package_meta[0].strip()
packages[name] = {
"name": name,
"version": package_meta[1].strip(),
"description": description.strip()
}
except ValueError:
# Skip line if it doesn't have the expected format
pass
return packages
else:
raise FileNotFoundError(requirements_filepath)
def is_installed(self, package_name: str) -> bool:
"""
Check if the specified package is installed and the version matches that specified
in the `requirements.txt` file.
Parameters
----------
package : str
The name of the package to check.
Returns
-------
bool
True if the package is the current version, False otherwise.
"""
package = self.packages.get(package_name)
try:
available_version = get_version(package['name'])
return available_version == package['version']
except PackageNotFoundError:
return False
def run_python(self, cmd_list: list = None, timeout: int = 600):
"""
Runs pip command using the specified command list and returns the command output.
Parameters
----------
cmd_list : list, optional
List of pip commands to be executed. Defaults to None.
mirror_url : str, optional
URL of a package repository mirror to be used for the command. Defaults to ''.
timeout : int, optional
Time in seconds to wait for the command to complete. Defaults to 600.
Returns
-------
tuple
A tuple containing the command list, command return code, command standard output,
and command standard error.
Example
-------
Install numpy using pip and print the command output
```python
cmd_list = ["-m", "pip", "install", "numpy"]
mirror_url = 'https://pypi.org/simple/'
cmd_output = run_python(cmd_list, timeout=300)
print(cmd_output)
```
"""
# path to python.exe
python_exe = str(Path(sys.executable).resolve())
# build the command list
cmd_list = [python_exe] + cmd_list
# add mirror to the command list if it's valid
if self.pypi_mirror_url and self.pypi_mirror_url.startswith('https'):
cmd_list += ['-i', self.pypi_mirror_url]
log = self.start_logging()
log.info(f"Running Pip: '{cmd_list}'")
# run the command and capture the output
result = subprocess.run(cmd_list, timeout=timeout,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
log.error('Command failed: %s', cmd_list)
log.error('stdout: %s', result.stdout.decode())
log.error('stderr: %s', result.stderr.decode(errors='ignore'))
else:
log.info('Command succeeded: %s', cmd_list)
log.info('stdout: %s', result.stdout.decode())
# return the command list, return code, stdout, and stderr as a tuple
return result
def install_package(self, package: str) -> list:
"""
Install a Python package and its dependencies using pip.
Parameters
----------
package : str
The name of the package to install.
pypi_mirror_provider : str, optional
The name/url of the PyPI mirror provider to use. Default is 'Default'.
Returns
-------
list
A list of tuples containing the command list, return code, stdout, and stderr
for each pip command run.
Raises
------
ValueError
If the package name is not provided.
Example
-------
To install the package 'requests' from the PyPI mirror 'MyMirror', use:
```
install_package('requests', 'MyMirror')
```
"""
if not package:
raise ValueError("Package name must be provided.")
print(f"Installing {package}...")
print(f"Using PyPI mirror: {self.pypi_mirror_provider}")
self.run_python(["-m", "ensurepip"]),
self.run_python(["-m", "pip", "install", "--upgrade", "pip"])
result = self.run_python(["-m", "pip", "install", package, "--user"])
return result
def install_all_packages(self, pypi_mirror_provider: str = 'Default') -> list:
"""
Install all packages listed in the 'requirements.txt' file.
Parameters
----------
pypi_mirror_provider : str, optional
The PyPI mirror to use for package installation. Defaults to 'Default',
which uses the official PyPI repository.
Returns
-------
list
A list of tuples containing the installation results for each package.
Raises
------
InstallationError
If there is an error during package installation.
Example
-------
To install all packages listed in the 'requirements.txt' file, run the following command:
```
install_all_packages(pypi_mirror_provider='https://pypi.org/simple/')
```
"""
results = []
for package in self.packages.items():
try:
result = self.install_package(
package=f"{package.get('name')}=={package.get('version')}"
)
results.append(result)
except InstallationError as e:
raise InstallationError(
f"Error installing package {package.get('name')}: {str(e)}")
return results
class EXTERNALPACKAGE_OT_Install_Package(bpy.types.Operator):
bl_idname = 'externalpackage.install_package'
bl_label = 'Install Given Python Package'
bl_options = {'REGISTER', 'INTERNAL'}
package: bpy.props.StringProperty(
name='Python Package',
description='Python Package to Install'
) # type: ignore
version: bpy.props.StringProperty(
name='Python Package',
description='Python Package to Install'
) # type: ignore
description: bpy.props.StringProperty(
name='Operator description',
default='Install specified python package.'
) # type: ignore
@classmethod
def description(cls, context, properties):
return properties.description
def execute(self, context):
preferences = context.preferences.addons[ADDON_NAME].preferences
installer = PackageInstaller(
pypi_mirror_provider=preferences.pypi_mirror_provider,
log_dir=preferences.log_dir,
requirements_dir=preferences.requirements_dir
)
result = installer.install_package(f"{self.package}=={self.version}")
if result.returncode == 0 or installer.is_installed(self.package):
self.report(
{'INFO'},
f"Successfully installed {self.package} v{self.version}"
)
else:
self.report(
{'ERROR'},
f"""Error installing package. Please check the log files
in {preferences.log_dir}."""
)
return {'FINISHED'}

View File

@@ -1,95 +0,0 @@
import bpy
import subprocess
from pathlib import Path
from .package import PackageInstaller, PYPI_MIRROR
# Defines the preferences panel for the addon, which shows the buttons for
# installing and reinstalling the required python packages defined in 'requirements.txt'
def get_pypi_mirrors(self, context, edit_text):
return PYPI_MIRROR.keys()
class RebootBlender(bpy.types.Operator):
bl_idname = "externalpackage.reboot_blender"
bl_label = "Reboot Blender"
bl_description = "Reboot Blender"
bl_options = {'UNDO'}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self, event)
return {'RUNNING_MODAL'}
def execute(self, context):
blender_launcher = Path(
bpy.app.binary_path).parent / "blender-launcher.exe"
subprocess.run([str(blender_launcher), "-con", "--python-expr",
"import bpy; bpy.ops.wm.recover_last_session()"])
bpy.ops.wm.quit_blender()
return {'FINISHED'}
class ExternalPackagePreferences():
requirements_dir: bpy.props.StringProperty(
name="requirements.txt Directory",
subtype='DIR_PATH',
default=str(Path(__file__).parent)
) # type: ignore
log_dir: bpy.props.StringProperty(
name="Python Log Directory",
subtype='DIR_PATH',
default=str(Path(Path.home(), '.externalpackage', 'logs'))
) # type: ignore
pypi_mirror_provider: bpy.props.StringProperty(
name='pypi_mirror_provider',
description='PyPI Mirror Provider',
options={'TEXTEDIT_UPDATE', 'LIBRARY_EDITABLE'},
default='Default',
subtype='NONE',
search=get_pypi_mirrors,
) # type: ignore
def draw(self, context):
layout = self.layout
layout.label(
text='**Install the required packages, then hit "Reboot Blender"**')
col_main = layout.column(heading='', align=False)
row_import = col_main.row()
row_import.prop(self, 'pypi_mirror_provider', text='PyPI Mirror')
installer = PackageInstaller(
pypi_mirror_provider=self.pypi_mirror_provider,
log_dir=self.log_dir,
requirements_dir=self.requirements_dir
)
for package in installer.packages.values():
name = package.get('name')
version = package.get('version')
description = package.get('description')
if installer.is_installed(name):
row = layout.row()
row.label(text=f"{name} version {version} is installed.")
op = row.operator('externalpackage.install_package',
text=f'Reinstall {name}')
op.package = name
op.version = version
op.description = f'Reinstall {name}'
else:
row = layout.row(heading=f"Package: {name}")
col = row.column()
col.label(text=str(description))
col = row.column()
op = col.operator('externalpackage.install_package',
text=f'Install {name}')
op.package = name
op.version = version
op.description = f'Install required python package: {name}'
layout.operator(RebootBlender.bl_idname)

View File

@@ -1,805 +0,0 @@
import bpy
import shutil
from bpy_extras.io_utils import axis_conversion
import pyopenvdb as vdb
import numpy as np
from pathlib import Path
from uuid import uuid4
import mathutils
from .nodes import custom_nodes
from .props import BIOXELNODES_Series
from .utils import (calc_bbox_verts, get_container, get_layer, get_text_index_str,
get_node_by_type, hide_in_ray, lock_transform, show_message)
try:
import SimpleITK as sitk
except:
...
SUPPORT_EXTS = ['.dcm', '.DCM', '.DICOM',
'.bmp', '.BMP',
'.PIC', '.pic',
'.gipl', '.gipl.gz',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.lsm', '.LSM',
'.tif', '.TIF', '.tiff', '.TIFF',
'.mnc', '.MNC',
'.mrc', '.rec',
'.mha', '.mhd',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.nrrd', '.nhdr',
'.png', '.PNG',
'.vtk']
SEQUENCE_EXTS = ['.dcm', '.DCM', '.DICOM',
'.bmp', '.BMP',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.tif', '.TIF', '.tiff', '.TIFF',
'.png', '.PNG']
DICOM_EXTS = ['.dcm', '.DCM', '.DICOM']
FH_EXTS = ['.dcm', '.DCM', '.DICOM',
'.gipl', '.gipl.gz',
'.mnc', '.MNC',
'.mrc', '.rec',
'.mha', '.mhd',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nrrd', '.nhdr',
'.vtk',
'.gz']
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 get_ext(filepath: str) -> str:
file_path = Path(filepath)
if file_path.name.endswith(".nii.gz"):
return ".nii.gz"
elif file_path.name.endswith(".img.gz"):
return ".img.gz"
elif file_path.name.endswith(".gipl.gz"):
return ".gipl.gz"
else:
return file_path.suffix
def get_sequence_name(filepath: str) -> str:
ext = get_ext(filepath)
filename = Path(filepath).name.removesuffix(ext)
index: str = get_text_index_str(filename)
return filename.removesuffix(index)
def get_sequence_index(filepath: str) -> int:
ext = get_ext(filepath)
filename = Path(filepath).name.removesuffix(ext)
index: str = get_text_index_str(filename)
return int(index) if index else 0
def collect_image_sequence(filepath: str):
file_path = Path(filepath).resolve()
files = list(file_path.parent.iterdir())
files = [f for f in files if f.is_file()
and get_ext(file_path) == get_ext(f)
and get_sequence_name(file_path) == get_sequence_name(f)]
files.sort(key=get_sequence_index)
sequence = [str(f) for f in files]
return sequence
def read_image(filepath: str, series_id=""):
ext = get_ext(filepath)
if ext in DICOM_EXTS:
dir_path = Path(filepath).resolve().parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_files = reader.GetGDCMSeriesFileNames(
str(dir_path), series_id)
reader.SetFileNames(series_files)
image = reader.Execute()
name = dir_path.name
elif ext in SEQUENCE_EXTS:
image = sitk.ReadImage(filepath)
if image.GetDimension() == 2:
sequence = collect_image_sequence(filepath)
image = sitk.ReadImage(sequence)
name = get_sequence_name(filepath)
else:
image = sitk.ReadImage(filepath)
name = Path(filepath).name.removesuffix(ext)
else:
image = sitk.ReadImage(filepath)
name = Path(filepath).name.removesuffix(ext)
return image, name
def rgb2gray(image):
# Convert sRGB image to gray scale and rescale results to [0,255]
layers = [sitk.VectorIndexSelectionCast(
image, i, sitk.sitkFloat32) for i in range(image.GetNumberOfComponentsPerPixel())]
# linear mapping
I = (0.2126*layers[0] + 0.7152*layers[1] + 0.0722*layers[2])
return sitk.Cast(I, sitk.sitkFloat32)
def x2gray(image):
return sitk.VectorIndexSelectionCast(image, 0, sitk.sitkUInt16)
class ImportVolumeDataDialog(bpy.types.Operator):
bl_idname = "bioxelnodes.import_volume_data_dialog"
bl_label = "Volume Data as Bioxel Layer"
bl_description = "Import Volume Data as Bioxel Layer"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
container_name: bpy.props.StringProperty(
name="Container Name"
) # type: ignore
layer_name: bpy.props.StringProperty(
name="Layer Name",
) # type: ignore
series_id: bpy.props.StringProperty() # type: ignore
resample_method: bpy.props.EnumProperty(
name="Resample Method",
default="linear",
items=[("linear", "Linear", ""),
("nearest_neighbor", "Nearest Neighbor", ""),
("gaussian", "Gaussian", "")]
) # type: ignore
container: bpy.props.StringProperty() # type: ignore
read_as: bpy.props.EnumProperty(
name="Read as",
default="scalar",
items=[("scalar", "Scalar", ""),
("labels", "Labels", "")]
) # type: ignore
bioxel_size: bpy.props.FloatProperty(
name="Bioxel Size (Larger size means small resolution)",
soft_min=0.1, soft_max=10.0,
min=0.1, max=1e2,
default=1,
) # type: ignore
orig_spacing: bpy.props.FloatVectorProperty(
name="Original Spacing",
default=(1, 1, 1),
) # type: ignore
orig_shape: bpy.props.IntVectorProperty(
name="Original Shape",
default=(100, 100, 100)
) # type: ignore
scene_scale: bpy.props.FloatProperty(
name="Scene Scale (Bioxel Unit pre Blender Unit)",
soft_min=0.001, soft_max=100.0,
min=1e-6, max=1e6,
default=0.01,
) # type: ignore
do_orient: bpy.props.BoolProperty(
name="Orient to RAS",
default=True,
) # type: ignore
invert_scalar: bpy.props.BoolProperty(
name="Invert Scalar (Background value maybe higher than object)",
default=False,
) # type: ignore
def execute(self, context):
image, name = read_image(self.filepath, self.series_id)
container_name = self.container_name or name
bioxel_size = self.bioxel_size
orig_spacing = self.orig_spacing
image_spacing = image.GetSpacing()
image_shape = image.GetSize()
layer_spacing = (
image_spacing[0] / orig_spacing[0] * bioxel_size,
image_spacing[1] / orig_spacing[1] * bioxel_size,
image_spacing[2] / orig_spacing[2] * bioxel_size
)
layer_shape = get_layer_shape(
bioxel_size, image_shape, orig_spacing)
if self.read_as == "labels":
if "vector" in image.GetPixelIDTypeAsString():
print("Conveting to Grayscale...")
image = x2gray(image)
else:
image = sitk.Cast(image, sitk.sitkUInt16)
default_value = 0
elif self.read_as == "scalar":
if "vector" in image.GetPixelIDTypeAsString():
print("Conveting to Grayscale...")
image = rgb2gray(image)
else:
image = sitk.Cast(image, sitk.sitkFloat32)
stats = sitk.StatisticsImageFilter()
stats.Execute(image)
default_value = stats.GetMaximum() if self.invert_scalar else stats.GetMinimum()
if self.resample_method == "linear":
interpolator = sitk.sitkLinear
elif self.resample_method == "nearest_neighbor":
interpolator = sitk.sitkNearestNeighbor
elif self.resample_method == "gaussian":
interpolator = sitk.sitkGaussian
if self.read_as == "labels":
interpolator = sitk.sitkNearestNeighbor
print(f"Resampling...")
image = sitk.Resample(
image1=image,
size=layer_shape,
transform=sitk.Transform(),
interpolator=interpolator,
outputOrigin=image.GetOrigin(),
outputSpacing=layer_spacing,
outputDirection=image.GetDirection(),
defaultPixelValue=default_value,
outputPixelType=image.GetPixelID(),
)
if self.do_orient:
print("Orienting to RAS...")
image = sitk.DICOMOrient(image, 'RAS')
print("Oriented Origin:", image.GetOrigin())
print("Oriented Direction:", image.GetDirection())
# return {'FINISHED'}
# ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i]
# https://www.slicer.org/wiki/Coordinate_systems
# ITK Numpy 3D
# R (ight) i -> k -> x
# A (nterior) j -> j -> y
# S (uperior) k -> i -> z
array = sitk.GetArrayFromImage(image)
orig_dtype = str(array.dtype)
# print(f"Coverting Dtype from {orig_dtype} to float...")
# array = array.astype(float)
if array.ndim == 4:
array = array[:, :, :, 0]
array = np.transpose(array)
# After sitk.DICOMOrient(), origin and direction will also orient base on LPS
# so we need to convert them into RAS
mat_lps2ras = axis_conversion(
from_forward='-Z',
from_up='-Y',
to_forward='-Z',
to_up='Y'
).to_4x4()
origin = image.GetOrigin()
direction = image.GetDirection()
mat_location = mathutils.Matrix.Translation(
mathutils.Vector(origin)
)
mat_rotation = mathutils.Matrix(
np.array(direction).reshape((3, 3))
).to_4x4()
mat_scale = mathutils.Matrix.Scale(
bioxel_size, 4
)
transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale \
if self.do_orient else mat_location @ mat_rotation @ mat_scale
# Wrapper a Container
if not self.container:
# Make transformation
scene_scale = float(self.scene_scale)
# (S)uperior -Z -> Y
# (A)osterior Y -> Z
mat_ras2blender = axis_conversion(
from_forward='-Z',
from_up='Y',
to_forward='Y',
to_up='Z'
).to_4x4()
mat_scene_scale = mathutils.Matrix.Scale(
scene_scale, 4
)
bpy.ops.mesh.primitive_cube_add(
enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
)
container = bpy.context.active_object
bbox_verts = calc_bbox_verts((0, 0, 0), array.shape)
for index, vert in enumerate(container.data.vertices):
bbox_transform = transfrom
vert.co = bbox_transform @ mathutils.Vector(bbox_verts[index])
container.matrix_world = mat_ras2blender @ mat_scene_scale
container.name = container_name
container.data.name = container_name
container['bioxel_container'] = True
container['scene_scale'] = scene_scale
bpy.ops.node.new_geometry_nodes_modifier()
container_node_tree = container.modifiers[0].node_group
input_node = get_node_by_type(container_node_tree.nodes,
'NodeGroupInput')[0]
container_node_tree.links.remove(input_node.outputs[0].links[0])
# bpy.ops.mesh.primitive_cube_add(
# enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
# )
# frame = bpy.context.active_object
# bbox_verts = calc_bbox_verts((0, 0, 0), array.shape)
# for index, vert in enumerate(frame.data.vertices):
# # bbox_transform = mat_ras2blender @ mat_scene_scale @ transfrom
# bbox_transform = transfrom
# vert.co = bbox_transform @ mathutils.Vector(bbox_verts[index])
# frame.name = f"Frame_{container_name}"
# frame.data.name = f"Frame_{container_name}"
# lock_transform(frame)
# hide_in_ray(frame)
# frame.hide_select = True
# frame.hide_render = True
# frame.display_type = 'WIRE'
# frame.parent = container
else:
container = bpy.data.objects[self.container]
container_node_tree = container.modifiers[0].node_group
preferences = context.preferences.addons[__package__].preferences
cache_dir = Path(preferences.cache_dir, 'VDBs')
cache_dir.mkdir(parents=True, exist_ok=True)
loc, rot, sca = transfrom.decompose()
layer_origin = tuple(loc)
layer_rotation = tuple(rot.to_euler())
layer_shape = tuple(array.shape)
def create_layer(array, layer_name, layer_type="scalar"):
grid = vdb.FloatGrid()
array = array.copy().astype(float)
grid.copyFromArray(array)
grid.transform = vdb.createLinearTransform(transfrom.transposed())
grid.name = layer_type
vdb_path = Path(cache_dir, f"{uuid4()}.vdb")
print(f"Storing the cache ({str(vdb_path)})...")
vdb.write(str(vdb_path), grids=[grid])
# Read VDB
print(f"Loading the cache to Blender scene...")
bpy.ops.object.volume_import(
filepath=str(vdb_path), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
layer = bpy.context.active_object
# Set props to VDB object
layer.name = layer_name
layer.data.name = layer_name
lock_transform(layer)
hide_in_ray(layer)
layer.hide_select = True
layer.hide_render = True
layer.hide_viewport = True
layer.data.display.use_slice = True
layer.data.display.density = 1e-05
layer['bioxel_layer'] = True
layer['bioxel_layer_type'] = layer_type
layer.parent = container
for collection in layer.users_collection:
collection.objects.unlink(layer)
for collection in container.users_collection:
collection.objects.link(layer)
print(f"Creating layer ...")
bpy.ops.node.new_geometry_nodes_modifier()
node_tree = layer.modifiers[0].node_group
nodes = node_tree.nodes
links = node_tree.links
input_node = get_node_by_type(nodes, 'NodeGroupInput')[0]
output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0]
to_layer_node = custom_nodes.add_node(nodes, "BioxelNodes__ConvertToLayer")
links.new(input_node.outputs[0], to_layer_node.inputs[0])
links.new(to_layer_node.outputs[0], output_node.inputs[0])
to_layer_node.inputs['Bioxel Size'].default_value = bioxel_size
to_layer_node.inputs['Shape'].default_value = layer_shape
to_layer_node.inputs['Origin'].default_value = layer_origin
to_layer_node.inputs['Rotation'].default_value = layer_rotation
return layer
if self.read_as == "labels":
orig_max = int(np.max(array))
orig_min = int(np.min(array))
layer_name = self.layer_name or "Label"
for i in range(orig_max):
label = array == np.full_like(array, i+1)
layer = create_layer(array=label,
layer_name=f"{container_name}_{layer_name}_{i+1}",
layer_type="label")
output_node = get_node_by_type(container_node_tree.nodes,
'NodeGroupOutput')[0]
mask_node = custom_nodes.add_node(container_node_tree.nodes,
'BioxelNodes_MaskByLabel')
mask_node.inputs[0].default_value = layer
if len(output_node.inputs[0].links) == 0:
container_node_tree.links.new(mask_node.outputs[0],
output_node.inputs[0])
else:
if self.invert_scalar:
array = -array
orig_max = float(np.max(array))
orig_min = float(np.min(array))
orig_median = float(np.median(array))
orig_percentile80 = float(np.percentile(array, 80)) \
if self.invert_scalar else float(np.percentile(array, 80))
stats_table = [("Max", orig_max),
("Min", orig_min),
("Median", orig_median),
("80%", orig_percentile80)]
print("Volume Data Stats:")
for stats in stats_table:
print("| {: >10} | {: >40} |".format(*stats))
scalar_offset = 0
if orig_min < 0:
scalar_offset = -orig_min
array = array + np.full_like(array, scalar_offset)
layer_name = self.layer_name or "Scalar"
layer = create_layer(array=array,
layer_name=f"{container_name}_{layer_name}",
layer_type="scalar")
layer_node_tree = layer.modifiers[0].node_group
to_layer_node = layer_node_tree.nodes['BioxelNodes__ConvertToLayer']
to_layer_node.inputs['Scalar Offset'].default_value = scalar_offset
to_layer_node.inputs['Scalar Max'].default_value = orig_max
to_layer_node.inputs['Scalar Min'].default_value = orig_min
output_node = get_node_by_type(container_node_tree.nodes,
'NodeGroupOutput')[0]
mask_node = custom_nodes.add_node(container_node_tree.nodes,
'BioxelNodes_MaskByThreshold')
mask_node.inputs[0].default_value = layer
if len(output_node.inputs[0].links) == 0:
container_node_tree.links.new(mask_node.outputs[0],
output_node.inputs[0])
bpy.context.view_layer.objects.active = container
# Change render setting for better result
if preferences.do_change_render_setting:
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.volume_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_preview_step_rate = 10
self.report({"INFO"}, "Successfully Imported")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self, width=500)
return {'RUNNING_MODAL'}
def draw(self, context):
layer_shape = get_layer_shape(
self.bioxel_size, self.orig_shape, self.orig_spacing)
bioxel_count = layer_shape[0] * layer_shape[1] * layer_shape[2]
text = f"Shape will be: {str(layer_shape)} {bioxel_count:,} "
if bioxel_count > 100000000:
text += "**TOO LARGE!**"
layout = self.layout
panel = layout.box()
panel.prop(self, "container_name")
panel.prop(self, "layer_name")
panel = layout.box()
panel.prop(self, "resample_method")
panel.prop(self, "bioxel_size")
row = panel.row()
row.prop(self, "orig_spacing")
panel.label(text=text)
panel = layout.box()
panel.prop(self, "do_orient")
panel = layout.box()
panel.prop(self, "read_as")
if self.read_as == "labels":
...
else:
panel.prop(self, "invert_scalar")
panel = layout.box()
panel.prop(self, "scene_scale")
def get_series_ids(self, context):
items = []
for index, series_id in enumerate(self.series_ids):
items.append((
series_id.id,
series_id.label,
"",
index
))
return items
class ParseVolumeData(bpy.types.Operator):
bl_idname = "bioxelnodes.parse_volume_data"
bl_label = "Volume Data as Bioxel Layer"
bl_description = "Import Volume Data as Bioxel Layer"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
container: bpy.props.StringProperty() # type: ignore
series_id: bpy.props.EnumProperty(
name="Select Series",
items=get_series_ids
) # type: ignore
series_ids: bpy.props.CollectionProperty(
type=BIOXELNODES_Series) # type: ignore
def execute(self, context):
ext = get_ext(self.filepath)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported extension.")
return {'CANCELLED'}
print("Collecting Meta Data...")
image, name = read_image(self.filepath, self.series_id)
stats_table = [("Shape", str(image.GetSize())),
("Spacing", str(image.GetSpacing())),
("Origin", str(image.GetOrigin())),
("Direction", str(image.GetDirection())),
("Data Type", image.GetPixelIDTypeAsString())]
for k in image.GetMetaDataKeys():
stats_table.append((k, str(image.GetMetaData(k))))
print("Meta Data:")
for stats in stats_table:
print("| {: >20} | {: >100} |".format(*stats))
do_orient = ext not in SEQUENCE_EXTS or ext in DICOM_EXTS
orig_shape = image.GetSize()
orig_spacing = image.GetSpacing()
min_size = min(orig_spacing[0], orig_spacing[1], orig_spacing[2])
bioxel_size = max(min_size, 1.0)
if self.container:
container = bpy.data.objects[self.container]
container_name = container.name
else:
container_name = name
bpy.ops.bioxelnodes.import_volume_data_dialog(
'INVOKE_DEFAULT',
filepath=self.filepath,
container_name=container_name,
orig_shape=orig_shape,
orig_spacing=orig_spacing,
bioxel_size=bioxel_size,
series_id=self.series_id or "",
do_orient=do_orient,
container=self.container
)
self.report({"INFO"}, "Successfully Readed.")
return {'FINISHED'}
def modal(self, context, event):
self.execute(context)
return {'FINISHED'}
def invoke(self, context, event):
if not self.filepath and not self.directory:
return {'CANCELLED'}
show_message('Reading image data, it may take a while...',
'Please be patient...')
if get_ext(self.filepath) == '.dcm':
dir_path = Path(self.filepath).parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_ids = reader.GetGDCMSeriesIDs(str(dir_path))
for _id in series_ids:
series_id = self.series_ids.add()
series_id.id = _id
series_id.label = _id
if len(series_ids) > 1:
context.window_manager.invoke_props_dialog(self, width=400)
return {'RUNNING_MODAL'}
else:
self.series_id = series_ids[0]
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
layout.prop(self, "series_id")
layout.label(
text="Reading image data, it may take a while...")
layout.label(
text="Please be patient...")
class ImportVolumeData(bpy.types.Operator):
bl_idname = "bioxelnodes.import_volume_data"
bl_label = "Volume Data as Bioxel Layer"
bl_description = "Import Volume Data as Bioxel Layer"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
def execute(self, context):
bpy.ops.bioxelnodes.parse_volume_data(
'INVOKE_DEFAULT',
filepath=self.filepath,
directory=self.directory
)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
try:
class BIOXELNODES_FH_ImportVolumeData(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportVolumeData"
bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.parse_volume_data"
bl_file_extensions = ";".join(FH_EXTS)
@classmethod
def poll_drop(cls, context):
return (context.area and context.area.type == 'VIEW_3D')
except:
...
class AddVolumeData(bpy.types.Operator):
bl_idname = "bioxelnodes.add_volume_data"
bl_label = "Add Volume Data to Container"
bl_description = "Add Volume Data to Container"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
@classmethod
def poll(cls, context):
container = get_container(bpy.context.active_object)
return True if container else False
def execute(self, context):
container = get_container(bpy.context.active_object)
bpy.ops.bioxelnodes.parse_volume_data(
'INVOKE_DEFAULT',
filepath=self.filepath,
directory=self.directory,
container=container.name
)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class ExportVDB(bpy.types.Operator):
bl_idname = "bioxelnodes.export_vdb"
bl_label = "Bioxel Layer as VDB"
bl_description = "Export Bioxel Layer as VDB"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".vdb"
@classmethod
def poll(cls, context):
layer = get_layer(bpy.context.active_object)
return True if layer else False
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
layer = get_layer(bpy.context.active_object)
filepath = f"{self.filepath.split('.')[0]}.vdb"
# "//"
source_dir = bpy.path.abspath(layer.data.filepath)
output_path: Path = Path(filepath).resolve()
source_path: Path = Path(source_dir).resolve()
# print('output_path', output_path)
# print('source_path', source_path)
shutil.copy(source_path, output_path)
self.report({"INFO"}, f"Successfully exported to {output_path}")
return {'FINISHED'}

View File

@@ -1,221 +0,0 @@
# TODO: WIP
import bpy
import bmesh
def create_points_obj(points, name="points"):
bpy.ops.mesh.primitive_cube_add(
enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1)
)
obj = bpy.context.active_object
mesh = obj.data
bm = bmesh.new()
value_layer = bm.verts.layers.float.new('value')
for point in points:
pos = point['pos']
value = point['value']
vert = bm.verts.new(pos) # add a new vert
vert[value_layer] = value
# make the bmesh the object's mesh
bm.to_mesh(mesh)
bm.free() # always do this when finished
return obj
# class ImportDICOMPointsDialog(bpy.types.Operator):
# bl_idname = "bioxelnodes.import_dicom_points_dialog"
# bl_label = "Volume Data as Bioxels"
# bl_description = "Import Volume Data as Bioxels (VDB)"
# bl_options = {'UNDO'}
# filepath: bpy.props.StringProperty(
# subtype="FILE_PATH",
# options={'HIDDEN'}
# ) # type: ignore
# bioxels_shape: bpy.props.IntVectorProperty(
# name="Bioxels Shape (ReadOnly)",
# min=0,
# default=(100, 100, 100)
# ) # type: ignore
# bioxel_size: bpy.props.FloatProperty(
# name="Bioxel Size",
# soft_min=0.1, soft_max=10.0,
# min=1e-2, max=1e2,
# default=1,
# update=on_bioxel_size_changed
# ) # type: ignore
# orig_spacing: bpy.props.FloatVectorProperty(
# name="Original Spacing",
# default=(1, 1, 1),
# update=on_orig_spacing_changed
# ) # type: ignore
# orig_shape: bpy.props.IntVectorProperty(
# name="Original Shape",
# default=(100, 100, 100),
# options={'HIDDEN'}
# ) # type: ignore
# auto: bpy.props.BoolProperty(
# name="Auto Setting",
# default=False,
# options={'HIDDEN'}
# ) # type: ignore
# scene_scale: bpy.props.FloatProperty(
# name="Scene Scale",
# soft_min=0.001, soft_max=100.0,
# min=1e-6, max=1e6,
# default=0.01,
# ) # type: ignore
# do_add_segmentnode: bpy.props.BoolProperty(
# name="Add Segment Node",
# default=True,
# ) # type: ignore
# do_change_render_setting: bpy.props.BoolProperty(
# name="Change Render Setting",
# default=True,
# ) # type: ignore
# def execute(self, context):
# files = get_data_files(self.filepath)
# name = Path(self.filepath).parent.name
# image = sitk.ReadImage(files)
# bioxel_size = float(self.bioxel_size)
# orig_spacing = tuple(self.orig_spacing)
# image_spacing = image.GetSpacing()
# image_shape = image.GetSize()
# bioxels_spacing = (
# image_spacing[0] / orig_spacing[0] * bioxel_size,
# image_spacing[1] / orig_spacing[1] * bioxel_size,
# image_spacing[2] / orig_spacing[2] * bioxel_size
# )
# bioxels_shape = (
# int(image_shape[0] / bioxel_size * orig_spacing[0]),
# int(image_shape[1] / bioxel_size * orig_spacing[1]),
# int(image_shape[2] / bioxel_size * orig_spacing[2]),
# )
# print("Resampling...")
# image = sitk.Resample(
# image1=image,
# size=bioxels_shape,
# transform=sitk.Transform(),
# interpolator=sitk.sitkLinear,
# outputOrigin=image.GetOrigin(),
# outputSpacing=bioxels_spacing,
# outputDirection=image.GetDirection(),
# defaultPixelValue=0,
# outputPixelType=image.GetPixelID(),
# )
# print("Orienting to RAS...")
# image = sitk.DICOMOrient(image, 'RAS')
# array = sitk.GetArrayFromImage(image)
# orig_dtype = str(array.dtype)
# print(f"Coverting Dtype from {orig_dtype} to float...")
# array = array.astype(float)
# # ITK indices, by convention, are [i,j,k] while NumPy indices are [k,j,i]
# # https://www.slicer.org/wiki/Coordinate_systems
# # ITK Numpy 3D
# # R (ight) i -> k -> x
# # A (nterior) j -> j -> y
# # S (uperior) k -> i -> z
# array = np.transpose(array)
# bioxels_max = float(np.max(array))
# bioxels_min = float(np.min(array))
# bioxels_shape = array.shape
# print("Bioxel Size:", bioxel_size)
# print("Bioxels Shape:", bioxels_shape)
# bioxels_offset = 0.0
# if bioxels_min < 0 and orig_dtype[0] != "u":
# bioxels_offset = -bioxels_min
# array = array + np.full_like(array, bioxels_offset)
# bioxels_max = float(np.max(array))
# bioxels_min = float(np.min(array))
# print("Offseted Max:", bioxels_max)
# print("Offseted Min:", bioxels_min)
# points = []
# for x in range(array.shape[0]):
# for y in range(array.shape[1]):
# for z in range(array.shape[2]):
# points.append({
# "pos": (x, y, z),
# "value": array[x, y, z],
# })
# # # Build VDB
# # grid = vdb.FloatGrid()
# # grid.copyFromArray(array.copy())
# # # After sitk.DICOMOrient(), origin and direction will also orient base on LPS
# # # so we need to convert them into RAS
# # mat_lps2ras = axis_conversion(
# # from_forward='-Z',
# # from_up='-Y',
# # to_forward='-Z',
# # to_up='Y'
# # ).to_4x4()
# # mat_location = mathutils.Matrix.Translation(
# # mathutils.Vector(image.GetOrigin())
# # )
# # mat_rotation = mathutils.Matrix(
# # np.array(image.GetDirection()).reshape((3, 3))
# # ).to_4x4()
# # mat_scale = mathutils.Matrix.Scale(
# # bioxel_size, 4
# # )
# # transfrom = mat_lps2ras @ mat_location @ mat_rotation @ mat_scale
# obj = create_points_obj(points)
# # Make transformation
# scene_scale = float(self.scene_scale)
# # (S)uperior -Z -> Y
# # (A)osterior Y -> Z
# mat_ras2blender = axis_conversion(
# from_forward='-Z',
# from_up='Y',
# to_forward='Y',
# to_up='Z'
# ).to_4x4()
# mat_scene_scale = mathutils.Matrix.Scale(
# scene_scale, 4
# )
# obj.matrix_world = mat_ras2blender @ mat_scene_scale
# self.report({"INFO"}, "Successfully Imported")
# return {'FINISHED'}
# def invoke(self, context, event):
# # print(tuple(self.orig_shape))
# self.auto = True
# self.bioxel_size = 1
# context.window_manager.invoke_props_dialog(self)
# return {'RUNNING_MODAL'}

View File

@@ -1,67 +1,197 @@
import bpy
from .operators import ConvertToMesh, JoinLayers
from .io import ExportVDB, ImportVolumeData, AddVolumeData
from .misc import SaveBioxelLayers
from .bioxelutils.container import get_container_objs_from_selection
from .operators.layer import (CombineLabels, SignScalar,
FillByLabel, FillByThreshold, FillByRange)
from .operators.container import (SaveContainer, LoadContainer,
AddPieCutter, AddPlaneCutter,
AddCylinderCutter, AddCubeCutter, AddSphereCutter,
PickBboxWire, PickMesh, PickVolume)
from .operators.io import (ExportVolumetricData,
ImportAsLabelLayer, ImportAsScalarLayer)
from .operators.misc import (CleanAllCaches,
ReLinkNodes, SaveCaches, SaveStagedData)
class View3DBioxelsMenu(bpy.types.Menu):
def container_is_selected():
container_objs = get_container_objs_from_selection()
return len(container_objs) > 0
class PickFromContainerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_PICK"
bl_label = "Pick from Container"
def draw(self, context):
layout = self.layout
layout.operator(PickMesh.bl_idname)
layout.operator(PickVolume.bl_idname)
layout.operator(PickBboxWire.bl_idname)
class ModifyLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_MODIFY_LAYERS"
bl_label = "Modify Layer"
def draw(self, context):
layout = self.layout
layout.operator(SignScalar.bl_idname)
layout.operator(FillByThreshold.bl_idname)
layout.operator(FillByRange.bl_idname)
layout.operator(FillByLabel.bl_idname)
layout.operator(CombineLabels.bl_idname)
class AddCutterMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_CUTTERS"
bl_label = "Add a Cutter to Container"
def draw(self, context):
layout = self.layout
layout.operator(AddPlaneCutter.bl_idname, text="Plane Cutter")
layout.operator(AddCylinderCutter.bl_idname, text="Cylinder Cutter")
layout.operator(AddCubeCutter.bl_idname, text="Cube Cutter")
layout.operator(AddSphereCutter.bl_idname, text="Sphere Cutter")
layout.operator(AddPieCutter.bl_idname, text="Pie Cutter")
class ImportLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_LAYERS"
bl_label = "Import Volumetric Data"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalarLayer.bl_idname, text="as Scalar")
layout.operator(ImportAsLabelLayer.bl_idname, text="as Label")
class BioxelNodesView3DMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_VIEW3D"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
layout.operator(ConvertToMesh.bl_idname)
layout.operator(AddVolumeData.bl_idname)
is_selected = container_is_selected()
layout.operator(ImportAsScalarLayer.bl_idname,
text=ImportAsScalarLayer.bl_label+" (Add to)"
if is_selected else ImportAsScalarLayer.bl_label+" (Init)")
layout.operator(ImportAsLabelLayer.bl_idname,
text=ImportAsLabelLayer.bl_label+" (Add to)"
if is_selected else ImportAsLabelLayer.bl_label+" (Init)")
layout.separator()
layout.operator(SaveBioxelLayers.bl_idname)
layout.operator(AddPlaneCutter.bl_idname)
layout.operator(AddCylinderCutter.bl_idname)
layout.operator(AddCubeCutter.bl_idname)
layout.operator(AddSphereCutter.bl_idname)
layout.operator(AddPieCutter.bl_idname)
layout.separator()
layout.operator(PickMesh.bl_idname)
layout.operator(PickVolume.bl_idname)
layout.operator(PickBboxWire.bl_idname)
layout.separator()
layout.operator(SaveCaches.bl_idname)
class OutlinerBioxelsMenu(bpy.types.Menu):
class BioxelNodesOutlinerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_OUTLINER"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
layout.operator(JoinLayers.bl_idname)
layout.operator(ConvertToMesh.bl_idname)
layout.operator(AddVolumeData.bl_idname)
is_selected = container_is_selected()
layout.operator(ImportAsScalarLayer.bl_idname,
text=ImportAsScalarLayer.bl_label+" (Add to)"
if is_selected else ImportAsScalarLayer.bl_label+" (Init)")
layout.operator(ImportAsLabelLayer.bl_idname,
text=ImportAsLabelLayer.bl_label+" (Add to)"
if is_selected else ImportAsLabelLayer.bl_label+" (Init)")
layout.separator()
layout.operator(SaveBioxelLayers.bl_idname)
layout.operator(AddPlaneCutter.bl_idname)
layout.operator(AddCylinderCutter.bl_idname)
layout.operator(AddCubeCutter.bl_idname)
layout.operator(AddSphereCutter.bl_idname)
layout.operator(AddPieCutter.bl_idname)
layout.separator()
layout.operator(PickMesh.bl_idname)
layout.operator(PickVolume.bl_idname)
layout.operator(PickBboxWire.bl_idname)
layout.separator()
layout.operator(SaveCaches.bl_idname)
layout.separator()
layout.operator(SignScalar.bl_idname)
layout.operator(FillByThreshold.bl_idname)
layout.operator(FillByRange.bl_idname)
layout.operator(FillByLabel.bl_idname)
layout.operator(CombineLabels.bl_idname)
def TOPBAR_FILE_IMPORT(self, context):
layout = self.layout
is_selected = container_is_selected()
layout.separator()
layout.operator(ImportVolumeData.bl_idname)
layout.menu(ImportLayerMenu.bl_idname, text="Volumetric Data as Bioxel (Add to)"
if is_selected else "Volumetric Data as Bioxel (Init)")
def TOPBAR_FILE_EXPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ExportVDB.bl_idname)
layout.operator(ExportVolumetricData.bl_idname,
text="Bioxel Layer (.vdb)")
def VIEW3D_OBJECT(self, context):
layout = self.layout
layout.menu(View3DBioxelsMenu.bl_idname)
layout.separator()
layout.menu(BioxelNodesView3DMenu.bl_idname, icon="FILE_VOLUME")
def OUTLINER_OBJECT(self, context):
layout = self.layout
layout.menu(OutlinerBioxelsMenu.bl_idname)
layout.separator()
layout.menu(BioxelNodesOutlinerMenu.bl_idname, icon="FILE_VOLUME")
class BioxelNodesTopbarMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_TOPBAR"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
is_selected = container_is_selected()
layout.menu(ImportLayerMenu.bl_idname, text=ImportLayerMenu.bl_label+" (Add to)"
if is_selected else ImportLayerMenu.bl_label+" (Init)")
layout.separator()
layout.operator(LoadContainer.bl_idname)
layout.operator(SaveContainer.bl_idname)
layout.separator()
layout.menu(AddCutterMenu.bl_idname)
layout.menu(PickFromContainerMenu.bl_idname)
layout.separator()
layout.operator(SaveStagedData.bl_idname)
layout.operator(ReLinkNodes.bl_idname)
layout.separator()
layout.operator(CleanAllCaches.bl_idname)
def TOPBAR(self, context):
layout = self.layout
layout.menu(BioxelNodesTopbarMenu.bl_idname)
def add():
bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT)
bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT)
bpy.types.OUTLINER_MT_object.prepend(OUTLINER_OBJECT)
bpy.types.VIEW3D_MT_object_context_menu.prepend(VIEW3D_OBJECT)
# bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT)
bpy.types.OUTLINER_MT_object.append(OUTLINER_OBJECT)
bpy.types.VIEW3D_MT_object_context_menu.append(VIEW3D_OBJECT)
bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR)
def remove():
bpy.types.TOPBAR_MT_file_import.remove(TOPBAR_FILE_IMPORT)
bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT)
# bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT)
bpy.types.OUTLINER_MT_object.remove(OUTLINER_OBJECT)
bpy.types.VIEW3D_MT_object_context_menu.remove(VIEW3D_OBJECT)
bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR)

View File

@@ -1,92 +0,0 @@
import bpy
from pathlib import Path
import shutil
from bioxelnodes.utils import get_all_layers, get_container, get_container_layers
def save_layer(layer, output_dir):
name = layer.name
# "//"
output_dir = bpy.path.abspath(output_dir)
source_dir = bpy.path.abspath(layer.data.filepath)
output_path: Path = Path(output_dir, f"{name}.vdb").resolve()
source_path: Path = Path(source_dir).resolve()
if output_path != source_path:
shutil.copy(source_path, output_path)
blend_path = Path(bpy.path.abspath("//")).resolve()
layer.data.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
class SaveBioxelLayers(bpy.types.Operator):
bl_idname = "bioxelnodes.save_bioxel_layers"
bl_label = "Save Bioxel Layers"
bl_description = "Save Bioxel Layers to Directory."
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
container = get_container(bpy.context.active_object)
return True if container else False
def execute(self, context):
container = get_container(bpy.context.active_object)
if not container:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
for layer in get_container_layers(container):
try:
save_layer(layer, context.scene.bioxel_layer_dir)
except:
self.report(
{"WARNING"}, f"Fail to save {layer.name}, skiped")
self.report({"INFO"}, f"Successfully saved bioxel layers.")
return {'FINISHED'}
class SaveAllBioxelLayers(bpy.types.Operator):
bl_idname = "bioxelnodes.save_all_bioxel_layers"
bl_label = "Save All Bioxel Layers"
bl_description = "Save All Bioxel Layers to Directory."
bl_options = {'UNDO'}
def execute(self, context):
layers = get_all_layers()
if len(layers) == 0:
self.report({"WARNING"}, "Cannot find any bioxel layer.")
return {'FINISHED'}
for layer in layers:
try:
save_layer(layer, context.scene.bioxel_layer_dir)
except:
self.report(
{"WARNING"}, f"Fail to save {layer.name}, skiped")
self.report({"INFO"}, f"Successfully saved bioxel layers.")
return {'FINISHED'}
class BIOXELNODES_PT_Bioxels(bpy.types.Panel):
bl_idname = "BIOXELNODES_PT_Bioxels"
bl_label = "Bioxel Nodes"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, 'bioxel_layer_dir')
layout.operator(SaveAllBioxelLayers.bl_idname)

View File

@@ -1,306 +1,220 @@
from pathlib import Path
from .customnodes import CustomNodes
import bpy
NODE_FILE = "BioxelNodes_4.2"
def add_driver_to_node_factory(source_prop, target_prop):
callback_str = f"""
import bpy
from bioxelnodes.utils import add_direct_driver, get_bioxels_obj
bioxels_obj = get_bioxels_obj(bpy.context.active_object)
if bioxels_obj:
container_obj = bioxels_obj.parent
add_direct_driver(
target=node,
target_prop='{target_prop}',
source=container_obj,
source_prop='{source_prop}'
)
else:
print('Cannot find any bioxels.')
"""
return callback_str
def set_prop_to_node_factory(source_prop, target_prop):
callback_str = f"""
import bpy
from bioxelnodes.utils import get_bioxels_obj
bioxels_obj = get_bioxels_obj(bpy.context.active_object)
if bioxels_obj:
container_obj = bioxels_obj.parent
node.inputs.get('{target_prop}').default_value = container_obj.get(
'{source_prop}')
else:
print('Cannot find any bioxels.')
"""
return callback_str
if bpy.app.version >= (4, 1, 0):
NODE_FILE = "BioxelNodes_4.1"
MENU_ITEMS = [
{
'label': 'Methods',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Mask by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'node_type': 'BioxelNodes_MaskByThreshold',
'node_description': ''
},
{
'label': 'Mask by Range',
'icon': 'IPO_CONSTANT',
'node_type': 'BioxelNodes_MaskByRange',
'node_description': ''
},
{
'label': 'Mask by Label',
'icon': 'MESH_CAPSULE',
'node_type': 'BioxelNodes_MaskByLabel',
'node_description': ''
}
]
},
{
'label': 'Shaders',
'icon': 'SHADING_RENDERED',
'items': [
{
'label': 'Solid Shader',
'icon': 'SHADING_SOLID',
'node_type': 'BioxelNodes_AssignSolidShader',
'node_description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_AssignSlimeShader',
'node_description': ''
},
{
'label': 'Volume Shader',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_AssignVolumeShader',
'node_description': ''
},
{
'label': 'Universal Shader',
'icon': 'SHADING_RENDERED',
'node_type': 'BioxelNodes_AssignUniversalShader',
'node_description': ''
}
]
},
{
'label': 'Colors',
'icon': 'COLOR',
'items': [
{
'label': 'Color Presets',
'icon': 'COLOR',
'node_type': 'BioxelNodes_SetColorPresets',
'node_description': ''
},
{
'label': 'Color Ramp 2',
'icon': 'IPO_QUAD',
'node_type': 'BioxelNodes_SetColorRamp2',
'node_description': ''
},
{
'label': 'Color Ramp 3',
'icon': 'IPO_CUBIC',
'node_type': 'BioxelNodes_SetColorRamp3',
'node_description': ''
},
{
'label': 'Color Ramp 4',
'icon': 'IPO_QUART',
'node_type': 'BioxelNodes_SetColorRamp4',
'node_description': ''
},
{
'label': 'Color Ramp 5',
'icon': 'IPO_QUINT',
'node_type': 'BioxelNodes_SetColorRamp5',
'node_description': ''
}
]
},
{
'label': 'Cutters',
'icon': 'MOD_BEVEL',
'items': [
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'node_type': 'BioxelNodes_Cut',
'node_description': ''
},
"separator",
{
'label': 'Plane Cutter',
'icon': 'MOD_LATTICE',
'node_type': 'BioxelNodes_PlaneCutter',
'node_description': '',
},
{
'label': 'Plane Object Cutter',
'icon': 'OUTLINER_OB_LATTICE',
'node_type': 'BioxelNodes_PlaneObjectCutter',
'node_description': '',
}
]
},
{
'label': 'Utils',
'icon': 'MODIFIER',
'items': [
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'node_type': 'BioxelNodes_JoinComponent',
'node_description': ''
}
]
}
]
else:
NODE_FILE = "BioxelNodes_4.0"
MENU_ITEMS = [
{
'label': 'Methods',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Mask by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'node_type': 'BioxelNodes_MaskByThreshold',
'node_description': ''
},
{
'label': 'Mask by Range',
'icon': 'IPO_CONSTANT',
'node_type': 'BioxelNodes_MaskByRange',
'node_description': ''
},
{
'label': 'Mask by Label',
'icon': 'MESH_CAPSULE',
'node_type': 'BioxelNodes_MaskByLabel',
'node_description': ''
}
]
},
{
'label': 'Shaders',
'icon': 'SHADING_RENDERED',
'items': [
{
'label': 'Solid Shader',
'icon': 'SHADING_SOLID',
'node_type': 'BioxelNodes_AssignSolidShader',
'node_description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_AssignSlimeShader',
'node_description': ''
},
{
'label': 'Volume Shader',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_AssignVolumeShader',
'node_description': ''
},
{
'label': 'Universal Shader',
'icon': 'SHADING_RENDERED',
'node_type': 'BioxelNodes_AssignUniversalShader',
'node_description': ''
}
]
},
{
'label': 'Colors',
'icon': 'COLOR',
'items': [
{
'label': 'Color Ramp 2',
'icon': 'IPO_QUAD',
'node_type': 'BioxelNodes_SetColorRamp2',
'node_description': ''
},
{
'label': 'Color Ramp 3',
'icon': 'IPO_CUBIC',
'node_type': 'BioxelNodes_SetColorRamp3',
'node_description': ''
},
{
'label': 'Color Ramp 4',
'icon': 'IPO_QUART',
'node_type': 'BioxelNodes_SetColorRamp4',
'node_description': ''
},
{
'label': 'Color Ramp 5',
'icon': 'IPO_QUINT',
'node_type': 'BioxelNodes_SetColorRamp5',
'node_description': ''
}
]
},
{
'label': 'Cutters',
'icon': 'MOD_BEVEL',
'items': [
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'node_type': 'BioxelNodes_Cut',
'node_description': ''
},
"separator",
{
'label': 'Plane Cutter',
'icon': 'MOD_LATTICE',
'node_type': 'BioxelNodes_PlaneCutter',
'node_description': '',
},
{
'label': 'Plane Object Cutter',
'icon': 'OUTLINER_OB_LATTICE',
'node_type': 'BioxelNodes_PlaneObjectCutter',
'node_description': '',
}
]
},
{
'label': 'Utils',
'icon': 'MODIFIER',
'items': [
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'node_type': 'BioxelNodes_JoinComponent',
'node_description': ''
}
]
}
]
MENU_ITEMS = [
{
'label': 'Methods',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Mask by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'node_type': 'BioxelNodes_MaskByThreshold',
'node_description': ''
},
{
'label': 'Mask by Range',
'icon': 'IPO_CONSTANT',
'node_type': 'BioxelNodes_MaskByRange',
'node_description': ''
},
{
'label': 'Mask by Label',
'icon': 'MESH_CAPSULE',
'node_type': 'BioxelNodes_MaskByLabel',
'node_description': ''
}
]
},
{
'label': 'Shaders',
'icon': 'SHADING_RENDERED',
'items': [
{
'label': 'Membrane Shader',
'icon': 'NODE_MATERIAL',
'node_type': 'BioxelNodes_AssignMembraneShader',
'node_description': ''
},
{
'label': 'Solid Shader',
'icon': 'SHADING_SOLID',
'node_type': 'BioxelNodes_AssignSolidShader',
'node_description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_DATA_META',
'node_type': 'BioxelNodes_AssignSlimeShader',
'node_description': ''
},
{
'label': 'Volume Shader',
'icon': 'VOLUME_DATA',
'node_type': 'BioxelNodes_AssignVolumeShader',
'node_description': ''
},
{
'label': 'Universal Shader',
'icon': 'MATSHADERBALL',
'node_type': 'BioxelNodes_AssignUniversalShader',
'node_description': ''
}
]
},
{
'label': 'Colors',
'icon': 'COLOR',
'items': [
{
'label': 'Color Presets',
'icon': 'COLOR',
'node_type': 'BioxelNodes_ColorPresets',
'node_description': ''
},
{
'label': 'Color Presets MRI',
'icon': 'COLOR',
'node_type': 'BioxelNodes_ColorPresets_MRI',
'node_description': ''
},
"separator",
{
'label': 'Color Ramp 2',
'icon': 'IPO_QUAD',
'node_type': 'BioxelNodes_SetColorRamp2',
'node_description': ''
},
{
'label': 'Color Ramp 3',
'icon': 'IPO_CUBIC',
'node_type': 'BioxelNodes_SetColorRamp3',
'node_description': ''
},
{
'label': 'Color Ramp 4',
'icon': 'IPO_QUART',
'node_type': 'BioxelNodes_SetColorRamp4',
'node_description': ''
},
{
'label': 'Color Ramp 5',
'icon': 'IPO_QUINT',
'node_type': 'BioxelNodes_SetColorRamp5',
'node_description': ''
}
]
},
{
'label': 'Cutters',
'icon': 'MOD_BEVEL',
'items': [
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'node_type': 'BioxelNodes_Cut',
'node_description': ''
},
"separator",
{
'label': 'Primitive Cutter',
'icon': 'MOD_LINEART',
'node_type': 'BioxelNodes_PrimitiveCutter',
'node_description': ''
},
"separator",
{
'label': 'Plane Cutter',
'icon': 'MESH_PLANE',
'node_type': 'BioxelNodes_PlaneObjectCutter',
'node_description': ''
},
{
'label': 'Cylinder Cutter',
'icon': 'MESH_CYLINDER',
'node_type': 'BioxelNodes_CylinderObjectCutter',
'node_description': '',
},
{
'label': 'Cube Cutter',
'icon': 'MESH_CUBE',
'node_type': 'BioxelNodes_CubeObjectCutter',
'node_description': '',
},
{
'label': 'Sphere Cutter',
'icon': 'MESH_UVSPHERE',
'node_type': 'BioxelNodes_SphereObjectCutter',
'node_description': '',
},
{
'label': 'Pie Cutter',
'icon': 'MESH_CONE',
'node_type': 'BioxelNodes_PieObjectCutter',
'node_description': '',
}
]
},
{
'label': 'Utils',
'icon': 'MODIFIER',
'items': [
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'node_type': 'BioxelNodes_JoinComponent',
'node_description': ''
},
"separator",
{
'label': 'Pick Mesh',
'icon': 'OUTLINER_OB_MESH',
'node_type': 'BioxelNodes_PickMesh',
'node_description': ''
},
{
'label': 'Pick Volume',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_PickVolume',
'node_description': ''
},
{
'label': 'Pick Bbox Wire',
'icon': 'MESH_CUBE',
'node_type': 'BioxelNodes_PickBboxWire',
'node_description': ''
},
"separator",
{
'label': 'Inflate',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_M_Inflate',
'node_description': ''
},
{
'label': 'Smooth',
'icon': 'MOD_SMOOTH',
'node_type': 'BioxelNodes_M_Smooth',
'node_description': ''
},
{
'label': 'Remove Small Island',
'icon': 'FORCE_LENNARDJONES',
'node_type': 'BioxelNodes_M_RemoveSmallIsland',
'node_description': ''
}
]
}
]
custom_nodes = CustomNodes(
menu_items=MENU_ITEMS,
nodes_file=Path(__file__).parent / f"assets/Nodes/{NODE_FILE}.blend",
class_prefix="BIOXELNODES_MT_NODES",
root_label='Bioxel Nodes',
root_icon="FILE_VOLUME",
)

View File

@@ -1,226 +0,0 @@
from pathlib import Path
import bpy
import pyopenvdb as vdb
from uuid import uuid4
from .nodes import custom_nodes
from .utils import (get_container, get_container_layers,
get_layer, get_node_by_type, hide_in_ray, lock_transform)
def get_scalar_layer_selection(self, context):
items = [("None", "None", "")]
base_layer = bpy.data.objects[self.base_layer]
container = base_layer.parent
for layer in get_container_layers(container):
if layer.get("bioxel_layer_type") == "scalar":
items.append((
layer.name,
layer.name,
""
))
return items
def get_label_layer_selection(self, context):
items = [("None", "None", "")]
base_layer = bpy.data.objects[self.base_layer]
container = base_layer.parent
for layer in get_container_layers(container):
if layer.get("bioxel_layer_type") == "label":
items.append((
layer.name,
layer.name,
""
))
return items
class JoinLayers(bpy.types.Operator):
bl_idname = "bioxelnodes.join_layers"
bl_label = "Join Additinal Layers"
bl_description = "Join Layers"
bl_options = {'UNDO'}
base_layer: bpy.props.StringProperty(
options={"HIDDEN"}
) # type: ignore
scalar_layer: bpy.props.EnumProperty(
name="Scaler Layer",
items=get_scalar_layer_selection
) # type: ignore
label_layer: bpy.props.EnumProperty(
name="Label Layer",
items=get_label_layer_selection
) # type: ignore
# color_layer: bpy.props.StringProperty() # type: ignore
@classmethod
def poll(cls, context):
layer = get_layer(bpy.context.active_object)
return True if layer else False
def execute(self, context):
base_layer = bpy.data.objects[self.base_layer]
if not base_layer:
self.report({"WARNING"}, "Cannot find any bioxel layer as base.")
return {'FINISHED'}
base_layer_dir = bpy.path.abspath(base_layer.data.filepath)
base_grids, base_metadata = vdb.readAll(base_layer_dir)
preferences = context.preferences.addons[__package__].preferences
cache_dir = Path(preferences.cache_dir, 'VDBs')
cache_dir.mkdir(parents=True, exist_ok=True)
layers = []
if self.scalar_layer != "None":
scalar_layer = bpy.data.objects[self.scalar_layer]
layers.append(scalar_layer)
if self.label_layer != "None":
label_layer = bpy.data.objects[self.label_layer]
layers.append(label_layer)
# TODO: add color and vector
if len(layers) == 0:
self.report({"WARNING"}, "No additinal layers setted.")
return {'FINISHED'}
for layer in layers:
layer_dir = bpy.path.abspath(layer.data.filepath)
grids, metadata = vdb.readAll(layer_dir)
base_grids.extend(grids)
vdb_path = Path(cache_dir, f"{uuid4()}.vdb")
print(f"Storing the VDB file ({str(vdb_path)})...")
vdb.write(str(vdb_path), grids=base_grids)
# Read VDB
print(f"Loading the cache to Blender scene...")
bpy.ops.object.volume_import(
filepath=str(vdb_path), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
joined_layer = bpy.context.active_object
base_layer_node = base_layer.modifiers[0].node_group.nodes['BioxelNodes__ConvertToLayer']
bioxel_size = base_layer_node.inputs['Bioxel Size'].default_value
layer_shape = base_layer_node.inputs['Shape'].default_value
layer_origin = base_layer_node.inputs['Origin'].default_value
layer_rotation = base_layer_node.inputs['Rotation'].default_value
scalar_offset = base_layer_node.inputs['Scalar Offset'].default_value
scalar_min = base_layer_node.inputs['Scalar Min'].default_value
scalar_max = base_layer_node.inputs['Scalar Max'].default_value
if self.scalar_layer != "None":
scalar_layer_node = scalar_layer.modifiers[0].node_group.nodes['BioxelNodes__ConvertToLayer']
scalar_offset = scalar_layer_node.inputs['Scalar Offset'].default_value
scalar_min = scalar_layer_node.inputs['Scalar Min'].default_value
scalar_max = scalar_layer_node.inputs['Scalar Max'].default_value
bpy.ops.node.new_geometry_nodes_modifier()
node_tree = joined_layer.modifiers[0].node_group
nodes = node_tree.nodes
links = node_tree.links
input_node = get_node_by_type(nodes, 'NodeGroupInput')[0]
output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0]
joined_layer_node = custom_nodes.add_node(
nodes, "BioxelNodes__ConvertToLayer")
links.new(input_node.outputs[0], joined_layer_node.inputs[0])
links.new(joined_layer_node.outputs[0], output_node.inputs[0])
joined_layer_node.inputs['Bioxel Size'].default_value = bioxel_size
joined_layer_node.inputs['Shape'].default_value = layer_shape
joined_layer_node.inputs['Origin'].default_value = layer_origin
joined_layer_node.inputs['Rotation'].default_value = layer_rotation
joined_layer_node.inputs['Scalar Offset'].default_value = scalar_offset
joined_layer_node.inputs['Scalar Min'].default_value = scalar_min
joined_layer_node.inputs['Scalar Max'].default_value = scalar_max
# Set props to VDB object
joined_layer.name = f"{base_layer.name}_Joined"
joined_layer.data.name = f"{base_layer.name}_Joined"
lock_transform(joined_layer)
hide_in_ray(joined_layer)
joined_layer.hide_select = True
joined_layer.hide_render = True
joined_layer.hide_viewport = True
joined_layer.data.display.use_slice = True
joined_layer.data.display.density = 1e-05
joined_layer['bioxel_layer'] = True
joined_layer.parent = base_layer.parent
return {'FINISHED'}
def invoke(self, context, event):
base_layer = get_layer(bpy.context.active_object)
self.base_layer = base_layer.name
context.window_manager.invoke_props_dialog(self, width=400)
return {'RUNNING_MODAL'}
class ConvertToMesh(bpy.types.Operator):
bl_idname = "bioxelnodes.convert_to_mesh"
bl_label = "Bioxel Components To Mesh"
bl_description = "Convert Bioxel Components To Mesh"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
container = get_container(bpy.context.active_object)
return True if container else False
def execute(self, context):
container = get_container(bpy.context.active_object)
if not container:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
mesh = bpy.context.active_object
mesh.name = f"Mesh_{container.name}"
bpy.ops.object.constraint_add(type='COPY_TRANSFORMS')
mesh.constraints[0].target = container
bpy.ops.node.new_geometry_nodes_modifier()
modifier = mesh.modifiers[0]
nodes = modifier.node_group.nodes
links = modifier.node_group.links
output_node = get_node_by_type(nodes, 'NodeGroupOutput')[0]
object_node = nodes.new("GeometryNodeObjectInfo")
realize_nodes = nodes.new("GeometryNodeRealizeInstances")
separate_node = custom_nodes.add_node(
nodes, "BioxelNodes__SeparateComponent")
object_node.inputs[0].default_value = container
separate_node.inputs[1].default_value = 1
links.new(object_node.outputs['Geometry'], separate_node.inputs[0])
links.new(separate_node.outputs[0], realize_nodes.inputs[0])
links.new(realize_nodes.outputs[0], output_node.inputs[0])
bpy.ops.object.convert(target='MESH')
bpy.ops.constraint.apply(constraint="Copy Transforms", owner='OBJECT')
bpy.context.object.active_material_index = 1
bpy.ops.object.material_slot_remove()
self.report({"INFO"}, f"Successfully convert to mesh")
return {'FINISHED'}

View File

View File

@@ -0,0 +1,290 @@
import bpy
import bmesh
from ..nodes import custom_nodes
from ..bioxel.io import load_container, save_container
from ..bioxelutils.layer import get_all_layer_objs
from ..bioxelutils.container import (container_to_obj, obj_to_container,
get_container_objs_from_selection)
from ..bioxelutils.node import get_nodes_by_type, move_node_between_nodes, move_node_to_node
from .utils import change_render_setting, get_cache_dir, get_preferences, select_object
class SaveContainer(bpy.types.Operator):
bl_idname = "bioxelnodes.save_container"
bl_label = "Save Container (.bioxel) (BETA)"
bl_description = "Clean all caches saved in temp"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".bioxel"
@classmethod
def poll(cls, context):
container_objs = get_container_objs_from_selection()
return len(container_objs) > 0
def execute(self, context):
container_objs = get_container_objs_from_selection()
if len(container_objs) == 0:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container = obj_to_container(container_objs[0])
save_path = f"{self.filepath.split('.')[0]}.bioxel"
save_container(container, save_path, overwrite=True)
self.report({"INFO"}, f"Successfully save to {save_path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class LoadContainer(bpy.types.Operator):
bl_idname = "bioxelnodes.load_container"
bl_label = "Load Container (.bioxel) (BETA)"
bl_description = "Clean all caches saved in temp"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".bioxel"
def execute(self, context):
load_path = self.filepath
container = load_container(self.filepath)
is_first_import = len(get_all_layer_objs()) == 0
container_obj = container_to_obj(container,
scene_scale=0.01,
cache_dir=get_cache_dir(context))
select_object(container_obj)
if is_first_import:
change_render_setting(context)
self.report({"INFO"}, f"Successfully load {load_path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class PickObject():
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
container_objs = get_container_objs_from_selection()
return len(container_objs) > 0
def execute(self, context):
container_objs = get_container_objs_from_selection()
if len(container_objs) == 0:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container_obj = container_objs[0]
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.active_object
obj.name = f"{self.object_type}_{container_obj.name}"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = obj.modifiers[0]
node_group = modifier.node_group
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
pick_mesh_node = custom_nodes.add_node(node_group,
f"BioxelNodes_Pick{self.object_type}")
pick_mesh_node.inputs[0].default_value = container_obj
node_group.links.new(pick_mesh_node.outputs[0], output_node.inputs[0])
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class PickMesh(bpy.types.Operator, PickObject):
bl_idname = "bioxelnodes.pick_mesh"
bl_label = "Pick Mesh"
bl_description = "Pick Container Mesh"
object_type = "Mesh"
class PickVolume(bpy.types.Operator, PickObject):
bl_idname = "bioxelnodes.pick_volume"
bl_label = "Pick Volume"
bl_description = "Pick Container Volume"
object_type = "Volume"
class PickBboxWire(bpy.types.Operator, PickObject):
bl_idname = "bioxelnodes.pick_bbox_wire"
bl_label = "Pick Bbox Wire"
bl_description = "Pick Container Bbox Wire"
object_type = "BboxWire"
class AddCutter():
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
container_objs = get_container_objs_from_selection()
return len(container_objs) > 0
def execute(self, context):
container_objs = get_container_objs_from_selection()
if len(container_objs) == 0:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container_obj = container_objs[0]
if self.object_type == "plane":
node_type = "BioxelNodes_PlaneObjectCutter"
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.object_type == "cylinder":
node_type = "BioxelNodes_CylinderObjectCutter"
bpy.ops.mesh.primitive_cylinder_add(
radius=1, depth=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
bpy.context.object.rotation_euler[0] = container_obj.rotation_euler[0]
elif self.object_type == "cube":
node_type = "BioxelNodes_CubeObjectCutter"
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.object_type == "sphere":
node_type = "BioxelNodes_SphereObjectCutter"
bpy.ops.mesh.primitive_ico_sphere_add(
radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.object_type == "pie":
node_type = "BioxelNodes_PieObjectCutter"
# Create mesh
pie_mesh = bpy.data.meshes.new('Pie')
# Create object
pie = bpy.data.objects.new('Pie', pie_mesh)
# Link object to scene
bpy.context.scene.collection.objects.link(pie)
# Get a BMesh representation
bm = bmesh.new() # create an empty BMesh
bm.from_mesh(pie_mesh) # fill it in from a Mesh
# Hot to create vertices
v_0 = bm.verts.new((0.0, -1.0, 0.0))
v_1 = bm.verts.new((-1.0, -1.0, 1.0))
v_2 = bm.verts.new((0.0, 1.0, 0.0))
v_3 = bm.verts.new((-1.0, 1.0, 1.0))
v_4 = bm.verts.new((1.0, -1.0, 1.0))
v_5 = bm.verts.new((1.0, 1.0, 1.0))
# Initialize the index values of this sequence.
bm.verts.index_update()
# How to create a face
# it's not necessary to create the edges before, I made it only to show how create
# edges too
bm.faces.new((v_0, v_1, v_3, v_2))
bm.faces.new((v_0, v_2, v_5, v_4))
# Finish up, write the bmesh back to the mesh
bm.to_mesh(pie_mesh)
bpy.context.view_layer.objects.active = pie
cutter_obj = bpy.context.active_object
cutter_obj.visible_camera = False
cutter_obj.visible_diffuse = False
cutter_obj.visible_glossy = False
cutter_obj.visible_transmission = False
cutter_obj.visible_volume_scatter = False
cutter_obj.visible_shadow = False
cutter_obj.hide_render = True
cutter_obj.display_type = 'WIRE'
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
cutter_node = custom_nodes.add_node(node_group, node_type)
cutter_node.inputs[0].default_value = cutter_obj
cut_nodes = get_nodes_by_type(node_group,
'BioxelNodes_Cut')
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
if len(cut_nodes) == 0:
cut_node = custom_nodes.add_node(node_group, 'BioxelNodes_Cut')
if len(output_node.inputs[0].links) == 0:
node_group.links.new(cut_node.outputs[0],
output_node.inputs[0])
move_node_to_node(cut_node, output_node, (-300, 0))
else:
pre_output_node = output_node.inputs[0].links[0].from_node
node_group.links.new(pre_output_node.outputs[0],
cut_node.inputs[0])
node_group.links.new(cut_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(cut_node,
[pre_output_node, output_node])
node_group.links.new(cutter_node.outputs[0],
cut_node.inputs[1])
move_node_to_node(cutter_node, cut_node, (-300, -300))
select_object(cutter_obj)
else:
move_node_to_node(cutter_node, output_node, (0, -100))
select_object(container_obj)
return {'FINISHED'}
class AddPlaneCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_plane_cutter"
bl_label = "Add a Plane Cutter"
bl_description = "Add a Plane Cutter to Container"
object_type = "plane"
class AddCylinderCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cylinder_cutter"
bl_label = "Add a Cylinder Cutter"
bl_description = "Add a Cylinder Cutter to Container"
object_type = "cylinder"
class AddCubeCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cube_cutter"
bl_label = "Add a Cube Cutter"
bl_description = "Add a Cube Cutter to Container"
object_type = "cube"
class AddSphereCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_sphere_cutter"
bl_label = "Add a Sphere Cutter"
bl_description = "Add a Sphere Cutter to Container"
object_type = "sphere"
class AddPieCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_pie_cutter"
bl_label = "Add a Pie Cutter"
bl_description = "Add a Pie Cutter to Container"
object_type = "pie"

793
bioxelnodes/operators/io.py Normal file
View File

@@ -0,0 +1,793 @@
import math
import bpy
import shutil
import threading
import numpy as np
from pathlib import Path
from ..exceptions import CancelledByUser
from ..props import BIOXELNODES_Series
from ..bioxel.layer import Layer
from ..bioxelutils.layer import (get_all_layer_objs,
get_layer_obj)
from ..bioxelutils.container import (Container,
add_layers,
container_to_obj,
get_container_objs_from_selection)
from ..bioxel.parse import (DICOM_EXTS, SUPPORT_EXTS,
get_ext, parse_volumetric_data)
from .utils import (change_render_setting, get_cache_dir, get_preferences,
progress_update, progress_bar, select_object)
# 3rd-party
import SimpleITK as sitk
import transforms3d
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 get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0):
size = (float(shape[0] * bioxel_size * scale),
float(shape[1] * bioxel_size * scale),
float(shape[2] * bioxel_size * scale))
return size
"""
ImportVolumetricData
-> ParseVolumetricData -> ImportVolumetricDataDialog
FH_ImportVolumetricData
start import parse data execute import
"""
class ImportVolumetricData():
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
read_as = "scalar"
def execute(self, context):
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported format.")
return {'CANCELLED'}
bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT',
filepath=self.filepath,
skip_read_as=True,
read_as=self.read_as)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class ImportAsScalarLayer(bpy.types.Operator, ImportVolumetricData):
bl_idname = "bioxelnodes.import_as_scalar_layer"
bl_label = "Import as Scalar"
bl_description = "Import Volumetric Data to Container as Scalar"
read_as = "scalar"
class ImportAsLabelLayer(bpy.types.Operator, ImportVolumetricData):
bl_idname = "bioxelnodes.import_as_label_layer"
bl_label = "Import as Label"
bl_description = "Import Volumetric Data to Container as Label"
read_as = "label"
class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportVolumetricData"
bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.parse_volumetric_data"
bl_file_extensions = ";".join(SUPPORT_EXTS)
@classmethod
def poll_drop(cls, context):
return (context.area and context.area.type == 'VIEW_3D')
def get_series_ids(self, context):
items = []
for index, series_id in enumerate(self.series_ids):
items.append((
series_id.id,
series_id.label,
"",
index
))
return items
class ParseVolumetricData(bpy.types.Operator):
bl_idname = "bioxelnodes.parse_volumetric_data"
bl_label = "Import Volumetric Data (BioxelNodes)"
bl_description = "Import Volumetric Data as Layer"
bl_options = {'UNDO'}
meta = None
thread = None
_timer = None
container_obj_name = ""
progress: bpy.props.FloatProperty(name="Progress",
options={"SKIP_SAVE"},
default=1) # type: ignore
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
skip_read_as: bpy.props.BoolProperty(name="Skip Read As",
default=False) # type: ignore
skip_series_select: bpy.props.BoolProperty(name="Skip Sries Select",
default=True) # type: ignore
read_as: bpy.props.EnumProperty(name="Read as",
default="scalar",
items=[("scalar", "Scalar", ""),
("label", "Labels", "")]) # type: ignore
series_id: bpy.props.EnumProperty(name="Select Series",
items=get_series_ids) # type: ignore
series_ids: bpy.props.CollectionProperty(
type=BIOXELNODES_Series) # type: ignore
def execute(self, context):
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported format.")
return {'CANCELLED'}
print("Collecting Meta Data...")
def parse_volumetric_data_func(self, context, cancel):
def progress_callback(factor, text):
if cancel():
raise CancelledByUser
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 CancelledByUser:
return
except Exception as e:
self.has_error = e
return
if cancel():
return
self.meta = meta
# Init cancel flag
self.is_cancelled = False
self.has_error = None
# Create the thread
self.thread = threading.Thread(target=parse_volumetric_data_func,
args=(self, context, lambda: self.is_cancelled))
# 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, "Canceling...")
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)
# Check if thread is still running
if self.thread.is_alive():
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)
# Check if thread is cancelled by user
if self.is_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.")
return {'CANCELLED'}
# If not canncelled...
for key, value in self.meta.items():
print(f"{key}: {value}")
orig_shape = self.meta['xyz_shape']
orig_spacing = self.meta['spacing']
min_size = min(orig_spacing[0],
orig_spacing[1], orig_spacing[2])
bioxel_size = max(min_size, 1.0)
layer_shape = get_layer_shape(1, orig_shape, orig_spacing)
layer_size = get_layer_size(layer_shape,
bioxel_size)
log10 = math.floor(math.log10(max(*layer_size)))
log10 = max(1, log10)
log10 = min(3, log10)
scene_scale = math.pow(10, -log10)
if self.container_obj_name:
container_obj = bpy.data.objects.get(self.container_obj_name)
container_name = container_obj.name
else:
container_name = self.meta['name']
series_id = self.series_id if self.series_id != "empty" else ""
bpy.ops.bioxelnodes.import_volumetric_data_dialog(
'INVOKE_DEFAULT',
filepath=self.filepath,
container_name=container_name,
layer_name=self.meta['description'],
orig_shape=orig_shape,
orig_spacing=orig_spacing,
bioxel_size=bioxel_size,
series_id=series_id,
frame_count=self.meta['frame_count'],
channel_count=self.meta['channel_count'],
container_obj_name=self.container_obj_name,
read_as=self.read_as,
scene_scale=scene_scale
)
self.report({"INFO"}, "Successfully Readed.")
return {'FINISHED'}
def invoke(self, context, event):
if not self.filepath:
return {'CANCELLED'}
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
container_objs = get_container_objs_from_selection()
if len(container_objs) > 0:
self.container_obj_name = container_objs[0].name
title = f"Add to **{self.container_obj_name}**" \
if self.container_obj_name != "" else f"Init a Container"
# Series Selection
if ext in DICOM_EXTS:
data_dirpath = data_path.parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_ids = reader.GetGDCMSeriesIDs(str(data_dirpath))
series_items = {}
for series_id in series_ids:
series_files = reader.GetGDCMSeriesFileNames(
str(data_dirpath), series_id)
single = sitk.ImageFileReader()
single.SetFileName(series_files[0])
single.LoadPrivateTagsOn()
single.ReadImageInformation()
def get_meta(key):
try:
stirng = single.GetMetaData(key).removesuffix(" ")
if stirng in ["No study description",
"No series description",
""]:
return "Unknown"
else:
return stirng
except:
return "Unknown"
study_description = get_meta("0008|1030")
series_description = get_meta("0008|103e")
series_modality = get_meta("0008|0060")
size_x = get_meta("0028|0011")
size_y = get_meta("0028|0010")
count = get_meta("0020|0013")
count = count if count != "Unknown" else 1
# some series image count = 0 ????
if int(count) == 0:
continue
# series_id cannot be "" in blender selection
if series_id == "":
series_id = "empty"
series_items[series_id] = "{:<20} {:>1}".format(f"{study_description}>{series_description}({series_modality})",
f"({size_x}x{size_y})x{count}")
for key, value in series_items.items():
series_item = self.series_ids.add()
series_item.id = key
series_item.label = value
if len(series_items.keys()) > 1:
self.skip_series_select = False
context.window_manager.invoke_props_dialog(self,
width=400,
title=title)
return {'RUNNING_MODAL'}
elif len(series_items.keys()) == 1:
self.series_id = list(series_items.keys())[0]
else:
self.report({"ERROR"}, "Get no vaild series.")
return {'CANCELLED'}
if self.skip_read_as:
self.execute(context)
return {'RUNNING_MODAL'}
else:
context.window_manager.invoke_props_dialog(self,
width=400,
title=title)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
if not self.skip_read_as:
layout.label(
text='What kind is the data read as?')
layout.prop(self, "read_as")
if not self.skip_series_select:
layout.label(text='Which series to import?')
layout.prop(self, "series_id")
def get_sequence_sources(self, context):
items = [("-1", "None (1 frame)", "")]
orig_shape = tuple(self.orig_shape)
if self.frame_count > 1:
items.append(("0", f"Frame ({self.frame_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count > 1:
items.append(("4", f"Channel ({self.channel_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count == 1:
items.append(("1", f"X ({orig_shape[0]} frames)", ""))
items.append(("2", f"Y ({orig_shape[1]} frames)", ""))
items.append(("3", f"Z ({orig_shape[2]} frames)", ""))
return items
class ImportVolumetricDataDialog(bpy.types.Operator):
bl_idname = "bioxelnodes.import_volumetric_data_dialog"
bl_label = "Import Volumetric Data"
bl_description = "Import Volumetric Data as Layer"
bl_options = {'UNDO'}
layers = None
thread = None
_timer = None
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
container_name: bpy.props.StringProperty(
name="Container Name") # type: ignore
layer_name: bpy.props.StringProperty(name="Layer Name") # type: ignore
series_id: bpy.props.StringProperty() # type: ignore
container_obj_name: bpy.props.StringProperty() # type: ignore
frame_count: bpy.props.IntProperty() # type: ignore
channel_count: bpy.props.IntProperty() # type: ignore
read_as: bpy.props.EnumProperty(name="Read as",
default="scalar",
items=[("scalar", "Scalar", ""),
("label", "Labels", "")]) # type: ignore
bioxel_size: bpy.props.FloatProperty(name="Bioxel Size (Larger size means small resolution)",
soft_min=0.1, soft_max=10.0,
min=0.1, max=1e2,
default=1) # type: ignore
orig_spacing: bpy.props.FloatVectorProperty(name="Original Spacing",
default=(1, 1, 1)) # type: ignore
orig_shape: bpy.props.IntVectorProperty(name="Original Shape",
default=(100, 100, 100)) # type: ignore
scene_scale: bpy.props.FloatProperty(name="Scene Scale (Bioxel Unit pre Blender Unit)",
soft_min=0.0001, soft_max=10.0,
min=1e-6, max=1e6,
default=0.01) # type: ignore
split_channels: bpy.props.BoolProperty(name="Split Channels",
default=False) # type: ignore
sequence_source: bpy.props.EnumProperty(name="Time Sequence From",
items=get_sequence_sources) # 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 CancelledByUser
progress_update(context, factor*0.2, text)
try:
data, meta = parse_volumetric_data(data_file=self.filepath,
series_id=self.series_id,
progress_callback=progress_callback)
except CancelledByUser:
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
if cancel():
return
# change shape as sequence or not
if self.sequence_source == "-1":
data = data[0:1, :, :, :, :]
elif self.sequence_source == "0":
# frame as frame
pass
elif self.sequence_source == "1":
# X as frame
data = data.transpose(1, 0, 2, 3, 4)
shape = (1, shape[1], shape[2])
elif self.sequence_source == "2":
# Y as frame
data = data.transpose(2, 1, 0, 3, 4)
shape = (shape[0], 1, shape[2])
elif self.sequence_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)
def progress_callback_factory(layer_name, progress, progress_step):
def progress_callback(frame, total):
if cancel():
raise CancelledByUser
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}...")
return progress_callback
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)
try:
layer = Layer(data=data == np.full_like(data, i+1),
name=name_i,
kind=kind,
affine=affine)
layer.resize(shape=shape,
progress_callback=progress_callback)
layers.append(layer)
except CancelledByUser:
return
except Exception as e:
self.has_error = e
return
elif kind == "scalar":
name = self.layer_name or "Scalar"
if self.split_channels:
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,
affine=affine)
layer.resize(shape=shape,
progress_callback=progress_callback)
layers.append(layer)
except CancelledByUser:
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,
affine=affine)
layer.resize(shape=shape,
progress_callback=progress_callback)
layers.append(layer)
except CancelledByUser:
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.thread = threading.Thread(target=import_volumetric_data_func,
args=(self, context, lambda: self.is_cancelled))
self.thread.start()
self._timer = context.window_manager.event_timer_add(time_step=0.1,
window=context.window)
bpy.types.STATUSBAR_HT_header.append(progress_bar)
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
if event.type == 'ESC':
self.is_cancelled = True
progress_update(context, 0, "Canceling...")
return {'PASS_THROUGH'}
if event.type != 'TIMER':
return {'PASS_THROUGH'}
bpy.context.workspace.status_text_set_internal(None)
if self.thread.is_alive():
return {'PASS_THROUGH'}
self.thread.join()
context.window_manager.event_timer_remove(self._timer)
bpy.types.STATUSBAR_HT_header.remove(progress_bar)
if self.is_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.layers is None:
self.report({"ERROR"}, "Some thing went wrong.")
return {'CANCELLED'}
is_first_import = len(get_all_layer_objs()) == 0
if self.container_obj_name:
container_obj = bpy.data.objects.get(self.container_obj_name)
if container_obj is None:
raise Exception("Could not find target container")
container_obj = add_layers(self.layers,
container_obj=container_obj,
cache_dir=get_cache_dir(context))
else:
container = Container(name=self.container_name,
layers=self.layers)
container_obj = container_to_obj(container,
scene_scale=self.scene_scale,
cache_dir=get_cache_dir(context))
select_object(container_obj)
# Change render setting for better result
if is_first_import:
change_render_setting(context)
self.report({"INFO"}, "Successfully Imported")
return {'FINISHED'}
def invoke(self, context, event):
layer_kind = self.read_as.capitalize()
title = f"Add to **{self.container_obj_name}**, As {layer_kind}" \
if self.container_obj_name != "" else f"Init a Container, As {layer_kind}"
context.window_manager.invoke_props_dialog(self,
width=500,
title=title)
return {'RUNNING_MODAL'}
def draw(self, context):
layer_shape = get_layer_shape(self.bioxel_size,
self.orig_shape,
self.orig_spacing)
orig_shape = tuple(self.orig_shape)
# change shape as sequence or not
channel_count = self.channel_count
if self.sequence_source == "-1":
pass
elif self.sequence_source == "0":
# frame as frame
pass
elif self.sequence_source == "1":
layer_shape = (1, layer_shape[1], layer_shape[2])
elif self.sequence_source == "2":
layer_shape = (layer_shape[0], 1, layer_shape[2])
elif self.sequence_source == "3":
layer_shape = (layer_shape[0], layer_shape[1], 1)
else:
channel_count = 1
import_channel = channel_count if self.split_channels or channel_count == 1 else "combined"
bioxel_count = layer_shape[0] * layer_shape[1] * layer_shape[2]
layer_shape_text = f"Shape from {str(orig_shape)} to {str(layer_shape)}"
if bioxel_count > 100000000:
layer_shape_text += "**TOO LARGE!**"
layout = self.layout
if self.container_obj_name == "":
layout.prop(self, "container_name")
layout.prop(self, "layer_name")
panel = layout.box()
panel.prop(self, "bioxel_size")
row = panel.row()
row.prop(self, "orig_spacing")
panel.label(text=layer_shape_text)
panel = layout.box()
panel.prop(self, "sequence_source")
if self.read_as == "scalar":
panel = layout.box()
panel.prop(self, "split_channels",
text=f"Split Channels (Get {channel_count} channels, import {import_channel} channels)")
if self.container_obj_name == "":
layer_size = get_layer_size(layer_shape,
self.bioxel_size,
self.scene_scale)
layer_size_text = f"Size will be: ({layer_size[0]:.2f}, {layer_size[1]:.2f}, {layer_size[2]:.2f}) m"
panel = layout.box()
panel.prop(self, "scene_scale")
panel.label(text=layer_size_text)
class ExportVolumetricData(bpy.types.Operator):
bl_idname = "bioxelnodes.export_volumetric_data"
bl_label = "Export Layer as VDB"
bl_description = "Export Layer as VDB"
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".vdb"
@classmethod
def poll(cls, context):
layer_obj = get_layer_obj(bpy.context.active_object)
return True if layer_obj else False
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
layer_obj = get_layer_obj(bpy.context.active_object)
filepath = f"{self.filepath.split('.')[0]}.vdb"
# "//"
source_dir = bpy.path.abspath(layer_obj.data.filepath)
output_path: Path = Path(filepath).resolve()
source_path: Path = Path(source_dir).resolve()
# print('output_path', output_path)
# print('source_path', source_path)
shutil.copy(source_path, output_path)
self.report({"INFO"}, f"Successfully exported to {output_path}")
return {'FINISHED'}

View File

@@ -0,0 +1,251 @@
from pathlib import Path
import bpy
import numpy as np
from ..bioxel.layer import Layer
from ..bioxelutils.node import get_nodes_by_type
from ..bioxelutils.container import add_layers, get_container_obj
from ..bioxelutils.layer import obj_to_layer, get_container_layer_objs, get_layer_obj
from .utils import get_preferences, select_object
def get_layer_prop_value(layer_obj: bpy.types.Object, prop: str):
try:
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
return layer_node.inputs[prop].default_value
except:
return None
def get_label_layer_selection(self, context):
items = [("None", "None", "")]
container_obj = get_container_obj(bpy.context.active_object)
for layer_obj in get_container_layer_objs(container_obj):
if get_layer_prop_value(layer_obj, "kind") == "label":
items.append((layer_obj.name,
layer_obj.name,
""))
return items
class LayerOperator():
bl_options = {'UNDO'}
base_obj: bpy.types.Object = None
def add_layer(self, context, layer):
preferences = get_preferences(context)
cache_dir = Path(preferences.cache_dir, 'VDBs')
container_obj = self.base_obj.parent
add_layers([layer],
container_obj,
cache_dir)
select_object(container_obj)
def invoke(self, context, event):
self.base_obj = get_layer_obj(bpy.context.active_object)
return self.execute(context)
class ScalarOperator(LayerOperator):
@classmethod
def poll(cls, context):
label_objs = [obj for obj in context.selected_ids
if get_layer_prop_value(obj, "kind") == "scalar"]
return True if len(label_objs) > 0 else False
class LabelOperator(LayerOperator):
@classmethod
def poll(cls, context):
label_objs = [obj for obj in context.selected_ids
if get_layer_prop_value(obj, "kind") == "label"]
return True if len(label_objs) > 0 else False
class SignScalar(bpy.types.Operator, ScalarOperator):
bl_idname = "bioxelnodes.sign_scalar"
bl_label = "Sign Scalar"
bl_description = "Sign the scalar value"
def execute(self, context):
base_layer = obj_to_layer(self.base_obj)
modified_layer = base_layer.copy()
modified_layer.data = -base_layer.data
modified_layer.name = f"{base_layer.name}_Signed"
self.add_layer(context, modified_layer)
return {'FINISHED'}
class FillOperator(ScalarOperator):
def invoke(self, context, event):
self.base_obj = get_layer_obj(bpy.context.active_object)
scalar_min = get_layer_prop_value(self.base_obj, "min")
self.fill_value = min(scalar_min, 0)
context.window_manager.invoke_props_dialog(self, width=400)
return {'RUNNING_MODAL'}
class FillByThreshold(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_threshold"
bl_label = "Fill Value by Threshold"
bl_description = "Fill Value by Threshold"
threshold: bpy.props.FloatProperty(
name="Threshold",
soft_min=0, soft_max=1024,
default=128,
) # type: ignore
fill_value: bpy.props.FloatProperty(
name="Fill Value",
soft_min=0, soft_max=1024.0,
default=0,
) # type: ignore
invert: bpy.props.BoolProperty(
name="Invert Area",
default=True,
) # type: ignore
def execute(self, context):
base_layer = obj_to_layer(self.base_obj)
data = np.amax(base_layer.data, -1)
mask = data <= self.threshold \
if self.invert else data > self.threshold
modified_layer = base_layer.copy()
modified_layer.fill(self.fill_value, mask)
modified_layer.name = f"{base_layer.name}_{self.threshold}-Filled"
self.add_layer(context, modified_layer)
return {'FINISHED'}
class FillByRange(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_range"
bl_label = "Fill Value by Range"
bl_description = "Fill Value by Range"
from_min: bpy.props.FloatProperty(
name="From Min",
soft_min=0, soft_max=1024,
default=128,
) # type: ignore
from_max: bpy.props.FloatProperty(
name="From Max",
soft_min=0, soft_max=1024,
default=256,
) # type: ignore
fill_value: bpy.props.FloatProperty(
name="Fill Value",
soft_min=0, soft_max=1024.0,
default=0,
) # type: ignore
invert: bpy.props.BoolProperty(
name="Invert Area",
default=True,
) # type: ignore
def execute(self, context):
base_layer = obj_to_layer(self.base_obj)
data = np.amax(base_layer.data, -1)
mask = (data <= self.from_min) | (data >= self.from_max) if self.invert else \
(data > self.from_min) & (data < self.from_max)
modified_layer = base_layer.copy()
modified_layer.fill(self.fill_value, mask)
modified_layer.name = f"{base_layer.name}_{self.from_min}-{self.from_max}-Filled"
self.add_layer(context, modified_layer)
return {'FINISHED'}
class FillByLabel(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_label"
bl_label = "Fill Value by Label"
bl_description = "Fill Value by Label Area"
label_obj_name: bpy.props.EnumProperty(
name="Label Layer",
items=get_label_layer_selection
) # type: ignore
fill_value: bpy.props.FloatProperty(
name="Fill Value",
soft_min=0, soft_max=1024.0,
default=0,
) # type: ignore
invert: bpy.props.BoolProperty(
name="Invert Label",
default=True,
) # type: ignore
def execute(self, context):
label_obj = bpy.data.objects.get(self.label_obj_name)
if not label_obj:
self.report({"WARNING"}, "Cannot find any label layer.")
return {'FINISHED'}
base_layer = obj_to_layer(self.base_obj)
label_layer = obj_to_layer(label_obj)
label_layer.resize(base_layer.shape)
mask = np.amax(label_layer.data, -1)
if self.invert:
mask = 1 - mask
modified_layer = base_layer.copy()
modified_layer.fill(self.fill_value, mask)
modified_layer.name = f"{base_layer.name}_{label_layer.name}-Filled"
self.add_layer(context, modified_layer)
return {'FINISHED'}
class CombineLabels(bpy.types.Operator, LabelOperator):
bl_idname = "bioxelnodes.combine_labels"
bl_label = "Combine Labels"
bl_description = "Combine all selected labels"
def execute(self, context):
label_objs = [obj for obj in context.selected_ids
if get_layer_prop_value(obj, "kind") == "label"]
if len(label_objs) < 2:
self.report({"WARNING"}, "Not enough layers.")
return {'FINISHED'}
base_obj = label_objs[0]
label_objs = label_objs[1:]
container_obj = base_obj.parent
base_layer = obj_to_layer(base_obj)
modified_layer = base_layer.copy()
label_names = [base_layer.name]
for label_obj in label_objs:
label_layer = obj_to_layer(label_obj)
label_layer.resize(base_layer.shape)
modified_layer.data = np.maximum(base_layer.data, label_layer.data)
label_names.append(label_layer.name)
modified_layer.name = f"{'-'.join(label_names)}-Combined"
self.add_layer(context, modified_layer)
return {'FINISHED'}

View File

@@ -0,0 +1,215 @@
import re
import bpy
from pathlib import Path
import shutil
from ..utils import copy_to_dir
from .utils import get_cache_dir
from ..nodes import custom_nodes
from ..bioxelutils.container import get_container_objs_from_selection
from ..bioxelutils.layer import get_all_layer_objs, get_container_layer_objs
CLASS_PREFIX = "BIOXELNODES_MT_NODES"
class ReLinkNodes(bpy.types.Operator):
bl_idname = "bioxelnodes.relink_nodes"
bl_label = "Relink Nodes to Addon"
bl_description = "Relink all nodes to addon source"
def execute(self, context):
file_name = Path(custom_nodes.nodes_file).name
for lib in bpy.data.libraries:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
lib_name = lib_path.name
if lib_name == file_name:
if str(lib_path) != custom_nodes.nodes_file:
lib.filepath = custom_nodes.nodes_file
self.report({"INFO"}, f"Successfully relinked.")
return {'FINISHED'}
def save_layer_cache(layer_obj, output_dir):
pattern = r'\.\d{4}\.'
# "//"
output_dir = bpy.path.abspath(output_dir)
source_dir = bpy.path.abspath(layer_obj.data.filepath)
source_path: Path = Path(source_dir).resolve()
is_sequence = re.search(pattern, source_path.name) is not None
name = layer_obj.name if is_sequence else f"{layer_obj.name}.vdb"
output_path: Path = Path(output_dir, name, source_path.name).resolve() \
if is_sequence else Path(output_dir, name).resolve()
if output_path != source_path:
copy_to_dir(source_path.parent if is_sequence else source_path,
output_path.parent.parent if is_sequence else output_path.parent,
new_name=name)
blend_path = Path(bpy.path.abspath("//")).resolve()
layer_obj.data.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
class SaveStagedData(bpy.types.Operator):
bl_idname = "bioxelnodes.save_staged_data"
bl_label = "Save Staged Data"
bl_description = "Save all staged data in this file for sharing"
save_cache: bpy.props.BoolProperty(
name="Save Layer Caches",
default=True,
) # type: ignore
cache_dir: bpy.props.StringProperty(
name="Cache Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
save_lib: bpy.props.BoolProperty(
name="Save Node Library File",
default=True,
) # type: ignore
lib_dir: bpy.props.StringProperty(
name="Library Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def execute(self, context):
if self.save_lib:
files = []
for classname in dir(bpy.types):
if CLASS_PREFIX in classname:
cls = getattr(bpy.types, classname)
files.append(cls.nodes_file)
files = list(set(files))
for file in files:
file_name = Path(file).name
# "//"
lib_dir = bpy.path.abspath(self.lib_dir)
output_path: Path = Path(lib_dir, file_name).resolve()
source_path: Path = Path(file).resolve()
if output_path != source_path:
shutil.copy(source_path, output_path)
for lib in bpy.data.libraries:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path == source_path:
blend_path = Path(bpy.path.abspath("//")).resolve()
lib.filepath = bpy.path.relpath(
str(output_path), start=str(blend_path))
self.report({"INFO"}, f"Successfully saved to {output_path}")
if self.save_cache:
fails = []
for layer in get_all_layer_objs():
try:
save_layer_cache(layer, self.cache_dir)
except:
fails.append(layer)
if len(fails) == 0:
self.report({"INFO"}, f"Successfully saved bioxel layers.")
else:
self.report(
{"WARNING"}, f"{','.join([layer.name for layer in fails])} fail to save.")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self,
width=500)
return {'RUNNING_MODAL'}
@classmethod
def poll(cls, context):
return bpy.data.is_saved
def draw(self, context):
layout = self.layout
panel = layout.box()
panel.prop(self, "save_cache")
panel.prop(self, "cache_dir")
panel = layout.box()
panel.prop(self, "save_lib")
panel.prop(self, "lib_dir")
class SaveCaches(bpy.types.Operator):
bl_idname = "bioxelnodes.save_caches"
bl_label = "Save Caches"
bl_description = "Save Container's caches to directory."
cache_dir: bpy.props.StringProperty(
name="Layer Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
@classmethod
def poll(cls, context):
container_objs = get_container_objs_from_selection()
return len(container_objs) > 0
def execute(self, context):
container_objs = get_container_objs_from_selection()
if len(container_objs) == 0:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
fails = []
for container_obj in container_objs:
for layer_obj in get_container_layer_objs(container_obj):
try:
save_layer_cache(layer_obj, self.cache_dir)
except:
fails.append(layer_obj)
if len(fails) == 0:
self.report({"INFO"}, f"Successfully saved bioxel layers.")
else:
self.report(
{"WARNING"}, f"{','.join([layer.name for layer in fails])} fail to save.")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self,
width=500)
return {'RUNNING_MODAL'}
class CleanAllCaches(bpy.types.Operator):
bl_idname = "bioxelnodes.clear_all_caches"
bl_label = "Clean All Caches in Temp"
bl_description = "Clean all caches saved in temp"
def execute(self, context):
cache_dir = get_cache_dir(context)
try:
shutil.rmtree(cache_dir)
self.report({"INFO"}, f"Successfully cleaned caches.")
return {'FINISHED'}
except:
self.report({"WARNING"},
"Fail to clean caches, you may do it manually.")
return {'CANCELLED'}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message="All caches will be cleaned, include other project files, do you still want to clean?")
return {'RUNNING_MODAL'}

View File

@@ -0,0 +1,60 @@
import bpy
from pathlib import Path
from .. import __package__ as base_package
def change_render_setting(context):
preferences = get_preferences(context)
if preferences.do_change_render_setting:
bpy.context.scene.render.engine = 'CYCLES'
try:
bpy.context.scene.cycles.shading_system = True
bpy.context.scene.cycles.volume_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_preview_step_rate = 10
bpy.context.scene.cycles.volume_step_rate = 10
except:
pass
try:
bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.volumetric_shadow_samples = 128
bpy.context.scene.eevee.volumetric_samples = 256
bpy.context.scene.eevee.use_volumetric_shadows = True
except:
pass
def select_object(target_obj):
for obj in bpy.data.objects:
obj.select_set(False)
target_obj.select_set(True)
bpy.context.view_layer.objects.active = target_obj
def progress_bar(self, context):
row = self.layout.row()
row.progress(
factor=context.window_manager.bioxelnodes_progress_factor,
type="BAR",
text=context.window_manager.bioxelnodes_progress_text
)
row.scale_x = 2
def progress_update(context, factor, text=""):
context.window_manager.bioxelnodes_progress_factor = factor
context.window_manager.bioxelnodes_progress_text = text
def get_preferences(context):
return context.preferences.addons[base_package].preferences
def get_cache_dir(context):
preferences = get_preferences(context)
cache_path = Path(preferences.cache_dir, 'VDBs')
cache_path.mkdir(parents=True, exist_ok=True)
return str(cache_path)

View File

@@ -1,9 +1,7 @@
import bpy
from pathlib import Path
from .externalpackage import ExternalPackagePreferences
class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences):
class BioxelNodesPreferences(bpy.types.AddonPreferences):
bl_idname = __package__
cache_dir: bpy.props.StringProperty(
@@ -20,11 +18,6 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferen
def draw(self, context):
layout = self.layout
# ExternalPackagePreferences Config
self.requirements_dir = str(Path(__file__).parent)
super().draw(context)
layout.label(text="Configuration")
layout.prop(self, 'cache_dir')
layout.prop(self, "do_change_render_setting")

View File

@@ -1 +0,0 @@
SimpleITK==2.3.1 # Insight Segmentation and Registration Toolkit.

View File

@@ -1,178 +1,32 @@
import bpy
import mathutils
from pathlib import Path
import shutil
def get_type(cls):
return type(cls).__name__
def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True):
source = Path(source_path)
target = Path(dir_path)
# Check if the source exists
if not source.exists():
raise FileNotFoundError
def get_node_by_type(nodes, type_name: str):
return [node for node in nodes if get_type(node) == type_name]
# Check if the target exists
if not target.exists():
target.mkdir(parents=True, exist_ok=True)
target_path = target / new_name if new_name else target / source.name
# If source is a file, copy it to the target directory
if source.is_file():
try:
shutil.copy(source, target_path)
except shutil.SameFileError:
if exist_ok:
pass
else:
raise shutil.SameFileError
# If source is a directory, copy its contents to the target directory
elif source.is_dir():
shutil.copytree(source, target_path, dirs_exist_ok=exist_ok)
def show_message(message="", title="Message Box", icon='INFO'):
def draw(self, context):
self.layout.label(text=message)
bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
def calc_bbox_verts(origin, size):
bbox_origin = mathutils.Vector(
(origin[0], origin[1], origin[2]))
bbox_size = mathutils.Vector(
(size[0], size[1], size[2]))
bbox_verts = [
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2],
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0,
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2],
),
]
return bbox_verts
def lock_transform(obj):
obj.lock_location[0] = True
obj.lock_location[1] = True
obj.lock_location[2] = True
obj.lock_rotation[0] = True
obj.lock_rotation[1] = True
obj.lock_rotation[2] = True
obj.lock_scale[0] = True
obj.lock_scale[1] = True
obj.lock_scale[2] = True
def hide_in_ray(obj):
obj.visible_camera = False
obj.visible_diffuse = False
obj.visible_glossy = False
obj.visible_transmission = False
obj.visible_volume_scatter = False
obj.visible_shadow = False
def get_container(current_obj):
if current_obj:
return current_obj if current_obj.get('bioxel_container') else None
return None
def get_layer(current_obj):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layers(container):
layers = []
for obj in bpy.data.objects:
if obj.parent == container and get_layer(obj):
layers.append(obj)
return layers
def get_all_layers():
layers = []
for obj in bpy.data.objects:
if get_layer(obj):
layers.append(obj)
return layers
def get_text_index_str(text):
# Initialize an empty string to store the digits
digits = ""
# Iterate through the characters in reverse order
started = False
for char in text[::-1]:
if char.isdigit():
started = True
# If the character is a digit, add it to the digits string
digits += char
else:
if started:
# If a non-digit character is encountered, stop the loop
break
# Reverse the digits string to get the correct order
last_number = digits[::-1]
return last_number
def add_driver(target_prop, var_sources, expression):
driver = target_prop.driver_add("default_value")
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
target_prop = target.inputs.get(target_prop)
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target_prop, drivers, expression)
if not target_path.exists():
raise Exception

95
build.py Normal file
View File

@@ -0,0 +1,95 @@
from pathlib import Path
import shutil
import subprocess
import sys
from dataclasses import dataclass
import tomlkit
@dataclass
class Platform:
pypi_suffix: str
blender_tag: str
required_packages = ["SimpleITK==2.3.1",
"pyometiff==1.0.0",
"mrcfile==1.5.1",
"h5py==3.11.0",
"transforms3d==0.4.2"]
platforms = {"windows-x64": Platform(pypi_suffix="win_amd64",
blender_tag="windows-x64"),
"linux-x64": Platform(pypi_suffix="manylinux2014_x86_64",
blender_tag="linux-x64"),
"macos-arm64": Platform(pypi_suffix="macosx_12_0_arm64",
blender_tag="macos-arm64"),
"macos-x64": Platform(pypi_suffix="macosx_10_16_x86_64",
blender_tag="macos-x64")}
packages_to_remove = {
"imagecodecs",
"numpy"
}
def run_python(args: str):
python = Path(sys.executable).resolve()
subprocess.run([python] + args.split(" "))
def build_extension(platform: Platform, python_version="3.11") -> None:
wheel_dirpath = Path("./bioxelnodes/wheels")
toml_filepath = Path("bioxelnodes/blender_manifest.toml")
scipy_ndimage_dirpath = Path("./scipy_ndimage", platform.blender_tag)
# download required_packages
run_python(
f"-m pip download {' '.join(required_packages)} --dest {wheel_dirpath.as_posix()} --only-binary=:all: --python-version={python_version} --platform={platform.pypi_suffix}"
)
for f in wheel_dirpath.glob('*.whl'):
if any([package in f.name for package in packages_to_remove]):
f.unlink(missing_ok=True)
elif platform.blender_tag == "macos-arm64" and \
"lxml" in f.name and "universal2" in f.name:
f.rename(Path(f.parent,
f.name.replace("universal2", "arm64")))
for ndimage_filepath in scipy_ndimage_dirpath.iterdir():
to_filepath = Path("./bioxelnodes/bioxel/scipy", ndimage_filepath.name)
shutil.copy(ndimage_filepath, to_filepath)
# Load the TOML file
with toml_filepath.open("r") as file:
manifest = tomlkit.parse(file.read())
manifest["platforms"] = [platform.blender_tag]
manifest["wheels"] = [f"./wheels/{f.name}"
for f in wheel_dirpath.glob('*.whl')]
# build = tomlkit.table(True)
# generated = tomlkit.table()
# generated["platforms"] = [platform.blender_tag]
# generated["wheels"] = [f"./wheels/{f.name}"
# for f in wheel_dirpath.glob('*.whl')]
# build.append('generated', generated)
# manifest.append('build', build)
# Write the updated TOML file
with toml_filepath.open("w") as file:
text = tomlkit.dumps(manifest)
file.write(text)
def main():
platform_name = sys.argv[1]
platform = platforms[platform_name]
build_extension(platform)
if __name__ == "__main__":
main()

BIN
docs/assets/4d-time.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
docs/assets/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
docs/assets/eevee.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 18 KiB

0
docs/benchmark.md Normal file
View File

1
docs/color.md Normal file
View File

@@ -0,0 +1 @@
This type of node is responsible for setting the color properties of the shader

46
docs/concept.md Normal file
View File

@@ -0,0 +1,46 @@
# Concepts & Pipeline
## Container, Layer, Component
Bioxel Nodes imports volumetric data and put it into a **Container** as a **Layer**. One container may has more than one layer, and each layer stores the information of different fields under the same location, which is similar to the view layers in map app, except that here it is in 3D space.
In order to visualize the volumetric data the way we want it to, we need to build renderable objects from layers. We call those objects **Component**. The following diagram shows the relationship of **Container**, **Layer**, and **Component**:
![alt text](assets/features_concept.png)
### Container Structure
In Blender, container structure is like this:
```bash
Case_0000 # Container
|-- Case_0000_CT # Layer
|-- Case_0000_Label_1 # Layer
`-- Case_0000_Label_2 # Layer
```
The container also stores the build process in geometry nodes:
![alt text](assets/features_container.png)
### Layer Type
The layer is categorized into these by data type:
- Scalar
- Label
- Vector (Not implemented yet)
- Color (Not implemented yet)
## Component Building Pipline
In order to build a component, the general process is to first use a "Mask Method" node to build the surface of the component based on its layers, and then connect to a "Assign Shader" node to add the physical properties. Finally, if you need to cut the cross-section, then connect to a "Cut" node. The whole process is shown in the following diagram
![alt text](assets/nodes_concept.png)
A typical example looks like this:
![alt text](assets/nodes_example.png)
The "Mask Method" node tends to be very computationally intensive, and if it consumes too much time, then you can bake it with a "Bake" node after it (but you need to save the Blender file first).
![alt text](assets/nodes_bake.png)

1
docs/cut.md Normal file
View File

@@ -0,0 +1 @@
This type of node is responsible for cutting components to present cross-section

View File

@@ -1,61 +0,0 @@
# Getting Started
Currently only support Blender 4.0 or above, make sure you have the correct version of Blender.
## Add-on Installation
Download the latest version https://github.com/OmooLab/BioxelNodes/releases/latest
In Blender, Edit > Preferences > Add-ons > Install, select the `BioxelNodes_{version}.zip` you just downloaded.
The add-on requires a third-party python dependency called SimpleITK, click `Install SimpleITK` button below to install the dependency. After clicking, blender may get stuck, it is downloading and installing, just wait for a moment. After that, click `Reboot Blender` button.
![dependency](assets/getting-started_dependency.png)
This step may have failed due to network factors, just click "Set PyPI Mirror" to change the mirror.
## Prepare Your Data
First you need to have your volumetric data ready. If not, you can access open research data from list below.
> Note that just because they are open and available for download does not mean you can use them for anything! Be sure to look at the description of the available scopes from website.
| Source | Object |
| ------------------------------------------------------------------------------------ | ------------------ |
| [Dryad](https://datadryad.org) | Open Research Data |
| [OpenOrganelle](https://openorganelle.janelia.org/datasets) | Cells |
| [Embodi3D](https://www.embodi3d.com/files/category/37-medical-scans/) | Medical Images |
| [Github](https://github.com/sfikas/medical-imaging-datasets) | Medical Images |
| [NIHR](https://nhsx.github.io/open-source-imaging-data-sets/) | Medical Images |
| [Medical Segmentation Decathlon](http://medicaldecathlon.com/) | Medical Images |
| [Allen Cell Explorer](https://www.allencell.org/3d-cell-viewer.html) | Cells |
| [Visible Human Project](https://www.nlm.nih.gov/research/visible/visible_human.html) | Medical Images |
## Import Volume Data
File > Import > Volume Data as Bioxel Layer, select **one** of the .dcm files and click on "Volume Data as Bioxel Layer" (you can also drag one of the .dcm files directly into the 3D viewport to trigger the import)
![importing](assets/getting-started_importing.png)
It may take a while to read data. After finishing reading, it will pop up a dialog box. Ignore all the options, just click OK (we will tell about these options later).
After importing the data, the add-on will automatically add the necessary nodes to build the component. Click on the new created object in outliner, open the geometry nodes panel, set `Threshold` in "Mask by Threshold" node to 200, and then connect a Slime Shader node (Add > Bioxel Nodes > Slime Shader) after it. The node graph should be as following:
![alt text](assets/getting-started_graph.png)
and then turned on the cycles rendering to directly see the result.
![result](assets/getting-started_result.png)
You can change the "Threshold" to modify the preview model, or change the "Color", "Density" to modify the shader effect. All the parameters are straightforward, you can understand them by changing the values.
## Share Your File
The VDB cache and the Bioxel nodes are not reachable to other computers if you don't save them. If you want to give your blender file to someone else, you will need to perform the following steps first:
1. Save your blender file
2. Click Scene > Bioxels > Save All Bioxels in the properties panel.
3. Click Scene > Custom Nodes > Save All Custom Nodes in the properties panel.
![alt text](assets/getting-started_share-file.png)
After that, both the VDB cache and the Bioxel nodes are restored in relative locations. When you share this file with someone, pack the entire directory so that the resources will be not lost. The other person will be able to open it correctly, regardless of whether they have Bioxel Nodes installed or not.

View File

@@ -1,34 +1,3 @@
# Features & Options
## Bioxel Design Concept
According to Bioxel design concept, Bioxel Nodes imports volume data and put it into a **Container** as a **Layer**. One container may has more than one layer, and each layer stores the information of different fields under the same location, which is similar to the view layers in map app, except that here it is in 3D space. In order to render the volume the way we want it to, we need to build renderable objects from layers. We call those objects **Component**. The following diagram shows the relationship of **Container**, **Layer**, and **Component**:
![alt text](assets/features_concept.png)
Bioxel Nodes staging the layer as an openvdb file. The process of building from layers to a component is taking place in blender geometry nodes graph with blender native geometry nodes. Therefore, **the blender file works without Bioxel Nodes.**
## Container Structure
In Blender, container structure is like this:
```bash
Case_0000 # Container
|-- Case_0000_CT # Layer
|-- Case_0000_Label_1 # Layer
`-- Case_0000_Label_2 # Layer
```
The container also stores the build process in geometry nodes:
![alt text](assets/features_container.png)
The layer is categorized into these by data type:
- Scalar
- Label
- Vector (Not implemented yet)
- Color (Not implemented yet)
## First Time Import Volume Data
@@ -69,13 +38,4 @@ A bioxel is like a pixel, the larger the `Bioxel Size`, the lower the resolution
## Adding Volume Data to an existing container
In 3D view or outliner panel, select the container and right click, Bioxel Nodes > Add Volume Data to Container.
The import settings are the same as for the first time import.
## Convert Bioxel Components to Mesh
In 3D view or outliner panel, select the container and right click, Bioxel Nodes > Bioxel Components To Mesh
Once it has been turned into a mesh model, you can perform any traditional 3D editing operations on it, such as sculpting, boolean, etc.
## Export Biovel Layer as VDB
File > Export > Biovel Layer as VDB
The import settings are the same as for the first time import.

View File

@@ -1,56 +1,76 @@
[中文文档](https://uj6xfhbzp0.feishu.cn/wiki/Qx3VwHuNPimeI8kr6nDcvl1DnHf?from=from_copylink)
# Bioxel Nodes
![Static Badge](https://img.shields.io/badge/Blender-orange?style=for-the-badge&logo=blender&logoColor=white)
![GitHub License](https://img.shields.io/github/license/OmooLab/BioxelNodes?style=for-the-badge)
![GitHub Release](https://img.shields.io/github/v/release/OmooLab/BioxelNodes?style=for-the-badge)
![GitHub Repo stars](https://img.shields.io/github/stars/OmooLab/BioxelNodes?style=for-the-badge)
[![For Blender](https://img.shields.io/badge/Blender-orange?style=for-the-badge&logo=blender&logoColor=white&color=black)](https://blender.org/)
![GitHub License](https://img.shields.io/github/license/OmooLab/BioxelNodes?style=for-the-badge&labelColor=black)
![GitHub Release](https://img.shields.io/github/v/release/OmooLab/BioxelNodes?style=for-the-badge&labelColor=black)
![GitHub Repo stars](https://img.shields.io/github/stars/OmooLab/BioxelNodes?style=for-the-badge&labelColor=black)
Bioxel Nodes is a Blender add-on for scientific volumetric data visualization. It using Blender's powerful Geometry Nodes | Cycles to process and render volumetric data.
[![Discord](https://img.shields.io/discord/1265129134397587457?style=for-the-badge&logo=discord&label=Discord&labelColor=white&color=black)](https://discord.gg/pYkNyq2TjE)
## About
Bioxel Nodes is a Blender addon for scientific volumetric data visualization. It using Blender's powerful **Geometry Nodes** and **Cycles** to process and render volumetric data.
Before us, there have been many tutorials and add-ons for importing volumetric data into Blender. However, we found that there were many details that were not addressed in place, some scientific facts were ignored, and the volume rendering was not pretty enough. With Bioxel Nodes, you can easily import the volumetric data into Blender, and more importantly, it can quickly make a beautiful realistic rendering of it.
![cover](https://omoolab.github.io/BioxelNodes/latest/assets/cover.png)
Below are some examples with Bioxel Nodes. Thanks to Cycles Render, the volumetric data can be rendered with great detail:
- Realistic rendering result, also support EEVEE NEXT.
- Support multiple formats.
- Support 4D volumetric data.
- All kinds of cutters.
- Simple and powerful nodes.
- Based on blender natively, can work without addon.
![gallery](assets/gallery.png)
**Read the [getting started](https://omoolab.github.io/BioxelNodes/latest/installation) to begin your journey into visualizing volumetric data!**
The "Bioxel" in "Bioxel Nodes", is a combination of the words "Bio-" and "Voxel". Bioxel is a voxel that stores biological data. We are developing a toolkit around Bioxel for better biological data visualization. but before its release, we made this Blender version of bioxels toolkit first, in order to let more people to have fun with volumetric data. [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started)
Welcome to our [discord server](https://discord.gg/pYkNyq2TjE), if you have any problems with this add-on.
## Supported Format
## Support Multiple Formats
| Format | EXT | Test |
| ------ | ---------------------------------------- | ------- |
| DICOM | .dcm, .DICOM | ✅ pass |
| DICOM | .dcm, .DCM, .DICOM, .ima, .IMA | ✅ pass |
| BMP | .bmp, .BMP | ✅ pass |
| JPEG | .jpg, .JPG, .jpeg, .JPEG | ✅ pass |
| PNG | .png, .PNG | ✅ pass |
| TIFF | .tif, .TIF, .tiff, .TIFF | ✅ pass |
| Nifti | .nia, .nii, .nii.gz, .hdr, .img, .img.gz | ✅ pass |
| Nrrd | .nrrd, .nhdr | ✅ pass |
| Meta | .mha, .mhd | yet |
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
| VTK | .vtk | yet |
| BioRad | .PIC, .pic | yet |
| Gipl | .gipl, .gipl.gz | yet |
| LSM | .lsm, .LSM | yet |
| MINC | .mnc, .MNC | yet |
| MRC | .mrc, .rec | yet |
| OME | .ome.tiff, .ome.tif | ✅ pass |
| MRC | .mrc, .mrc.gz, .map, .map.gz | ✅ pass |
## Support 4D volumetric data
![4d](https://omoolab.github.io/BioxelNodes/latest/assets/4d-time.gif)
🥰 4D volumetric data can also be imported into Blender.
## Support EEVEE NEXT
![eevee](https://omoolab.github.io/BioxelNodes/latest/assets/eevee.gif)
👍 EEVEE NEXT is absolutely AWESOME! Bioxel Nodes is fully support EEVEE NEXT now! However, there are some limitations:
1. Only one cutter supported.
2. EEVEE result is not that great as Cycles does.
## Known Limitations
- Sections cannot be generated (will be supported soon)
- Time sequence volume not supported (will be supported soon)
- Only works with Cycles CPU , Cycles GPU (OptiX), EEVEE
- Section surface cannot be generated when convert to mesh (will be supported soon)
## To Upgrade Add-on
## Compatible to Newer Version
To upgrade from an older version of the add-on to the latest, you need to do the following:
**v0.3.x is not compatible to v0.2.x, Updating this addon may break old files. Read the following carefully before upgradation**
1. Remove the old version of Bioxel Nodes at Preferences > Add-ons
2. Add the new version and restart Blender.
Before upgradation, you need to ask yourself whether this project file will be modified again or not, if it's an archived project file, I would recommend that you run **Bioxel Nodes > Save Staged Data** to make the addon nodes permanent. In this way, there will be no potential problem with the nodes not functioning due to the addon update.
It is not support editing the same blender file across add-on versions. In order to make sure that the previous file works properly. You need to save the staged data before upgrading ( read the last section of [Getting Started](https://omoolab.github.io/BioxelNodes/latest/getting-started/#share-your-file) ).
After the addon update, your old project files may not work either, this may be because you had executed **Save Staged Data**. If so, you need to execute **Bioxel Nodes > Relink Nodes to Addon** to relink them to make sure that the addon's new functionality and the addon nodes are synchronized.
But even then, there is still no guarantee that the new version of the add-on will work on the old blender file. Therefore, it is highly recommended to open a new blender file to start the creating, not based on the old one.
Also, the older shaders are not based on OSL, so if you find that you can't render volumes, you need to turn on **Open Shading Language (OSL)** in the Render Settings.
Alternatively, objects from the old file that have nothing to do with Bioxel Nodes could be append to the new blender file.
## Roadmap
- Better multi-format import experience
- One-click bake model with texture
- AI Segmentation to Generate Labels

27
docs/installation.md Normal file
View File

@@ -0,0 +1,27 @@
# Installation
> **Currently only support Blender 4.1 or above, make sure you have the correct version of Blender.**
## For Blender 4.2 or higher
The most recommended way is to open **Edit > Preferences > Extension**, enter "bio" in the search box and click **Install**. since the addon is quite large (20MB) you may need to wait a while!
![extension](assets/installation_extension.png)
Thats it!
> If it cannot be enable, reboot blender or install again as administrator
Also, you can do it maually. Download the **Extension** version `BioxelNodes_Extension_{version}.zip` from https://github.com/OmooLab/BioxelNodes/releases/latest
In Blender, **Edit > Preferences > Extensions > Install from Disk**, select the zip file you just downloaded.
## For Blender 4.1
Download the **Addon** version `BioxelNodes_Addon_{version}.zip` from https://github.com/OmooLab/BioxelNodes/releases/latest
In Blender, Edit > Preferences > Add-ons > Install, select the zip file you just downloaded.
The add-on requires a third-party python dependency called SimpleITK, click `Install SimpleITK` button below to install the dependency. After clicking, blender may get stuck, it is downloading and installing, just wait for a moment. After that, click `Reboot Blender` button.
![dependency](assets/installation_dependency.png)
This step may have failed due to network factors, just click "Set PyPI Mirror" to change the mirror.

0
docs/intergration.md Normal file
View File

1
docs/mask.md Normal file
View File

@@ -0,0 +1 @@
This type of node is responsible for culling out the extent of the component that does not need to be rendered, forming the interface between the object and the object, or the surface of the object (i.e., the interface between the object and the air)

View File

@@ -1,20 +0,0 @@
# Future Features
- Support more volumetric data formats (.map, .txm...)
- Generate sections
- More segmentation methods, e.g. AI segmentation.
- Even better shader for volumetric rendering
### Bioxels
Bioxels is based on the RAS coordinate system, Right Aanterior Superior, which was chosen over LPS because it is more compatible with most 3D CG software coordinate systems, and is in line with the 3D artist's understanding of space.
All distances within Bioxels are in Units, and are specified in Meter pre unit. However, when Bioxels is imported into 3D CG software, its size in the software is not scaled by reading the Meter pre unit directly. The reason for this is that many 3D operations in software require that the primtives not be too large or too small.
### Based on OpenVDB
Bioxels is based entirely on OpenVDB for storage and rendering. The main reason for choosing OpenVDB is that as a volumetric data format, it is the fastest way to work with most CG renderers.
### Based on Geometry Nodes
Bioxel Nodes relies on Blender Geometry Nodes to reconstruct and render volumetric data. Node-based operations ensure that the original data is not permanently altered during reconstruction and rendering operations. The fact that the processing is based on Geometry Nodes without any additional dependencies also ensures that Blender can open files without this plugin installed. Look for more support for OpenVDB in GeometryNodes so that Bioxel Nodes can do more in the future.

View File

@@ -1,23 +1,7 @@
# Nodes
## Nodes Design Concept
In order to build a component, the general process is to first use a "Mask Method" node to build the surface of the component based on its layers, and then connect to a "Assign Shader" node to add the material physical properties. Finally, if you need to cut the cross-section, then connect to a "Cut" node. The whole process is shown in the following diagram
![alt text](assets/nodes_concept.png)
A typical example looks like this:
![alt text](assets/nodes_example.png)
The "Mask Method" node tends to be very computationally intensive, and if it consumes too much time, then you can bake it with a "Bake" node after it (but you need to save the Blender file first).
![alt text](assets/nodes_bake.png)
## Mask Methods
This type of node is responsible for culling out the extent of the component that does not need to be rendered, forming the interface between the object and the object, or the surface of the object (i.e., the interface between the object and the air)
### ⬆️ Mask by Threshold
<div class="grid cards" markdown>
@@ -91,8 +75,6 @@ This type of node is responsible for culling out the extent of the component tha
## Shaders
This type of node is responsible for setting material physical properties to the component to reflect the differences in rendering effects of different components.
### 🥚 Solid Shader
<div class="grid cards" markdown>
@@ -192,8 +174,6 @@ This type of node is responsible for setting material physical properties to the
## Cutters
This type of node is responsible for cutting components to present cross-section
### 🪚 Cut
<div class="grid cards" markdown>
@@ -212,25 +192,6 @@ This type of node is responsible for cutting components to present cross-section
</div>
### 🧀 Plane Cutter
<div class="grid cards" markdown>
- ![alt text](assets/nodes_plane-cutter.png)
- Flat plane cutter, cuts off above Z-axis in local.
***
Node Parameter:
- **Origin**, _plane cutter position_
- **Direction**, _plane cutter orientation_
- Guide
- **Show**, _show the cutter guide_
- **Scale**, _scale the cutter guide_
</div>
### 🧀 Plane Object Cutter
@@ -253,8 +214,6 @@ This type of node is responsible for cutting components to present cross-section
## Colors
This type of node is responsible for setting the color properties of the shader
### 🎨 Color Presents
<div class="grid cards" markdown>

45
docs/prepare_data.md Normal file
View File

@@ -0,0 +1,45 @@
# Prepare Your Data
## Support Formats
First thing first, you need to get your volumetric data ready in disk. Bioxel Nodes is developed based on Simple ITK, so theoretically all formats supported by Simple ITK are supported by the addon. If your data is not in the support list, you may do the data conversion first.
| Format | EXT | Test |
| ------ | ---------------------------------------- | ------- |
| DICOM | .dcm, .DCM, .DICOM, .ima, .IMA | ✅ pass |
| BMP | .bmp, .BMP | ✅ pass |
| JPEG | .jpg, .JPG, .jpeg, .JPEG | ✅ pass |
| PNG | .png, .PNG | ✅ pass |
| TIFF | .tif, .TIF, .tiff, .TIFF | ✅ pass |
| Nifti | .nia, .nii, .nii.gz, .hdr, .img, .img.gz | ✅ pass |
| Nrrd | .nrrd, .nhdr | ✅ pass |
| Meta | .mha, .mhd | yet |
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
| VTK | .vtk | yet |
| BioRad | .PIC, .pic | yet |
| Gipl | .gipl, .gipl.gz | yet |
| LSM | .lsm, .LSM | yet |
| MINC | .mnc, .MNC | yet |
| MRC | .mrc, .rec | yet |
| OME | .ome.tiff, .ome.tif | ✅ pass |
| MRC | .mrc, .mrc.gz, .map, .map.gz | ✅ pass |
## Download Data from Internet
If you don't have any volumetric data, you can access open research data from list below.
> Note that just because they are open and available for download does not mean you can use them for anything! Be sure to look at the description of the available scopes from website.
| Source | Object |
| ------------------------------------------------------------------------------------ | ------------------ |
| [MorphoSource](https://www.morphosource.org/) | Open Research Data |
| [Dryad](https://datadryad.org) | Open Research Data |
| [Cell Image Library](http://cellimagelibrary.org/home) | Cells |
| [OpenOrganelle](https://openorganelle.janelia.org/datasets) | Cells |
| [Allen Cell Explorer](https://www.allencell.org/3d-cell-viewer.html) | Cells |
| [EMDB](https://www.ebi.ac.uk/emdb/) | Protein, Viruses |
| [Embodi3D](https://www.embodi3d.com/files/category/37-medical-scans/) | Medical Images |
| [Github](https://github.com/sfikas/medical-imaging-datasets) | Medical Images |
| [NIHR](https://nhsx.github.io/open-source-imaging-data-sets/) | Medical Images |
| [Medical Segmentation Decathlon](http://medicaldecathlon.com/) | Medical Images |
| [Visible Human Project](https://www.nlm.nih.gov/research/visible/visible_human.html) | Medical Images |

0
docs/render.md Normal file
View File

1
docs/shader.md Normal file
View File

@@ -0,0 +1 @@
This type of node is responsible for setting material physical properties to the component to reflect the differences in rendering effects of different components.

54
docs/step_by_step.md Normal file
View File

@@ -0,0 +1,54 @@
# Step by Step
## 1. Import Volumetric Data
**Bioxel Nodes > Import Volumetric Data (Init) > as Scalar**, select the data file, click on **Import as Scalar**. If the data is a sequence of files in directory, just pick **one** file in that directory, not the whole directory or multiple selection of files.
> you can also drag one of the data file directly into the 3D viewport to trigger the importing
![importing](assets/getting-started_importing.png)
It may take a while to read data. After finishing reading, it will pop up a dialog box. Ignore all the options, just click OK (we will talk about them later).
After the importing, the add-on will automatically add the necessary nodes to build the preview.
![masking](assets/getting-started_masking.png)
If there is nothing in the bbox wireframe, select the wireframe in 3D viewport, open the geometry nodes panel, set **Threshold** in the node lower to modify the preview.
## 2. Shading the Structure
The only node "Scalar" is responsible for mask out the unwanted area and generate the structure surface. You need to assign a shader to truly see the structure, connect a **Slime Shader** node (**Add > Bioxel Nodes > Shaders > Slime Shader**) after it. Turned on the cycles rendering to see the result.
![shading](assets/getting-started_shading.png)
You may change the **Color**, **Density** to modify the shader effect. All the parameters are straightforward, you can understand them by changing their values.
## 3. Cut to Cross-section
We often need to cut the structure to see the inside of the volumetric data. With Bioxel Nodes, it can be done by one click: select the structure in 3D viewport, right click, **Bioxel Nodes > Add a Plane Cutter**
. The add-on will add a cut node and a cutter object node to the node graph, which are responsible for the cutting process.
![cutting](assets/getting-started_cutting.png)
> Change the shader from **Slime** to **Universal** to render the cross-section. But it will increase rendering time
## 4. Save Staged Data (Optional)
The VDB cache and the custom nodes are not reachable to other computers if you don't save them. Those staged data should be saved, If you want to give your blender file to someone else, or you want to archive your blender file.
Save your blender file, then just click **Bioxel Nodes > Save Staged Data**
![save staged data](assets/getting-started_save_staged_data.png)
Just click **OK**, after that, both the VDB cache and the custom nodes are restored in relative locations. When you share this file with someone, pack the entire directory so that the resources will be not lost. The other person will be able to open it correctly, regardless of whether they have Bioxel Nodes installed or not.
## 5. Convert to Editable Mesh (Optional)
If you want digital sculpting, 3D printing, exporting to game engine, you should convert to editable mesh first.
Select the wireframe in 3D viewport, right click **Bioxel Nodes > Pick Mesh**.
![to mesh](assets/getting-started_convert_to_mesh.png)
It will create a new object with geometry nodes to refer the mesh. You can apply the geometry node to break the connection.

7
docs/terms.md Normal file
View File

@@ -0,0 +1,7 @@
# Terms
### Bioxels
Bioxels is based on the RAS coordinate system, Right Aanterior Superior, which was chosen over LPS because it is more compatible with most 3D CG software coordinate systems, and is in line with the 3D artist's understanding of space.
All distances within Bioxels are in Units, and are specified in Meter pre unit. However, when Bioxels is imported into 3D CG software, its size in the software is not scaled by reading the Meter pre unit directly. The reason for this is that many 3D operations in software require that the primtives not be too large or too small.

View File

@@ -1,4 +1,4 @@
site_name: Bioxel Nodes Docs
site_name: Bioxel Nodes Handbook
site_url: https://omoolab.github.io/BioxelNodes/
site_author: OmooLab
site_description: Bioxel Nodes is a Blender add-on for scientific volumetric data visualization.
@@ -7,15 +7,28 @@ repo_name: OmooLab/BioxelNodes
repo_url: https://github.com/OmooLab/BioxelNodes
nav:
- BioxelNodes:
- index.md
- Getting Started: getting-started.md
- Features & Options: features.md
- Nodes: nodes.md
- Future Features: misc.md
- index.md
- Getting Started:
- Installation: installation.md
- Prepare Your Data: prepare_data.md
- Step by Step: step_by_step.md
- Concepts & Pipeline: concept.md
- Visualizing any Bio-structure:
- Import & Preprocess: import.md
- Mask Methods: mask.md
- Assgin a Shader: shader.md
- Cut the Component: cut.md
- Set Colors: color.md
- Integration into Other Pipeline: intergration.md
- Render Tips: render.md
- Reference:
- Terms: terms.md
- Nodes: nodes.md
- Benchmark: benchmark.md
theme:
name: material
favicon: assets/logo.png
logo: assets/logo.png
language: en
font:
@@ -57,6 +70,7 @@ theme:
extra:
version:
provider: mike
default: latest
extra_css:
- stylesheets/extra.css

1606
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,29 @@
[tool.poetry]
name = "bioxelnodes"
version = "0.2.0"
version = "0.3.2"
description = ""
authors = ["Ma Nan <icrdr2010@outlook.com>"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = "~=3.10.0"
bpy = "~=4.0"
simpleitk = "^2.3.1"
python = ">=3.11.0,<3.12.dev0"
simpleitk = "2.3.1"
pyometiff = "1.0.0"
mrcfile = "1.5.1"
h5py = "3.11.0"
transforms3d = "0.4.2"
[tool.poetry.group.dev.dependencies]
ipykernel = "^6.25.2"
pytest = "^7.4.2"
pytest-dependency = "^0.5.1"
mkdocs = "^1.5.3"
mkdocs-material = "^9.4.6"
mkdocstrings = { extras = ["python"], version = "^0.23.0" }
mkdocs-click = "^0.8.1"
autopep8 = "^2.0.4"
pytest-cov = "^4.1.0"
mike = "^2.0.0"
bpy = "^4.2.0"
pytest = "^8.3.2"
pytest-dependency = "^0.6.0"
pytest-cov = "^5.0.0"
mkdocs = "^1.6.0"
mkdocs-material = "^9.5.30"
autopep8 = "^2.3.1"
mike = "^2.1.2"
tomlkit = "^0.13.0"
[build-system]
requires = ["poetry-core"]

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
SimpleITK==2.3.1 # Insight Segmentation and Registration Toolkit.
pyometiff==1.0.0 # .ome.tiff
mrcfile==1.5.1 # .map
h5py==3.11.0 # .h5
transforms3d==0.4.2 # transforms