71 Commits

Author SHA1 Message Date
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
Ma Nan
b35c816c25 Merge pull request #17 from OmooLab/feature/dev
fix: cannot convert to mesh in chinese language blender
2024-05-31 13:38:08 +08:00
0e2d6fe1a2 fix: cannot convert to mesh in chinese language blender 2024-05-31 13:36:47 +08:00
Ma Nan
e0c46e95f8 Merge pull request #16 from OmooLab/feature/dev
fix: label join scalar cannot get scalar offset
2024-05-31 10:10:55 +08:00
2528d984ad fix: label join scalar cannot get scalar offset 2024-05-31 10:10:16 +08:00
Ma Nan
56606fd721 Merge pull request #15 from OmooLab/feature/dev
Feature/dev
2024-05-30 09:54:57 +08:00
f21f7d6357 docs: v0.2.0 docs 2024-05-30 09:53:07 +08:00
fa031e63f8 feat: redesign nodes 2024-05-29 11:00:38 +08:00
Ma Nan
e929013183 Merge pull request #13 from OmooLab/feature/dev
Feature/dev
2024-05-14 14:41:01 +08:00
d380cf7036 docs: update docs 2024-05-14 14:39:34 +08:00
05a61527e7 fix: vector as label 2024-05-14 10:57:43 +08:00
Ma Nan
2c80c9ed81 Update README.md 2024-05-14 09:41:30 +08:00
Ma Nan
8e8b176e11 Merge pull request #12 from OmooLab/feature/dev
Feature/dev
2024-05-14 09:40:16 +08:00
c1886f95f8 docs: mkdocs 2024-05-14 09:39:43 +08:00
5e8d12c564 fix: io 2024-05-13 12:09:11 +08:00
1990a16972 feat: add more format support 2024-05-11 10:43:00 +08:00
eeca79e681 chore: change import path 2024-04-26 18:14:34 +08:00
Ma Nan
8b45de4a4d Merge pull request #9 from OmooLab/feature/dev
fix: save all node path not right
2024-04-25 12:13:04 +08:00
2c3d9e5791 fix: save all node path not right 2024-04-25 12:12:43 +08:00
Ma Nan
32b69575b1 Merge pull request #8 from OmooLab/feature/dev
feat: save bioxels, save nodes
2024-04-24 22:48:43 +08:00
d663bfd290 feat: save bioxels, save nodes 2024-04-24 22:48:09 +08:00
Ma Nan
a1aa776725 Merge pull request #7 from OmooLab/feature/dev
Feature/dev
2024-04-24 15:07:16 +08:00
6b18b4e9fa Merge branch 'main' into feature/dev 2024-04-24 15:06:30 +08:00
a579b24f9a feat: add save bioxels function 2024-04-24 14:54:22 +08:00
Ma Nan
8b3ab61467 Update README.md 2024-04-23 15:22:51 +08:00
Ma Nan
084031c496 Merge pull request #5 from OmooLab/feature/dev
fix: get no group input node
2024-04-23 15:12:38 +08:00
15b24b61b5 fix: get no group input node 2024-04-23 15:11:44 +08:00
Ma Nan
81dae0631a Update README.md 2024-04-22 22:48:44 +08:00
Ma Nan
38d2e52c93 Merge pull request #4 from OmooLab/feature/dev
Feature/dev
2024-04-22 22:39:16 +08:00
cf6ccb1743 docs: README add badges 2024-04-22 22:35:57 +08:00
f889b61494 chore: rename packages 2024-04-22 22:35:56 +08:00
Ma Nan
436288df7c Create LICENSE 2024-04-22 17:24:30 +08:00
Ma Nan
7967f144f1 Update README.md 2024-04-22 15:06:46 +08:00
Ma Nan
ba4c6c1dee Merge pull request #3 from OmooLab/feature/dev
docs: README.md
2024-04-22 14:52:30 +08:00
ca9df9065e docs: README.md 2024-04-22 14:51:51 +08:00
118 changed files with 9130 additions and 1333 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/*/**

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"tabWidth": 2,
"overrides": [
{
"files": "*.md",
"options": {
"tabWidth": 4
}
}
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 OmooLab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,3 +1,76 @@
# BioxelNodes
BioxelNodes is a blender add-on for better volume data visualization with cycles.
[中文文档](https://uj6xfhbzp0.feishu.cn/wiki/Qx3VwHuNPimeI8kr6nDcvl1DnHf?from=from_copylink)
# Bioxel Nodes
[![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)
[![Discord](https://img.shields.io/discord/1265129134397587457?style=for-the-badge&logo=discord&label=Discord&labelColor=white&color=black)](https://discord.gg/pYkNyq2TjE)
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.
![cover](https://omoolab.github.io/BioxelNodes/latest/assets/cover.png)
- 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.
**Read the [getting started](https://omoolab.github.io/BioxelNodes/latest/installation) to begin your journey into visualizing volumetric data!**
Welcome to our [discord server](https://discord.gg/pYkNyq2TjE), if you have any problems with this add-on.
## Support Multiple Formats
| 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 |
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
| 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
- Only works with Cycles CPU , Cycles GPU (OptiX), EEVEE
- Section surface cannot be generated when convert to mesh (will be supported soon)
## Compatible to Newer Version
**v0.3.x is not compatible to v0.2.x, Updating this addon may break old files. Read the following carefully before upgradation**
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.
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.
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.
## Roadmap
- Better multi-format import experience
- One-click bake model with texture
- AI Segmentation to Generate Labels

View File

@@ -1,34 +1,19 @@
from .nodes import custom_nodes
from . import auto_load
from . import menu
import bpy
from . import auto_load
from . import menus
bl_info = {
"name": "BiovelNodes",
"author": "Ma Nan",
"description": "",
"blender": (4, 0, 0),
"version": (0, 1, 0),
"location": "File -> Import",
"warning": "",
"category": "Import-Export"
}
auto_load.init()
def register():
auto_load.register()
custom_nodes.register()
menu.create_menu()
bpy.types.WindowManager.bioxelnodes_progress_factor = bpy.props.FloatProperty()
bpy.types.WindowManager.bioxelnodes_progress_text = bpy.props.StringProperty()
menus.add()
def unregister():
try:
menu.remove_menu()
custom_nodes.unregister()
auto_load.unregister()
except RuntimeError:
pass
menus.remove()
auto_load.unregister()

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,7 @@ blender_version = bpy.app.version
modules = None
ordered_classes = None
def init():
global modules
global ordered_classes
@@ -23,6 +24,7 @@ def init():
modules = get_all_submodules(Path(__file__).parent)
ordered_classes = get_ordered_classes_to_register(modules)
def register():
for cls in ordered_classes:
bpy.utils.register_class(cls)
@@ -33,27 +35,36 @@ def register():
if hasattr(module, "register"):
module.register()
def unregister():
for cls in reversed(ordered_classes):
bpy.utils.unregister_class(cls)
try:
bpy.utils.unregister_class(cls)
except Exception as e:
print(f"Fail to unregister {cls}, skiped")
for module in modules:
if module.__name__ == __name__:
continue
if hasattr(module, "unregister"):
module.unregister()
try:
if module.__name__ == __name__:
continue
if hasattr(module, "unregister"):
module.unregister()
except Exception as e:
print(f"Fail to unregister {module}, skiped")
# Import modules
#################################################
def get_all_submodules(directory):
return list(iter_submodules(directory, directory.name))
return list(iter_submodules(directory, __package__))
def iter_submodules(path, package_name):
for name in sorted(iter_submodule_names(path)):
yield importlib.import_module("." + name, package_name)
def iter_submodule_names(path, root=""):
for _, module_name, is_package in pkgutil.iter_modules([str(path)]):
if is_package:
@@ -70,19 +81,24 @@ def iter_submodule_names(path, root=""):
def get_ordered_classes_to_register(modules):
return toposort(get_register_deps_dict(modules))
def get_register_deps_dict(modules):
my_classes = set(iter_my_classes(modules))
my_classes_by_idname = {cls.bl_idname : cls for cls in my_classes if hasattr(cls, "bl_idname")}
my_classes_by_idname = {
cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
deps_dict = {}
for cls in my_classes:
deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname))
deps_dict[cls] = set(iter_my_register_deps(
cls, my_classes, my_classes_by_idname))
return deps_dict
def iter_my_register_deps(cls, my_classes, my_classes_by_idname):
yield from iter_my_deps_from_annotations(cls, my_classes)
yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname)
def iter_my_deps_from_annotations(cls, my_classes):
for value in typing.get_type_hints(cls, {}, {}).values():
dependency = get_dependency_from_annotation(value)
@@ -90,6 +106,7 @@ def iter_my_deps_from_annotations(cls, my_classes):
if dependency in my_classes:
yield dependency
def get_dependency_from_annotation(value):
if blender_version >= (2, 93):
if isinstance(value, bpy.props._PropertyDeferred):
@@ -100,6 +117,7 @@ def get_dependency_from_annotation(value):
return value[1]["type"]
return None
def iter_my_deps_from_parent_id(cls, my_classes_by_idname):
if bpy.types.Panel in cls.__bases__:
parent_idname = getattr(cls, "bl_parent_id", None)
@@ -108,6 +126,7 @@ def iter_my_deps_from_parent_id(cls, my_classes_by_idname):
if parent_cls is not None:
yield parent_cls
def iter_my_classes(modules):
base_types = get_register_base_types()
for cls in get_classes_in_modules(modules):
@@ -115,6 +134,7 @@ def iter_my_classes(modules):
if not getattr(cls, "is_registered", False):
yield cls
def get_classes_in_modules(modules):
classes = set()
for module in modules:
@@ -122,11 +142,13 @@ def get_classes_in_modules(modules):
classes.add(cls)
return classes
def iter_classes_in_module(module):
for value in module.__dict__.values():
if inspect.isclass(value):
yield value
def get_register_base_types():
return set(getattr(bpy.types, name) for name in [
"Panel", "Operator", "PropertyGroup", "FileHandler",
@@ -134,7 +156,7 @@ def get_register_base_types():
"Node", "NodeSocket", "NodeTree",
"UIList", "RenderEngine",
"Gizmo", "GizmoGroup",
])
] if hasattr(bpy.types, name))
# Find order to register to solve dependencies
@@ -151,5 +173,6 @@ def toposort(deps_dict):
sorted_values.add(value)
else:
unsorted.append(value)
deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted}
deps_dict = {value: deps_dict[value] -
sorted_values for value in unsorted}
return sorted_list

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

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

@@ -0,0 +1,388 @@
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',
'.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',
'.ome.tiff', '.ome.tif',
'.mrc', '.mrc.gz', '.map', '.map.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_index(filepath: Path):
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
last_number = digits[::-1]
return int(last_number) if last_number != "" else 0
def get_sequence_name(filepath: Path) -> str:
name = get_file_name(filepath)
index = get_file_index(filepath)
return name.removesuffix(str(index))
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_file_index(f)
file_dict[index] = f
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.1"
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,46 +0,0 @@
import bpy
from .utils import get_bioxels_obj
class ConvertToMesh(bpy.types.Operator):
bl_idname = "bioxelnodes.convert_to_mesh"
bl_label = "Bioxels To Mesh"
bl_description = "Convert Bioxels To Mesh."
bl_options = {'UNDO'}
def execute(self, context):
bioxels_objs = []
for obj in bpy.context.selected_objects:
bioxels_obj = get_bioxels_obj(obj)
if bioxels_obj:
bioxels_objs.append(bioxels_obj)
if len(bioxels_objs)==0:
self.report({"WARNING"}, "Cannot find any bioxels.")
return {'FINISHED'}
for bioxels_obj in bioxels_objs:
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
mesh_obj = bpy.context.active_object
mesh_obj.name = f"{bioxels_obj.parent.name}_Mesh"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = mesh_obj.modifiers[0]
nodes = modifier.node_group.nodes
links = modifier.node_group.links
output_node = nodes.get("Group Output")
object_node = nodes.new("GeometryNodeObjectInfo")
realize_nodes = nodes.new("GeometryNodeRealizeInstances")
object_node.inputs[0].default_value = bioxels_obj
object_node.transform_space = 'RELATIVE'
links.new(object_node.outputs['Geometry'], realize_nodes.inputs[0])
links.new(realize_nodes.outputs[0], output_node.inputs[0])
bpy.ops.object.convert(target='MESH')
bpy.context.object.active_material_index = 1
bpy.ops.object.material_slot_remove()
return {'FINISHED'}

View File

@@ -1 +0,0 @@
from .node_menu import CustomNodes

View File

@@ -0,0 +1 @@
from .menus import CustomNodes

View File

@@ -8,17 +8,19 @@ class CustomNodes():
self,
menu_items,
nodes_file,
class_prefix="CUSTOMNODES_MT_NODES",
root_label='CustomNodes',
root_icon='NONE'
) -> None:
if not Path(nodes_file).is_file():
raise FileNotFoundError(Path(nodes_file).resolve().as_posix())
raise FileNotFoundError(str(Path(nodes_file).resolve()))
self.menu_items = menu_items
self.nodes_file = Path(nodes_file).resolve().as_posix()
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(
@@ -28,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):
@@ -40,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.
@@ -60,6 +62,7 @@ class CustomNodes():
class Menu(bpy.types.Menu):
bl_idname = idname
bl_label = label
nodes_file = self.nodes_file
def draw(self, context):
layout = self.layout
@@ -93,10 +96,13 @@ class CustomNodes():
def _find_item(self, found_items, menu_items, node_type: str):
for item in menu_items:
if item == 'separator':
continue
if item.get("node_type") == node_type:
found_items.append(item)
item_items = item.get('items') if item != 'separator' else None
item_items = item.get('items')
if item_items:
self._find_item(found_items, item_items, node_type)
@@ -105,30 +111,37 @@ 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
op.node_type = node_type
if item:
op = AddCustomNode()
op.nodes_file = self.nodes_file
op.node_type = item['node_type']
op.node_label = item.get('label') or ""
op.node_link = item.get('link') or True
op.node_callback = item.get('node_callback') or ""
return op.add_node(node_tree)
else:
raise RuntimeError("No custom node type found.")
op.node_label = ""
op.node_link = True
op.node_callback = ""
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.customnodes_node_dir = bpy.props.StringProperty(
name="Node Directory",
subtype='DIR_PATH',
default="//"
)
def unregister(self):
bpy.types.NODE_MT_add.remove(self.add_node_menu)
try:
for cls in reversed(self.menu_classes):
bpy.utils.unregister_class(cls)
bpy.types.NODE_MT_add.remove(self.add_node_menu)
except RuntimeError:
pass

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 +0,0 @@
from .preferences import ExternalPackagePreferences

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,
log_dir: str,
requirements_dir: str,
pypi_mirror_provider='Default',
) -> None:
self.pypi_mirror_provider = pypi_mirror_provider
self.log_path: Path = Path(log_dir)
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 = Path(sys.executable).resolve().as_posix()
# 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())
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 and 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,73 +0,0 @@
import bpy
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 ExternalPackagePreferences():
requirements_dir: bpy.props.StringProperty(
name="requirements.txt Directory",
subtype='DIR_PATH',
default=Path(__file__).parent.as_posix()
) # type: ignore
log_dir: bpy.props.StringProperty(
name="Python Log Directory",
subtype='DIR_PATH',
default=Path(Path.home(), '.externalpackage', 'logs').as_posix()
) # 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.")
col_main = layout.column(heading='', align=False)
row_import = col_main.row()
row_import.prop(self, 'pypi_mirror_provider', text='Set 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}'

View File

@@ -1,486 +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 .utils import calc_bbox_verts, extract_last_number, get_bioxels_obj, show_message
from .nodes import custom_nodes
SUPPORT_EXTS = ['.dcm', '.tif', '.tiff', '.png', '.bmp', '', '.jpg']
def get_data_files(filepath: str):
file_path = Path(filepath).resolve()
suffix = file_path.suffix
files = list(file_path.parent.iterdir())
files = [f for f in files if f.is_file() and f.suffix == suffix]
def last_number(file: Path):
return extract_last_number(file.stem)
files.sort(key=last_number)
files = [f.as_posix() for f in files]
return files
def on_orig_spacing_changed(self, context):
if self.auto:
self.auto = False
new_orig_spacing = tuple(self.orig_spacing)
orig_shape = tuple(self.orig_shape)
bioxel_size = float(self.bioxel_size)
self.bioxels_shape = (
int(orig_shape[0] / bioxel_size * new_orig_spacing[0]),
int(orig_shape[1] / bioxel_size * new_orig_spacing[1]),
int(orig_shape[2] / bioxel_size * new_orig_spacing[2]),
)
else:
self.auto = True
def on_bioxel_size_changed(self, context):
if self.auto:
self.auto = False
new_bioxel_size = float(self.bioxel_size)
orig_shape = tuple(self.orig_shape)
orig_spacing = tuple(self.orig_spacing)
self.bioxels_shape = (
int(orig_shape[0] / new_bioxel_size * orig_spacing[0]),
int(orig_shape[1] / new_bioxel_size * orig_spacing[1]),
int(orig_shape[2] / new_bioxel_size * orig_spacing[2]),
)
else:
self.auto = True
def on_info_changed(self, context):
for region in context.area.regions:
if region.type == "UI":
region.tag_redraw()
class ImportDICOMDialog(bpy.types.Operator):
bl_idname = "bioxelnodes.import_dicom_dialog"
bl_label = "Volume Data as Biovels"
bl_description = "Import Volume Data as Biovels (VDB)."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH",
options={'HIDDEN'}
) # type: ignore
bioxels_shape: bpy.props.IntVectorProperty(
name="Bioxels Shape",
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):
if not self.filepath:
self.report({"WARNING"}, "Get no file path.")
return {'CANCELLED'}
if Path(self.filepath).suffix not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not Supported extension.")
return {'CANCELLED'}
file_path = Path(self.filepath).resolve()
name = file_path.parent.name
files = get_data_files(self.filepath)
import SimpleITK as sitk
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("Bioxel Size:", bioxel_size)
print("Bioxels Shape:", bioxels_shape)
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(),
)
try:
image = sitk.DICOMOrient(image, 'RAS')
except:
...
array = sitk.GetArrayFromImage(image)
orig_dtype = str(array.dtype)
print(f"Coverting Dtype {orig_dtype} -> 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_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)
# 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
grid.transform = vdb.createLinearTransform(transfrom.transposed())
grid.name = "value"
preferences = context.preferences.addons[__package__].preferences
vdb_dirpath = Path(preferences.cache_dir, 'VDBs')
vdb_dirpath.mkdir(parents=True, exist_ok=True)
vdb_path = Path(vdb_dirpath, f"{uuid4()}.vdb")
print(f"Storing the VDB file ({vdb_path.as_posix()})...")
vdb.write(vdb_path.as_posix(), grids=[grid])
# Read VDB
print(f"Importing the VDB file to Blender scene...")
bpy.ops.object.volume_import(
filepath=vdb_path.as_posix(), align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
bioxels_obj = bpy.context.active_object
# Set props to VDB object
bioxels_obj.name = f"{name}_Bioxels"
bioxels_obj.data.name = f"{name}_Bioxels"
# 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
)
bioxels_obj.matrix_world = mat_ras2blender @ mat_scene_scale
bioxels_obj.lock_location[0] = True
bioxels_obj.lock_location[1] = True
bioxels_obj.lock_location[2] = True
bioxels_obj.lock_rotation[0] = True
bioxels_obj.lock_rotation[1] = True
bioxels_obj.lock_rotation[2] = True
bioxels_obj.lock_scale[0] = True
bioxels_obj.lock_scale[1] = True
bioxels_obj.lock_scale[2] = True
bioxels_obj.hide_select = True
bioxels_obj['bioxels'] = True
bpy.ops.node.new_geometry_nodes_modifier()
node_tree = bioxels_obj.modifiers[0].node_group
# Wrapper a Container
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), bioxels_shape)
for index, vert in enumerate(container_obj.data.vertices):
co = transfrom @ mathutils.Vector(bbox_verts[index])
co = mat_ras2blender @ co
co = mat_scene_scale @ co
vert.co = co
bioxels_obj.parent = container_obj
container_obj.name = name
container_obj.data.name = name
container_obj.visible_camera = False
container_obj.visible_diffuse = False
container_obj.visible_glossy = False
container_obj.visible_transmission = False
container_obj.visible_volume_scatter = False
container_obj.visible_shadow = False
container_obj.display_type = 'WIRE'
container_obj['bioxels_container'] = True
container_obj['scene_scale'] = scene_scale
container_obj['bioxel_size'] = bioxel_size
container_obj['bioxels_max'] = bioxels_max
container_obj['bioxels_min'] = bioxels_min
container_obj['bioxels_offset'] = bioxels_offset
container_obj['bioxels_shape'] = bioxels_shape
bpy.ops.object.modifier_add(type='NODES')
container_obj.modifiers[0].node_group = node_tree
container_obj.modifiers[0].show_in_editmode = False
container_obj.modifiers[0].show_viewport = False
container_obj.modifiers[0].show_render = False
# Create BioxelNodes to VDB object
print(f"Creating BioxelNodes to the VDB...")
if self.do_add_segmentnode:
nodes = node_tree.nodes
links = node_tree.links
input_node = nodes.get("Group Input")
output_node = nodes.get("Group Output")
segment_node = custom_nodes.add_node(nodes, 'BioxelNodes_Segment')
links.new(input_node.outputs[0], segment_node.inputs[0])
links.new(segment_node.outputs[0], output_node.inputs[0])
# Change render setting for better result
if self.do_change_render_setting:
bpy.context.scene.render.engine = 'CYCLES'
bpy.context.scene.cycles.volume_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 64
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'}
class ReadDICOM(bpy.types.Operator):
bl_idname = "bioxelnodes.read_dicom"
bl_label = "Volume Data as Biovels"
bl_description = "Import Volume Data as Biovels (VDB)."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
ok: bpy.props.BoolProperty(
default=True
) # type: ignore
def execute(self, context):
files = get_data_files(self.filepath)
import SimpleITK as sitk
image = sitk.ReadImage(files)
print("Collecting Meta Data...")
print("Original Shape:", image.GetSize())
print("Original Spacing:", image.GetSpacing())
print("Original Origin:", image.GetOrigin())
print("Original Direction:", image.GetDirection())
bpy.ops.bioxelnodes.import_dicom_dialog(
'INVOKE_DEFAULT',
filepath=self.filepath,
orig_shape=image.GetSize(),
orig_spacing=image.GetSpacing()
)
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:
return {'CANCELLED'}
if Path(self.filepath).suffix not in SUPPORT_EXTS:
return {'CANCELLED'}
show_message('Reading image data, it may take a while',
'Please be patient...')
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
class ImportDICOM(bpy.types.Operator):
bl_idname = "bioxelnodes.import_dicom"
bl_label = "Volume Data as Biovels"
bl_description = "Import Volume Data as Biovels (VDB)."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
def execute(self, context):
bpy.ops.bioxelnodes.read_dicom(
'INVOKE_DEFAULT',
filepath=self.filepath
)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class BIOXELNODES_FH_ImportDicom(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportDicom"
bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.read_dicom"
bl_file_extensions = ".dcm"
@classmethod
def poll_drop(cls, context):
return (context.area and context.area.type == 'VIEW_3D')
class ExportVDB(bpy.types.Operator):
bl_idname = "bioxelnodes.export_vdb"
bl_label = "Bioxels as VDB"
bl_description = "Export Bioxels original VDB data."
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".vdb"
def invoke(self, context, event):
bioxels_obj = None
for obj in bpy.context.selected_objects:
bioxels_obj = get_bioxels_obj(obj)
break
if bioxels_obj:
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
else:
self.report({"WARNING"}, "Cannot find any bioxels.")
return {'CANCELLED'}
def execute(self, context):
bioxels_obj = None
for obj in bpy.context.selected_objects:
bioxels_obj = get_bioxels_obj(obj)
break
filepath = f"{self.filepath.split('.')[0]}.vdb"
output_path: Path = Path(filepath).resolve()
source_path: Path = Path(bioxels_obj.data.filepath).resolve()
# print('output_path', output_path)
# print('source_path', source_path)
shutil.copy(source_path, output_path)
return {'FINISHED'}

View File

@@ -1,33 +0,0 @@
import bpy
from .convert import ConvertToMesh
from .io import ExportVDB, ImportDICOM
def TOPBAR_FILE_IMPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ImportDICOM.bl_idname)
def TOPBAR_FILE_EXPORT(self, context):
layout = self.layout
layout.separator()
layout.operator(ExportVDB.bl_idname)
def VIEW3D_OBJECT(self, context):
layout = self.layout
layout.operator(ConvertToMesh.bl_idname)
layout.separator()
def create_menu():
bpy.types.TOPBAR_MT_file_import.append(TOPBAR_FILE_IMPORT)
bpy.types.TOPBAR_MT_file_export.append(TOPBAR_FILE_EXPORT)
bpy.types.VIEW3D_MT_object_context_menu.prepend(VIEW3D_OBJECT)
def remove_menu():
bpy.types.TOPBAR_MT_file_import.remove(TOPBAR_FILE_IMPORT)
bpy.types.TOPBAR_MT_file_export.remove(TOPBAR_FILE_EXPORT)
bpy.types.VIEW3D_MT_object_context_menu.remove(VIEW3D_OBJECT)

197
bioxelnodes/menus.py Normal file
View File

@@ -0,0 +1,197 @@
import bpy
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)
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
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(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 BioxelNodesOutlinerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_OUTLINER"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
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(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.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(ExportVolumetricData.bl_idname,
text="Bioxel Layer (.vdb)")
def VIEW3D_OBJECT(self, context):
layout = self.layout
layout.separator()
layout.menu(BioxelNodesView3DMenu.bl_idname, icon="FILE_VOLUME")
def OUTLINER_OBJECT(self, context):
layout = self.layout
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.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.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,146 +1,228 @@
from pathlib import Path
from .custom_nodes import CustomNodes
import bpy
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)
container_obj = bioxels_obj.parent
if bioxels_obj:
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)
container_obj = bioxels_obj.parent
if bioxels_obj:
node.inputs.get('{target_prop}').default_value = container_obj.get('{source_prop}')
else:
print('Cannot find any bioxels.')
"""
return callback_str
from .customnodes import CustomNodes
NODE_FILE = "BioxelNodes_4.2"
MENU_ITEMS = [
{
'label': 'Present',
'icon': 'MESH_MONKEY',
'items': [
{
'label': 'Segment',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_Segment',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
},
{
'label': 'Anatomy',
'icon': 'OUTLINER_OB_META',
'node_type': 'BioxelNodes_Anatomy',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
}
]
},
{
'label': 'Segment',
'label': 'Methods',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Segment by Level',
'icon': 'AREA_JOIN',
'node_type': 'BioxelNodes_SegmentByLevel',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
'label': 'Mask by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'node_type': 'BioxelNodes_MaskByThreshold',
'node_description': ''
},
{
'label': 'Segment by Range',
'icon': 'AREA_SWAP',
'node_type': 'BioxelNodes_SegmentByRange',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
'label': 'Mask by Range',
'icon': 'IPO_CONSTANT',
'node_type': 'BioxelNodes_MaskByRange',
'node_description': ''
},
{
'label': 'Segment by Layers',
'icon': 'OUTLINER_OB_FORCE_FIELD',
'node_type': 'BioxelNodes_SegmentsByLayers',
'node_description': '',
'node_callback': "\n".join([
set_prop_to_node_factory("bioxel_size", "Detail Size"),
set_prop_to_node_factory("bioxels_offset", "Offset"),
])
'label': 'Mask by Label',
'icon': 'MESH_CAPSULE',
'node_type': 'BioxelNodes_MaskByLabel',
'node_description': ''
}
]
},
{
'label': 'Shader',
'label': 'Shaders',
'icon': 'SHADING_RENDERED',
'items': [
{
'label': 'Segment Shader',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_SegmentShader',
'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': 'Slicer',
'icon': 'CLIPUV_DEHLT',
'label': 'Colors',
'icon': 'COLOR',
'items': [
{
'label': 'RAS Slicer',
'icon': 'EMPTY_DATA',
'node_type': 'BioxelNodes_RAS-Slicer',
'label': 'Color Presets',
'icon': 'COLOR',
'node_type': 'BioxelNodes_ColorPresets',
'node_description': ''
},
{
'label': 'Axis Slicer',
'icon': 'OUTLINER_OB_EMPTY',
'node_type': 'BioxelNodes_AxisSlicer',
'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': 'Box Slicer',
'icon': 'MOD_WIREFRAME',
'node_type': 'BioxelNodes_BoxSlicer',
'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/"assets/Nodes.blend",
root_label='BioxelNodes',
nodes_file=Path(__file__).parent / f"assets/Nodes/{NODE_FILE}.blend",
class_prefix="BIOXELNODES_MT_NODES",
root_label='Bioxel Nodes',
root_icon="FILE_VOLUME",
)
def register():
custom_nodes.register()
def unregister():
custom_nodes.unregister()

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"

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

@@ -0,0 +1,790 @@
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
FH_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA',
'.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'
'.ome.tiff', '.ome.tif',
'.mrc', '.mrc.gz', '.map', '.map.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_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
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
read_as = "scalar"
def execute(self, context):
container_objs = get_container_objs_from_selection()
if len(container_objs) > 0:
bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT',
filepath=self.filepath,
directory=self.directory,
container_obj_name=container_objs[0].name,
read_as=self.read_as)
else:
bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT',
filepath=self.filepath,
directory=self.directory,
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(FH_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"
bl_description = "Import Volumetric Data as Layer"
bl_options = {'UNDO'}
meta = None
thread = None
_timer = None
progress: bpy.props.FloatProperty(name="Progress",
options={"SKIP_SAVE"},
default=1) # type: ignore
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
directory: bpy.props.StringProperty(subtype='DIR_PATH') # type: ignore
container_obj_name: bpy.props.StringProperty() # 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 extension.")
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 and not self.directory:
return {'CANCELLED'}
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
# 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:
context.window_manager.invoke_props_dialog(self,
width=400,
title="Which series to import?")
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'}
self.execute(context)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
layout.label(
text='Detect multi-series in DICOM, pick one')
layout.prop(self, "series_id")
def get_sequence_sources(self, context):
items = [("-1", "None (Static)", "")]
orig_shape = tuple(self.orig_shape)
if self.frame_count > 1:
items.append(("0", f"Frame (Get {self.frame_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count > 1:
items.append(("4", f"Channel (Get {self.channel_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count == 1:
items.append(("1", f"X (Get {orig_shape[0]} frames)", ""))
items.append(("2", f"Y (Get {orig_shape[1]} frames)", ""))
items.append(("3", f"Z (Get {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):
if self.read_as == "label":
volume_dtype = "Label"
elif self.read_as == "scalar":
volume_dtype = "Scalar"
title = f"As {volume_dtype} Opitons (Add to Container: {self.container_obj_name})" \
if self.container_obj_name != "" else f"As {volume_dtype} Options (Init a Container)"
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_layer: bpy.props.BoolProperty(
name="Save Layer VDB Cache",
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_layer:
fails = []
for layer in get_all_layer_objs():
try:
save_layer(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_layer")
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_layers"
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,23 +1,23 @@
import bpy
from pathlib import Path
from .external_package import ExternalPackagePreferences
class BioxelNodesPreferences(bpy.types.AddonPreferences, ExternalPackagePreferences):
class BioxelNodesPreferences(bpy.types.AddonPreferences):
bl_idname = __package__
cache_dir: bpy.props.StringProperty(
name="Python Log Directory",
name="Set Cache Directory",
subtype='DIR_PATH',
default=Path(Path.home(), '.bioxelnodes').as_posix()
default=str(Path(Path.home(), '.bioxelnodes'))
) # type: ignore
do_change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting",
default=True,
) # type: ignore
def draw(self, context):
layout = self.layout
layout.label(text="Configuration")
layout.prop(self, 'cache_dir', text='Set Cache Directory')
# ExternalPackagePreferences Config
self.requirements_dir = Path(__file__).parent.as_posix()
super().draw(context)
layout.prop(self, 'cache_dir')
layout.prop(self, "do_change_render_setting")

5
bioxelnodes/props.py Normal file
View File

@@ -0,0 +1,5 @@
import bpy
class BIOXELNODES_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore

View File

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

View File

@@ -1,135 +1,32 @@
import bpy
import mathutils
from pathlib import Path
import shutil
def show_message(message="", title="Message Box", icon='INFO'):
def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True):
source = Path(source_path)
target = Path(dir_path)
def draw(self, context):
self.layout.label(text=message)
# Check if the source exists
if not source.exists():
raise FileNotFoundError
bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
# 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 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 get_bioxels_obj(current_obj):
bioxels_obj = None
if current_obj.get('bioxels_container'):
bioxels_values = []
for obj in bpy.data.objects:
if obj.parent == current_obj and obj.get('bioxels'):
bioxels_values.append(obj)
if len(bioxels_values) > 0:
bioxels_obj = bioxels_values[0]
elif current_obj.get('bioxels') and current_obj.parent:
if current_obj.parent.get('bioxels_container'):
bioxels_obj = current_obj
return bioxels_obj
def extract_last_number(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 int(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.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/assets/gallery.png Normal file

Binary file not shown.

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.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
docs/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/assets/nodes_bake.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
docs/assets/nodes_cut.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 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

41
docs/import.md Normal file
View File

@@ -0,0 +1,41 @@
## First Time Import Volume Data
File > Import > Volume Data as Bioxel Layer
### Resample
![alt text](assets/features_resample.png)
Sometimes the original data is too big, or the spacing in the original data is not reasonable, you can modify the `Bioxel Size` and `Original Spacing` to adjust the Shape of the layer.
A bioxel is like a pixel, the larger the `Bioxel Size`, the lower the resolution of the image, Original Spacing will be read from the original data record, but sometimes the image doesn't have original spacing, you may need to input it manually to get the correct shape.
### Read as
- as Scalar
![alt text](assets/features_as-scalar.png)
In some cases the environment value is higher than the value of the target object, you can check `Invert Scalar` to adjust the value for better result.
- as Labels
Many AI segmentation task datasets, provide segmentation data, which are often an integer value representing a layer of segmentation labels. You can set it to `Labels` to load them.
- _as Vector (Not implemented yet)_
- _as Color (Not implemented yet)_
### Others
![alt text](assets/features_import-others.png)
`Scene Scale` determines how many units of length in the Blender world correspond to one unit of length in the Bioxel world. Since Blender defaults to meters, and the default size of blender primitives are around 1 blender unit. Therefore `Scene Scale` set to 0.01 is appropriate.
`Orient to RAS` determines whether the layer should be converted to the RAS coordinate system. Regardless of the format of the medical image data, the coordinate system is mostly the LPS coordinate system. Bioxel, however, are in the RAS coordinate system and therefore need to be transformed in most cases.
## 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.

76
docs/index.md Normal file
View File

@@ -0,0 +1,76 @@
[中文文档](https://uj6xfhbzp0.feishu.cn/wiki/Qx3VwHuNPimeI8kr6nDcvl1DnHf?from=from_copylink)
# Bioxel Nodes
[![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)
[![Discord](https://img.shields.io/discord/1265129134397587457?style=for-the-badge&logo=discord&label=Discord&labelColor=white&color=black)](https://discord.gg/pYkNyq2TjE)
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.
![cover](https://omoolab.github.io/BioxelNodes/latest/assets/cover.png)
- 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.
**Read the [getting started](https://omoolab.github.io/BioxelNodes/latest/installation) to begin your journey into visualizing volumetric data!**
Welcome to our [discord server](https://discord.gg/pYkNyq2TjE), if you have any problems with this add-on.
## Support Multiple Formats
| 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 |
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 | ✅ pass |
| 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
- Only works with Cycles CPU , Cycles GPU (OptiX), EEVEE
- Section surface cannot be generated when convert to mesh (will be supported soon)
## Compatible to Newer Version
**v0.3.x is not compatible to v0.2.x, Updating this addon may break old files. Read the following carefully before upgradation**
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.
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.
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.
## 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.

Some files were not shown because too many files have changed in this diff Show More