102 Commits

Author SHA1 Message Date
Ma Nan
66aafb41bd Merge pull request #50 from OmooLab/feature/dev
Feature/dev
2024-10-30 13:32:19 +08:00
2c844c01ec Merge branch 'main' of https://github.com/OmooLab/BioxelNodes into feature/dev 2024-10-30 13:30:50 +08:00
e5be3ad5db feat: better shader in eevee
feat: add more control to transform node
feat: add env light to reduse volume shadow in eevee
2024-10-30 13:29:53 +08:00
Ma Nan
29b302531b Merge pull request #49 from OmooLab/feature/dev
feat: improve eevee render
2024-10-29 15:15:14 +08:00
3ca7c2ac5a feat: improve eevee render 2024-10-29 15:14:31 +08:00
Ma Nan
32138c1da6 Merge pull request #48 from OmooLab/feature/dev
feat: add help menu
2024-10-10 12:56:44 +08:00
86fc18ab69 feat: add help menu
feat: better surface topo
fix: cutter node invert not work
2024-10-10 12:54:51 +08:00
Ma Nan
928b15c722 Merge pull request #46 from OmooLab/feature/dev
fix: the as color button's text is incorrectly written as "as label"
2024-09-27 14:55:27 +08:00
487cea4662 fix: the as color button's text is incorrectly written as "as label"
feat: speed up convertion when as label
feat: add new "Extrude Transform" node
2024-09-27 14:53:47 +08:00
Ma Nan
92f85ff1f3 Merge pull request #45 from OmooLab/feature/dev
fix: relocate cannot work on missing layer
2024-09-19 12:28:35 +08:00
63c593f09a fix: relocate cannot work on missing layer
fix: import fail if label are all zero
fix: add cutter name to node
fix: cannot append node lib file on NAS (UNC path)
fix: join component cause disappear
feat: smooth label
feat: better topo on cutout by range
2024-09-19 12:27:39 +08:00
Ma Nan
3c5ff64afd Merge pull request #44 from OmooLab/feature/dev
Feature/dev
2024-09-13 17:12:41 +08:00
0ebc6dafbd fix cannot extract wire 2024-09-13 17:00:00 +08:00
c22ab2c85b Merge branch 'main' into feature/dev 2024-09-13 16:43:51 +08:00
133702619e fix: join component cause missing
feat: extract mesh from node
2024-09-13 16:42:18 +08:00
Ma Nan
e384798184 Merge pull request #43 from OmooLab/feature/dev
fix: cannot decode utf-8 issue
2024-09-11 15:36:19 +08:00
80da400891 fix: cannot decode utf-8 issue 2024-09-11 15:17:00 +08:00
Ma Nan
48700b3001 Merge pull request #42 from OmooLab/feature/dev
Feature/dev
2024-09-11 13:16:51 +08:00
b865d9d78d docs: fix image lost 2024-09-11 13:15:44 +08:00
20c6393344 docs: v1.0.0 2024-09-11 13:10:50 +08:00
Ma Nan
7c71417e5d Merge pull request #41 from OmooLab/feature/dev
Feature/dev
2024-09-04 12:42:33 +08:00
655b19bc7c feat: improving the user experience 2024-09-03 17:53:26 +08:00
051df670a5 feat: boost render speed 2024-08-16 13:56:14 +08:00
9e885865f7 feat: layer list system 2024-08-14 14:16:40 +08:00
Ma Nan
21e0b85662 Merge pull request #38 from OmooLab/fix-import
fix: color ramp not fit
2024-08-07 09:50:54 +08:00
38fb888f00 fix: color ramp not fit
fix: skip DICOM series option not reset.
fix: combine label only combine 2 layers
2024-08-07 09:42:25 +08:00
Ma Nan
2429eef5e0 Merge pull request #36 from OmooLab/feature/dev
Feature/dev
2024-08-05 15:54:19 +08:00
2563f398da chore: update version 2024-08-05 15:53:03 +08:00
0cad35e2da fix: fail to read all sequence in folder with same name prefix 2024-08-05 14:13:48 +08:00
65edf40164 feat: better drag and drop 2024-08-05 13:48:24 +08:00
c3f88b7846 fix: layer cache cannot be saved via save stage data 2024-08-05 11:27:50 +08:00
Ma Nan
088d9c1edb Merge pull request #35 from OmooLab/feature/dev
fix: macos-arm64 cannot install
2024-08-02 11:35:18 +08:00
a6ec769a9b fix: macos-arm64 cannot install
fix: better layer fill smooth filter
2024-08-02 11:34:35 +08:00
Ma Nan
7430fbf3ef Merge pull request #34 from OmooLab/feature/dev
BREAKING CHANGE: not support 4.1 any more
2024-08-01 15:51:48 +08:00
ce397b35aa feat: more time sequence choice
fix: contrast formulation cause leak of high value
fix: empty series_id cause fail to read
chore: restructure folder
chore!: change layer vars
BREAKING CHANGE: not support 4.1 any more
2024-08-01 15:50:41 +08:00
Ma Nan
caf184c327 Merge pull request #33 from OmooLab/feature/dev
chore: update version
2024-07-23 12:32:06 +08:00
488b05f062 chore: update version 2024-07-23 12:31:33 +08:00
Ma Nan
2447a47269 Merge pull request #32 from OmooLab/feature/dev
docs: update discord link
2024-07-23 12:29:36 +08:00
9380342b4f docs: update discord link 2024-07-23 12:27:52 +08:00
Ma Nan
9b36e9d911 Merge pull request #31 from OmooLab/feature/dev
Feature/dev
2024-07-23 12:05:18 +08:00
c9a961d5e8 fix some import issue 2024-07-23 12:04:30 +08:00
77878888ca fix: missing add .ima to DICOM_EXTS 2024-07-22 09:56:20 +08:00
Ma Nan
50e8564027 Merge pull request #30 from OmooLab/feature/dev
docs: update README
2024-07-22 09:06:50 +08:00
f07ac1c70c docs: update README 2024-07-22 09:05:52 +08:00
Ma Nan
b6e7ae0c8b Merge pull request #29 from OmooLab/feature/dev
feat: support more format
2024-07-22 08:51:59 +08:00
854af4c3bc feat: support more format
feat: support .mrc .map
feat: support .ome.tif
feat: improved user experience for importing
feat: better contrast algorithm
2024-07-22 08:51:12 +08:00
Ma Nan
0eaba42f27 Merge pull request #28 from OmooLab/feature/dev
feat: support 4d time data
2024-07-15 14:26:54 +08:00
b2791e3aae fix: eevee specular too high
feat: add pie cutter
feat: support 4d time data
chora: change operators name
docs: update README.md
2024-07-15 14:25:51 +08:00
Ma Nan
2eb2358589 Merge pull request #27 from OmooLab/feature/dev
feat: add primitive cutter
2024-07-10 12:45:43 +08:00
8b862b3fe0 feat: add primitive cutter
fix: eevee shadow
fix: save node toggle not work
fix: color over 1.0 fail to render
2024-07-10 12:45:04 +08:00
Ma Nan
5922269f7e Merge pull request #26 from OmooLab/feature/dev
feat: dramatic reduction in rendering time 🚀
2024-07-09 15:22:20 +08:00
99ce5c28d0 feat: dramatic reduction in rendering time 🚀
feat: add more support to  eevee render
fix: fail to add utils node inflate
fix: file import button missing
chore: change operator names
BREAKING CHANGE: remove support to blender 4.0
BREAKING CHANGE: remove support to gpu render with cuda
2024-07-09 15:20:26 +08:00
Ma Nan
637fc758a7 Merge pull request #25 from OmooLab/feature/dev
chore: change version
2024-07-04 15:06:10 +08:00
f90bf5c532 chore: change version 2024-07-04 15:05:22 +08:00
Ma Nan
f2d1d02c48 Merge pull request #24 from OmooLab/feature/dev
fix: misspell of "scalar"
2024-07-04 15:03:58 +08:00
d04f100dfd fix: misspell of "scalar"
fix: not change raw value of data
2024-07-04 15:02:17 +08:00
Ma Nan
734c4d1cd9 Merge pull request #23 from OmooLab/feature/dev
fix: fail to import membrane shader
2024-07-04 13:50:11 +08:00
78aba5394f fix: fail to import membrane shader 2024-07-04 13:49:18 +08:00
Ma Nan
4fc89beaa0 Merge pull request #22 from OmooLab/feature/dev
fix: fail to import nd_image
2024-07-04 13:43:17 +08:00
fb2a3c9c23 fix: fail to import nd_image
fix: membrane shader
2024-07-04 13:41:52 +08:00
Ma Nan
bc6f88996c Merge pull request #21 from OmooLab/feature/dev
feat: rewrite resize
2024-07-04 13:06:24 +08:00
1db63dd3db feat: rewrite resize
add more nodes
2024-07-04 13:05:46 +08:00
Ma Nan
467d368a76 Merge pull request #19 from OmooLab/feature/dev
feat: support eevee
2024-06-12 15:24:06 +08:00
134d8e382b feat: support eevee 2024-06-12 15:23:09 +08:00
Ma Nan
fefc9285e1 Merge pull request #18 from OmooLab/feature/dev
Feature/dev
2024-06-05 09:40:33 +08:00
322b84cc70 docs: update cover img
add extension description
2024-06-05 09:39:15 +08:00
f3be5d8660 feat: support blender 4.2 extension 2024-06-05 09:11:22 +08:00
470cdab4f6 fix: logging utf-8 cannot decode 2024-06-03 12:36:23 +08:00
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 10756 additions and 1605 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

6
.gitignore vendored
View File

@@ -131,4 +131,8 @@ blendcache_*
*.ipynb
.secrets
.secrets
.vdb
!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,66 @@
# BioxelNodes
BioxelNodes is a blender add-on for better volume data visualization with cycles.
[中文文档](https://uj6xfhbzp0.feishu.cn/wiki/LPKEwjooSivxjskWHlCcQznjnNf?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 |
| ------ | ---------------------------------------- |
| DICOM | .dcm, .DCM, .DICOM, .ima, .IMA |
| BMP | .bmp, .BMP |
| JPEG | .jpg, .JPG, .jpeg, .JPEG |
| PNG | .png, .PNG |
| TIFF | .tif, .TIF, .tiff, .TIFF |
| Nifti | .nia, .nii, .nii.gz, .hdr, .img, .img.gz |
| Nrrd | .nrrd, .nhdr |
| HDF5 | .hdf, .h4, .hdf4, .he2, .h5, .hdf5, .he5 |
| OME | .ome.tiff, .ome.tif |
| MRC | .mrc, .mrc.gz, .map, .map.gz |
## 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)
## Roadmap
- Better multi-format import experience
- One-click bake model with texture
- AI Segmentation to Generate Labels

View File

@@ -1,34 +1,26 @@
from .nodes import custom_nodes
from . import auto_load
from . import menu
import bpy
from .props import BIOXELNODES_LayerListUL
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(
default=1.0)
bpy.types.WindowManager.bioxelnodes_progress_text = bpy.props.StringProperty()
bpy.types.WindowManager.bioxelnodes_layer_list_UL = bpy.props.PointerProperty(
type=BIOXELNODES_LayerListUL)
menus.add()
def unregister():
try:
menu.remove_menu()
custom_nodes.unregister()
auto_load.unregister()
except RuntimeError:
pass
menus.remove()
del bpy.types.WindowManager.bioxelnodes_progress_factor
del bpy.types.WindowManager.bioxelnodes_progress_text
del bpy.types.WindowManager.bioxelnodes_layer_list_UL
auto_load.unregister()

Binary file not shown.

Binary file not shown.

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

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

@@ -0,0 +1,152 @@
import copy
import numpy as np
from . import scipy
from . import scipy as ndi
# 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, smooth: int = 0):
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, :, :, :]
if smooth > 0:
mask_frame = scipy.median_filter(mask_frame.astype(np.float32),
mode="nearest",
size=smooth)
# 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[:, :, :]
if smooth > 0:
mask_frame = scipy.median_filter(mask_frame.astype(np.float32),
mode="nearest",
size=smooth)
# 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, smooth: int = 0, 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 = data[f, :, :, :, :]
if smooth > 0:
frame = scipy.median_filter(frame.astype(np.float32),
mode="nearest",
size=smooth)
factors = np.divide(self.shape, shape)
zoom_factors = [1 / f for f in factors]
order = 0 if frame.dtype == bool else 1
frame = ndi.zoom(frame,
zoom_factors+[1.0],
mode="nearest",
grid_mode=False,
order=order)
if smooth > 0:
frame = frame.astype(self.dtype)
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
mat_scale = transforms3d.zooms.zfdir2aff(factors[0])
self.affine = np.dot(self.affine, mat_scale)

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

@@ -0,0 +1,436 @@
from pathlib import Path
import numpy as np
# 3rd-party
import SimpleITK as sitk
from pyometiff import OMETIFFReader
import mrcfile
import transforms3d
"""
Convert any volumetric data to 3D numpy array with order TXYZC
"""
SUPPORT_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA',
'.ome.tiff', '.ome.tif',
'.tif', '.TIF', '.tiff', '.TIFF',
'.mrc', '.mrc.gz', '.map', '.map.gz',
'.bmp', '.BMP',
'.png', '.PNG',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.PIC', '.pic',
'.gipl', '.gipl.gz',
'.lsm', '.LSM',
'.mnc', '.MNC',
'.mrc', '.rec',
'.mha', '.mhd',
'.hdf', '.h4', '.hdf4', '.he2', '.h5', '.hdf5', '.he5',
'.nia', '.nii', '.nii.gz', '.hdr', '.img', '.img.gz',
'.nrrd', '.nhdr',
'.vtk',
'.gz']
OME_EXTS = ['.ome.tiff', '.ome.tif',
'.tif', '.TIF', '.tiff', '.TIFF']
MRC_EXTS = ['.mrc', '.mrc.gz', '.map', '.map.gz']
DICOM_EXTS = ['', '.dcm', '.DCM', '.DICOM', '.ima', '.IMA']
SEQUENCE_EXTS = ['.bmp', '.BMP',
'.jpg', '.JPG', '.jpeg', '.JPEG',
'.tif', '.TIF', '.tiff', '.TIFF',
'.png', '.PNG',
'.mrc']
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:
suffix = filepath.suffix
return "" if len(suffix) > 5 else suffix
def get_filename(filepath: Path):
ext = get_ext(filepath)
return filepath.name.removesuffix(ext)
def get_filename_parts(filepath: Path) -> str:
def has_digits(s):
return any(char.isdigit() for char in s)
name = get_filename(filepath)
parts = name.replace(".", " ").replace("_", " ").split(" ")
skip_prefixs = ["CH", "ch", "channel"]
number_part = None
number_part_i = None
for i, part in enumerate(parts[::-1]):
if has_digits(part):
if not any([part.startswith(prefix) for prefix in skip_prefixs]):
number_part = part
number_part_i = len(parts)-i
break
if number_part is None:
return name, "", ""
prefix_parts = parts[:number_part_i-1]
prefix_parts_count = sum([len(part)+1 for part in prefix_parts])
digits = ""
suffix = ""
# Iterate through the characters in reverse order
started = False
for char in number_part[::-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
else:
suffix += char
digits = digits[::-1]
prefix_parts_count += len(number_part) - \
len(digits) - len(suffix)
# Reverse the digits string to get the correct order
prefix = name[:prefix_parts_count]
suffix = name[prefix_parts_count+len(digits):]
return prefix, digits, suffix
def get_file_no_digits_name(filepath: Path) -> str:
prefix, digits, suffix = get_filename_parts(filepath)
prefix = remove_end_str(prefix, "_")
prefix = remove_end_str(prefix, ".")
prefix = remove_end_str(prefix, "-")
prefix = remove_end_str(prefix, " ")
return prefix + suffix
def get_file_index(filepath: Path) -> int:
prefix, digits, suffix = get_filename_parts(filepath)
return int(digits) if digits != "" else 0
def collect_sequence(filepath: Path):
file_dict = {}
for f in filepath.parent.iterdir():
if f.is_file() \
and get_ext(filepath) == get_ext(f) \
and get_file_no_digits_name(filepath) == get_file_no_digits_name(f):
index = get_file_index(f)
file_dict[index] = f
# reomve isolated seq file
for key in file_dict.copy().keys():
if not file_dict.get(key+1) \
and not file_dict.get(key-1):
del file_dict[key]
file_dict = dict(sorted(file_dict.items()))
sequence = [str(f) for f in file_dict.values()]
if len(sequence) == 0:
sequence = [str(filepath)]
return sequence
def remove_end_str(string: str, end: str):
while string.endswith(end) and len(string) > 0:
string = string.removesuffix(end)
return string
def parse_volumetric_data(data_file: str, series_id="", progress_callback=None):
"""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.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_no_digits_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_no_digits_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(" ")
stirng.encode('utf-8')
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_file_no_digits_name(data_path)
else:
itk_image = sitk.ReadImage(data_path)
name = get_filename(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,270 @@
from ast import literal_eval
from pathlib import Path
import bpy
from ..constants import LATEST_NODE_LIB_PATH, NODE_LIB_DIRPATH, VERSIONS
from ..utils import get_cache_dir
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]
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 get_layer_prop_value(layer_obj: bpy.types.Object, prop_name: str):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
prop = layer_node.inputs.get(prop_name)
if prop is None:
return None
value = prop.default_value
if type(value).__name__ == "bpy_prop_array":
value = tuple(value)
return tuple([int(v) for v in value]) \
if prop in ["shape"] else value
elif type(value).__name__ == "str":
return str(value)
elif type(value).__name__ == "float":
value = float(value)
return round(value, 2) \
if prop in ["bioxel_size"] else value
elif type(value).__name__ == "int":
value = int(value)
return value
else:
return value
def get_layer_name(layer_obj):
return get_layer_prop_value(layer_obj, "name")
def get_layer_kind(layer_obj):
return get_layer_prop_value(layer_obj, "kind")
def get_layer_label(layer_obj):
name = get_layer_name(layer_obj)
# kind = get_layer_kind(layer_obj)
label = f"{name}"
if is_missing_layer(layer_obj):
return "**MISSING**" + label
elif is_temp_layer(layer_obj):
return "* " + label
else:
return label
def is_missing_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
return not cache_filepath.is_file()
def is_temp_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
cache_dirpath = Path(get_cache_dir())
return cache_dirpath in cache_filepath.parents
def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
layer_node.inputs[prop].default_value = value
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.data.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.data.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def add_driver(target, target_prop, var_sources, expression):
driver = target.driver_add(target_prop)
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):
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target, target_prop, drivers, expression)
def read_file_prop(content: str):
props = {}
for line in content.split("\n"):
line = line.replace(" ", "")
p = line.split("=")[0]
if p != "":
v = line.split("=")[-1]
props[p] = v
return props
def write_file_prop(props: dict):
lines = []
for p, v in props.items():
lines.append(f"{p} = {v}")
return "\n".join(lines)
def set_file_prop(prop, value):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
props[prop] = value
bpy.data.texts["BioxelNodes"].clear()
bpy.data.texts["BioxelNodes"].write(write_file_prop(props))
def get_file_prop(prop):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
return props.get(prop)
def get_node_version():
node_version = get_file_prop("node_version")
return literal_eval(node_version) if node_version else None
def is_incompatible():
node_version = get_node_version()
if node_version is None:
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
return True
else:
addon_version = VERSIONS[0]["node_version"]
if node_version[0] != addon_version[0]\
or node_version[1] != addon_version[1]:
return True
return False
def get_node_lib_path(node_version):
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_filename = f"BioxelNodes_{version_str}.blend"
return Path(NODE_LIB_DIRPATH,
lib_filename).resolve()
def local_lib_not_updated():
addon_version = VERSIONS[0]["node_version"]
addon_lib_path = LATEST_NODE_LIB_PATH
use_local = False
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
lib = node_group.library
if lib:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path != addon_lib_path:
use_local = True
break
not_update = get_node_version() != addon_version
return use_local and not_update
def get_output_node(node_group):
try:
output_node = get_nodes_by_type(node_group,
'NodeGroupOutput')[0]
except:
output_node = node_group.nodes.new("NodeGroupOutput")
return output_node

View File

@@ -0,0 +1,167 @@
import bpy
from ..bioxel.container import Container
from bpy_extras.io_utils import axis_conversion
from mathutils import Matrix, Vector
from .layer import Layer, layer_to_obj, obj_to_layer
from .common import (get_container_layer_objs,
get_layer_prop_value,
get_nodes_by_type, get_output_node,
move_node_to_node)
from .node import add_node_to_graph
from ..utils import get_use_link
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 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 add_layers(layers: list[Layer],
container_obj: bpy.types.Object,
cache_dir: str):
node_group = container_obj.modifiers[0].node_group
output_node = get_output_node(node_group)
for i, layer in enumerate(layers):
layer_obj = layer_to_obj(layer, container_obj, cache_dir)
fetch_node = add_node_to_graph("FetchLayer",
node_group,
use_link=get_use_link())
fetch_node.label = get_layer_prop_value(layer_obj, "name")
fetch_node.inputs[0].default_value = layer_obj
if len(output_node.inputs[0].links) == 0:
node_group.links.new(fetch_node.outputs[0],
output_node.inputs[0])
move_node_to_node(fetch_node, output_node, (-600, 0))
else:
move_node_to_node(fetch_node, output_node, (0, -100 * (i+1)))
return container_obj
def container_to_obj(container: Container,
scene_scale: float,
step_size: 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.lock_location[0] = True
container_obj.lock_location[1] = True
container_obj.lock_location[2] = True
container_obj.lock_rotation[0] = True
container_obj.lock_rotation[1] = True
container_obj.lock_rotation[2] = True
container_obj.lock_scale[0] = True
container_obj.lock_scale[1] = True
container_obj.lock_scale[2] = True
container_obj['bioxel_container'] = True
container_obj["scene_scale"] = scene_scale
container_obj["step_size"] = step_size
modifier = container_obj.modifiers.new("GeometryNodes", 'NODES')
node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree')
node_group.interface.new_socket(name="Output",
in_out="OUTPUT",
socket_type="NodeSocketGeometry")
modifier.node_group = node_group
node_group.nodes.new("NodeGroupOutput")
container_obj = add_layers(container.layers,
container_obj=container_obj,
cache_dir=cache_dir)
return container_obj

View File

@@ -0,0 +1,211 @@
import random
import re
import bpy
import numpy as np
import pyopenvdb as vdb
from pathlib import Path
from uuid import uuid4
from ..bioxel.layer import Layer
from ..utils import get_use_link
from .node import add_node_to_graph
from .common import (get_layer_prop_value,
move_node_between_nodes)
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
if grid["layer_kind"] in ['label', 'scalar']:
data_shape = grid["data_shape"]
else:
data_shape = tuple(list(grid["data_shape"]) + [3])
data_frame = np.ndarray(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
if grid["layer_kind"] in ['label', 'scalar']:
data_shape = grid["data_shape"]
else:
data_shape = tuple(list(grid["data_shape"]) + [3])
data = np.ndarray(data_shape, np.float32)
grid.copyToArray(data)
data = np.expand_dims(data, axis=0) # expend frame
name = get_layer_prop_value(layer_obj, "name") \
or metadata["layer_name"]
kind = get_layer_prop_value(layer_obj, "kind") \
or metadata["layer_kind"]
affine = metadata["layer_affine"]
dtype = get_layer_prop_value(layer_obj, "dtype") \
or metadata.get("data_dtype") or "float32"
offset = get_layer_prop_value(layer_obj, "offset") \
or 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
}
layer_display_name = f"{container_obj.name}_{layer.name}"
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):
if layer.kind in ['label', 'scalar']:
grid = vdb.FloatGrid()
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
else:
# color
grid = vdb.Vec3SGrid()
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)
else:
if layer.kind in ['label', 'scalar']:
grid = vdb.FloatGrid()
grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32))
else:
# color
grid = vdb.Vec3SGrid()
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]
layer_data = bpy.data.volumes.new(layer_display_name)
layer_data.render.space = 'WORLD'
scene_scale = container_obj.get("scene_scale") or 0.01
step_size = container_obj.get("step_size") or 1
layer_data.render.step_size = scene_scale * step_size
layer_data.sequence_mode = 'REPEAT'
layer_data.filepath = str(cache_filepaths[0])
if layer.frame_count > 1:
layer_data.is_sequence = True
layer_data.frame_duration = layer.frame_count
else:
layer_data.is_sequence = False
layer_obj = bpy.data.objects.new(layer_display_name, layer_data)
layer_obj['bioxel_layer'] = True
print(f"Creating Node for {layer.name}...")
modifier = layer_obj.modifiers.new("GeometryNodes", 'NODES')
node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree')
node_group.interface.new_socket(name="Cache",
in_out="INPUT",
socket_type="NodeSocketGeometry")
node_group.interface.new_socket(name="Layer",
in_out="OUTPUT",
socket_type="NodeSocketGeometry")
modifier.node_group = node_group
layer_node = add_node_to_graph("_Layer",
node_group,
use_link=get_use_link())
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['unique'].default_value = random.uniform(0, 1)
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['frame_count'].default_value = layer.frame_count
layer_node.inputs['channel_count'].default_value = layer.channel_count
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 = node_group.nodes.new("NodeGroupInput")
output_node = node_group.nodes.new("NodeGroupOutput")
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])
layer_obj.parent = container_obj
return layer_obj

View File

@@ -0,0 +1,71 @@
from pathlib import Path
import bpy
from .common import get_file_prop, set_file_prop
from ..exceptions import NoFound
from ..constants import LATEST_NODE_LIB_PATH, VERSIONS
def get_node_tree(node_type: str, use_link=True):
# unannotate below for local debug in node lib file.
# node_group = bpy.data.node_groups[node_type]
# return node_group
# added node is always from latest node version
addon_version = VERSIONS[0]["node_version"]
addon_lib_path = LATEST_NODE_LIB_PATH
if get_file_prop("node_version") is None:
set_file_prop("node_version", addon_version)
local_lib = None
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
lib = node_group.library
if lib:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path != addon_lib_path:
local_lib = lib.filepath
break
# local lib first
lib_path_str = local_lib or str(addon_lib_path)
with bpy.data.libraries.load(lib_path_str,
link=use_link,
relative=True) as (data_from, data_to):
data_to.node_groups = [n for n in data_from.node_groups
if n == node_type]
node_tree = data_to.node_groups[0]
if node_tree is None:
raise NoFound('No custom node found')
return node_tree
def assign_node_tree(node, node_tree):
node.node_tree = node_tree
node.width = 200.0
node.name = node_tree.name
return node
def add_node_to_graph(node_name: str, node_group, node_label=None, use_link=True):
node_type = f"BioxelNodes_{node_name}"
node_label = node_label or node_name
# Deselect all nodes first
for node in node_group.nodes:
if node.select:
node.select = False
node_tree = get_node_tree(node_type, use_link)
node = node_group.nodes.new("GeometryNodeGroup")
assign_node_tree(node, node_tree)
node.label = node_label
node.show_options = False
return node

View File

@@ -0,0 +1,37 @@
schema_version = "1.0.0"
id = "bioxelnodes"
version = "1.0.6"
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:GPL-3.0-or-later']
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.3.0-cp311-cp311-win_amd64.whl",
"./wheels/lxml-5.3.0-cp311-cp311-macosx_10_9_arm64.whl",
"./wheels/lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/lxml-5.3.0-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"

251
bioxelnodes/constants.py Normal file
View File

@@ -0,0 +1,251 @@
from pathlib import Path
VERSIONS = [{"label": "Latest", "node_version": (1, 0, 6)},
{"label": "v0.3.x", "node_version": (0, 3, 3)},
{"label": "v0.2.x", "node_version": (0, 2, 9)}]
NODE_LIB_DIRPATH = Path(Path(__file__).parent,
"assets/Nodes").resolve()
LATEST_NODE_LIB_PATH = lib_path = Path(NODE_LIB_DIRPATH,
"BioxelNodes_latest.blend").resolve()
COMPONENT_OUTPUT_NODES = [
"CutoutByThreshold",
"CutoutByRange",
"CutoutByHue",
"JoinComponent",
"SetProperties",
"SetColor",
"SetColorByColor",
"SetColorByColor",
"SetColorByRamp2",
"SetColorByRamp3",
"SetColorByRamp4",
"SetColorByRamp5",
"Cut",
]
MENU_ITEMS = [
{
'label': 'Component',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Cutout by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'name': 'CutoutByThreshold',
'description': ''
},
{
'label': 'Cutout by Range',
'icon': 'IPO_CONSTANT',
'name': 'CutoutByRange',
'description': ''
},
{
'label': 'Cutout by Hue',
'icon': 'COLOR',
'name': 'CutoutByHue',
'description': ''
},
"separator",
{
'label': 'To Surface',
'icon': 'MESH_DATA',
'name': 'ToSurface',
'description': ''
},
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'name': 'JoinComponent',
'description': ''
},
{
'label': 'Slice',
'icon': 'TEXTURE',
'name': 'Slice',
'description': ''
}
]
},
{
'label': 'Property',
'icon': 'PROPERTIES',
'items': [
{
'label': 'Set Properties',
'icon': 'PROPERTIES',
'name': 'SetProperties',
'description': ''
},
"separator",
{
'label': 'Set Color',
'icon': 'IPO_SINE',
'name': 'SetColor',
'description': ''
},
{
'label': 'Set Color by Color',
'icon': 'IPO_QUINT',
'name': 'SetColorByColor',
'description': ''
},
{
'label': 'Set Color by Ramp 2',
'icon': 'IPO_QUAD',
'name': 'SetColorByRamp2',
'description': ''
},
{
'label': 'Set Color by Ramp 3',
'icon': 'IPO_CUBIC',
'name': 'SetColorByRamp3',
'description': ''
},
{
'label': 'Set Color by Ramp 4',
'icon': 'IPO_QUART',
'name': 'SetColorByRamp4',
'description': ''
},
{
'label': 'Set Color by Ramp 5',
'icon': 'IPO_QUINT',
'name': 'SetColorByRamp5',
'description': ''
}
]
},
{
'label': 'Surface',
'icon': 'MESH_DATA',
'items': [
{
'label': 'Membrane Shader',
'icon': 'NODE_MATERIAL',
'name': 'AssignMembraneShader',
'description': ''
},
{
'label': 'Solid Shader',
'icon': 'SHADING_SOLID',
'name': 'AssignSolidShader',
'description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_DATA_META',
'name': 'AssignSlimeShader',
'description': ''
},
]
},
{
'label': 'Transform',
'icon': 'EMPTY_AXIS',
'items': [
{
'label': 'Transform',
'icon': 'EMPTY_AXIS',
'name': 'Transform',
'description': ''
},
{
'label': 'Transform Parent',
'icon': 'ORIENTATION_PARENT',
'name': 'TransformParent',
'description': ''
},
{
'label': 'ReCenter',
'icon': 'PROP_CON',
'name': 'ReCenter',
'description': ''
},
{
'label': 'Extract Transform',
'icon': 'EMPTY_AXIS',
'name': 'ExtractTransform',
'description': ''
},
]
},
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'items': [
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'name': 'Cut',
'description': ''
},
"separator",
{
'label': 'Primitive Cutter',
'icon': 'MOD_LINEART',
'name': 'PrimitiveCutter',
'description': ''
},
{
'label': 'Object Cutter',
'icon': 'MESH_PLANE',
'name': 'ObjectCutter',
'description': ''
}
]
},
{
'label': 'Extra',
'icon': 'MODIFIER',
'items': [
{
'label': 'Fetch Mesh',
'icon': 'OUTLINER_OB_MESH',
'name': 'FetchMesh',
'description': ''
},
{
'label': 'Fetch Volume',
'icon': 'OUTLINER_OB_VOLUME',
'name': 'FetchVolume',
'description': ''
},
{
'label': 'Fetch Shape Wire',
'icon': 'FILE_VOLUME',
'name': 'FetchShapeWire',
'description': ''
},
{
'label': 'Fetch Bbox Wire',
'icon': 'MESH_CUBE',
'name': 'FetchBboxWire',
'description': ''
},
"separator",
{
'label': 'Inflate',
'icon': 'OUTLINER_OB_META',
'name': 'Inflate',
'description': ''
},
{
'label': 'Smooth',
'icon': 'MOD_SMOOTH',
'name': 'Smooth',
'description': ''
},
{
'label': 'Remove Small Island',
'icon': 'FORCE_LENNARDJONES',
'name': 'RemoveSmallIsland',
'description': ''
}
]
}
]

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

@@ -1,134 +0,0 @@
import bpy
from pathlib import Path
from .nodes import AddCustomNode
class CustomNodes():
def __init__(
self,
menu_items,
nodes_file,
root_label='CustomNodes',
root_icon='NONE'
) -> None:
if not Path(nodes_file).is_file():
raise FileNotFoundError(Path(nodes_file).resolve().as_posix())
self.menu_items = menu_items
self.nodes_file = Path(nodes_file).resolve().as_posix()
self.root_label = root_label
self.root_icon = root_icon
menu_classes = []
self._create_menu_class(
items=menu_items,
label=root_label,
menu_classes=menu_classes
)
self.menu_classes = menu_classes
idname = f"CUSTOMNODES_MT_NODES__{root_label.replace(' ', '').upper()}"
def add_node_menu(self, context):
if ('GeometryNodeTree' == bpy.context.area.spaces[0].tree_type):
layout = self.layout
layout.separator()
layout.menu(idname, icon=root_icon)
self.add_node_menu = add_node_menu
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 = f"{idname_namespace}_{label.replace(' ', '').upper()}"
# create submenu class if item is menu.
for item in items:
item_items = item.get('items') if item != 'separator' else None
if item_items:
menu_class = self._create_menu_class(
menu_classes=menu_classes,
items=item_items,
label=item.get('label') or 'CustomNodes',
icon=item.get('icon') or 0,
idname_namespace=idname
)
item['menu_class'] = menu_class
# create menu class
class Menu(bpy.types.Menu):
bl_idname = idname
bl_label = label
def draw(self, context):
layout = self.layout
for item in items:
# print(item)
if item == "separator":
layout.separator()
elif item.get('menu_class'):
layout.menu(
item.get('menu_class').bl_idname,
icon=item.get('icon') or 'NONE'
)
else:
op = layout.operator(
'customnodes.add_custom_node',
text=item.get('label'),
icon=item.get('icon') or 'NONE'
)
op.nodes_file = nodes_file
op.node_type = item['node_type']
op.node_label = item.get('label') or ""
op.node_description = item.get(
'node_description') or "Add Custom Node."
op.node_link = item.get('link') or True
op.node_callback = item.get('node_callback') or ""
menu_classes.append(Menu)
return Menu
def _find_item(self, found_items, menu_items, node_type: str):
for item in menu_items:
if item.get("node_type") == node_type:
found_items.append(item)
item_items = item.get('items') if item != 'separator' else None
if item_items:
self._find_item(found_items, item_items, node_type)
def find_item(self, node_type: str):
found_items = []
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):
item = self.find_item(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.")
def register(self):
for cls in self.menu_classes:
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(self.add_node_menu)
def unregister(self):
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

@@ -1,120 +0,0 @@
import bpy
class AddCustomNode():
nodes_file: bpy.props.StringProperty(
name="nodes_file",
subtype='FILE_PATH',
default=""
) # type: ignore
node_type: bpy.props.StringProperty(
name='node_type',
description='',
default='',
subtype='NONE',
maxlen=0
) # type: ignore
node_label: bpy.props.StringProperty(
name='node_label',
default=''
) # type: ignore
node_description: bpy.props.StringProperty(
name="node_description",
description="",
default="Add custom node group.",
subtype="NONE"
) # type: ignore
node_link: bpy.props.BoolProperty(
name='node_link',
default=True
) # type: ignore
node_callback: bpy.props.StringProperty(
name='node_callback',
default=''
) # type: ignore
@classmethod
def description(cls, context, properties):
return properties.node_description
def assign_node_tree(self, node):
node.node_tree = bpy.data.node_groups[self.node_type]
node.width = 200.0
node.label = self.node_label or self.node_type
node.name = self.node_type
if self.node_callback:
exec(self.node_callback)
return node
def get_node_tree(self, node_type, node_link):
# try to get node from current file if exists
node_tree = bpy.data.node_groups.get(node_type)
# if not exists, get it from asset file.
if not node_tree:
bpy.ops.wm.append(
'EXEC_DEFAULT',
directory=f"{self.nodes_file}/NodeTree",
filename=node_type,
link=node_link,
use_recursive=True
)
node_tree = bpy.data.node_groups.get(node_type)
if node_tree:
# self.recursive_append_material(node_tree)
return node_tree
else:
raise RuntimeError('No custom node found')
def recursive_append_material(self, node_tree):
for child in node_tree.nodes:
material_socket = child.inputs.get('Material')
if material_socket:
print(material_socket.default_value)
try:
self.recursive_append_material(child.node_tree)
except:
...
def add_node(self, node_tree):
self.get_node_tree(self.node_type, self.node_link)
node = node_tree.new("GeometryNodeGroup")
self.assign_node_tree(node)
return node
class CUSTOMNODES_OT_Add_Custom_Node(bpy.types.Operator, AddCustomNode):
bl_idname = "customnodes.add_custom_node"
bl_label = "Add Custom Node"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
self.get_node_tree(self.node_type, self.node_link)
# intended to be called upon button press in the node tree
prev_context = bpy.context.area.type
bpy.context.area.type = 'NODE_EDITOR'
# actually invoke the operator to add a node to the current node tree
# use_transform=True ensures it appears where the user's mouse is and is currently
# being moved so the user can place it where they wish
bpy.ops.node.add_node(
'INVOKE_DEFAULT',
type='GeometryNodeGroup',
use_transform=True
)
bpy.context.area.type = prev_context
node = bpy.context.active_node
self.assign_node_tree(node)
return {"FINISHED"}

22
bioxelnodes/exceptions.py Normal file
View File

@@ -0,0 +1,22 @@
class CancelledByUser(Exception):
def __init__(self):
message = 'Cancelled by user'
super().__init__(message)
class NoContent(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
class NoFound(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
class Incompatible(Exception):
def __init__(self, message):
super().__init__(message)
self.message = 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)

435
bioxelnodes/menus.py Normal file
View File

@@ -0,0 +1,435 @@
import bpy
from .constants import MENU_ITEMS, VERSIONS
from .node_menu import NodeMenu
from .bioxelutils.common import (get_container_obj,
get_container_layer_objs, get_layer_label,
get_layer_prop_value, get_node_version, is_incompatible)
from .operators.layer import (FetchLayer, RelocateLayer, RetimeLayer, RenameLayer,
RemoveSelectedLayers, SaveSelectedLayersCache,
ResampleLayer, SignScalar, CombineLabels,
FillByLabel, FillByThreshold, FillByRange)
from .operators.container import (AddLocator, AddSlicer, ContainerProps,
SaveContainerLayersCache, RemoveContainerMissingLayers,
SaveContainer, LoadContainer,
AddPieCutter, AddPlaneCutter,
AddCylinderCutter, AddCubeCutter, AddSphereCutter,
ExtractBboxWire, ExtractMesh, ExtractShapeWire,
ExtractNodeMesh, ExtractNodeBboxWire, ExtractNodeShapeWire)
from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor)
from .operators.misc import (AddEeveeEnv, CleanTemp, Help,
ReLinkNodeLib, RemoveAllMissingLayers,
RenderSettingPreset, SaveAllLayersCache,
SaveNodeLib, SliceViewer)
class IncompatibleMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_INCOMPATIBLE"
bl_label = "Bioxel Nodes"
def draw(self, context):
tip_text = "please downgrade addon version."
node_version = get_node_version()
if node_version:
version_str = "v"+".".join([str(i) for i in list(node_version)])
tip_text = f"please downgrade addon version to {version_str}."
layout = self.layout
layout.label(text="Incompatible node version detected.")
layout.separator()
layout.label(
text="If you still want to edit this file with bioxel nodes, ")
layout.label(text=tip_text)
layout.separator()
layout.label(text="If this file is archived, "
"please relink node library, ")
layout.label(text="check if it still works, "
"then save node library.")
layout.separator()
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.operator(SaveNodeLib.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.menu(RenderSettingMenu.bl_idname)
layout.separator()
layout.operator(Help.bl_idname,
icon=Help.bl_icon)
class FetchLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_ADD_LAYER"
bl_label = "Fetch Layer"
def draw(self, context):
container_obj = get_container_obj(bpy.context.active_object)
layer_objs = get_container_layer_objs(container_obj)
layout = self.layout
for layer_obj in layer_objs:
op = layout.operator(FetchLayer.bl_idname,
text=get_layer_label(layer_obj))
op.layer_obj_name = layer_obj.name
class ExtractFromContainerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_PICK"
bl_label = "Extract from Container"
def draw(self, context):
layout = self.layout
layout.operator(ExtractMesh.bl_idname)
layout.operator(ExtractShapeWire.bl_idname)
layout.operator(ExtractBboxWire.bl_idname)
class AddCutterMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_CUTTERS"
bl_label = "Add a Cutter"
def draw(self, context):
layout = self.layout
layout.operator(AddPlaneCutter.bl_idname,
icon=AddPlaneCutter.bl_icon, text="Plane Cutter")
layout.operator(AddCylinderCutter.bl_idname,
icon=AddCylinderCutter.bl_icon, text="Cylinder Cutter")
layout.operator(AddCubeCutter.bl_idname,
icon=AddCubeCutter.bl_icon, text="Cube Cutter")
layout.operator(AddSphereCutter.bl_idname,
icon=AddSphereCutter.bl_icon, text="Sphere Cutter")
layout.operator(AddPieCutter.bl_idname,
icon=AddPieCutter.bl_icon, text="Pie Cutter")
class ImportLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_IMPORTLAYER"
bl_label = "Import Volumetric Data (Init)"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="as Scalar")
layout.operator(ImportAsLabel.bl_idname,
text="as Label")
layout.operator(ImportAsColor.bl_idname,
text="as Color")
class AddLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_ADDLAYER"
bl_label = "Import Volumetric Data (Add)"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="as Scalar")
layout.operator(ImportAsLabel.bl_idname,
text="as Label")
layout.operator(ImportAsColor.bl_idname,
text="as Color")
class ModifyLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_MODIFYLAYER"
bl_label = "Modify Layer"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(SignScalar.bl_idname,
icon=SignScalar.bl_icon)
layout.operator(FillByThreshold.bl_idname,
icon=FillByThreshold.bl_icon)
layout.operator(FillByRange.bl_idname,
icon=FillByRange.bl_icon)
layout.operator(FillByLabel.bl_idname,
icon=FillByLabel.bl_icon)
layout.operator(CombineLabels.bl_idname,
icon=CombineLabels.bl_icon)
class ReLinkNodeLibMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_RELINK"
bl_label = "Relink Node Library"
def draw(self, context):
layout = self.layout
for index, version in enumerate(VERSIONS):
op = layout.operator(ReLinkNodeLib.bl_idname,
text=version["label"])
op.index = index
class RenderSettingMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_RENDER"
bl_label = "Render Setting Presets"
def draw(self, context):
layout = self.layout
for k, v in RenderSettingPreset.PRESETS.items():
op = layout.operator(RenderSettingPreset.bl_idname,
text=v)
op.preset = k
class DangerZoneMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_DANGER"
bl_label = "Danger Zone"
def draw(self, context):
layout = self.layout
layout.operator(CleanTemp.bl_idname)
class BioxelNodesTopbarMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_TOPBAR"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
layout.menu(ImportLayerMenu.bl_idname,
icon=ImportLayerMenu.bl_icon)
layout.operator(LoadContainer.bl_idname)
layout.separator()
layout.operator(SaveAllLayersCache.bl_idname)
layout.operator(RemoveAllMissingLayers.bl_idname)
layout.separator()
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.operator(SaveNodeLib.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.operator(AddEeveeEnv.bl_idname)
layout.menu(RenderSettingMenu.bl_idname)
layout.separator()
layout.operator(Help.bl_idname,
icon=Help.bl_icon)
def TOPBAR(self, context):
layout = self.layout
if is_incompatible():
layout.menu(IncompatibleMenu.bl_idname)
else:
layout.menu(BioxelNodesTopbarMenu.bl_idname)
class NodeHeadMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_NODE_HEAD"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
def draw(self, context):
layout = self.layout
layout.menu(AddLayerMenu.bl_idname,
icon=AddLayerMenu.bl_icon)
layout.operator(SaveContainer.bl_idname)
layout.separator()
layout.operator(ContainerProps.bl_idname)
layout.menu(ExtractFromContainerMenu.bl_idname)
layout.separator()
layout.operator(SaveContainerLayersCache.bl_idname,
icon=SaveContainerLayersCache.bl_icon)
layout.operator(RemoveContainerMissingLayers.bl_idname)
layout.separator()
layout.operator(AddLocator.bl_idname,
icon=AddLocator.bl_icon)
layout.operator(AddSlicer.bl_idname,
icon=AddSlicer.bl_icon)
layout.menu(AddCutterMenu.bl_idname)
layout.separator()
layout.menu(FetchLayerMenu.bl_idname)
class NodeContextMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_NODE_CONTEXT"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
def draw(self, context):
layout = self.layout
layout.operator(ExtractNodeMesh.bl_idname)
layout.operator(ExtractNodeShapeWire.bl_idname)
layout.operator(ExtractNodeBboxWire.bl_idname)
layout.separator()
layout.operator(SaveSelectedLayersCache.bl_idname,
icon=SaveSelectedLayersCache.bl_icon)
layout.operator(RemoveSelectedLayers.bl_idname)
layout.separator()
layout.operator(RenameLayer.bl_idname,
icon=RenameLayer.bl_icon)
layout.operator(RetimeLayer.bl_idname)
layout.operator(RelocateLayer.bl_idname)
layout.separator()
layout.operator(ResampleLayer.bl_idname,
icon=ResampleLayer.bl_icon)
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 NODE_CONTEXT(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
if not is_geo_nodes or not is_container:
return
layout = self.layout
layout.separator()
layout.menu(NodeContextMenu.bl_idname)
def NODE_HEAD(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
if not is_geo_nodes or not is_container:
return
layout = self.layout
layout.separator()
layout.menu(NodeHeadMenu.bl_idname)
def NODE_PROP(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
self.bl_label = "Group"
if not is_geo_nodes or not is_container:
return
if container_obj.modifiers[0].node_group != context.space_data.edit_tree:
return
self.bl_label = "Bioxel Nodes"
layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL
layer_list = layer_list_UL.layer_list
layer_list.clear()
for layer_obj in get_container_layer_objs(container_obj):
layer_item = layer_list.add()
layer_item.label = get_layer_label(layer_obj)
layer_item.obj_name = layer_obj.name
layer_item.info_text = "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}"
for prop in ["kind",
"bioxel_size",
"shape",
"frame_count",
"channel_count",
"min", "max"]])
layout = self.layout
layout.label(text="Layer List")
split = layout.row()
split.template_list(listtype_name="BIOXELNODES_UL_layer_list",
list_id="layer_list",
dataptr=layer_list_UL,
propname="layer_list",
active_dataptr=layer_list_UL,
active_propname="layer_list_active",
item_dyntip_propname="info_text",
rows=20)
# sidebar = split.column(align=True)
# sidebar.menu(AddLayerMenu.bl_idname,
# icon=AddLayerMenu.bl_icon, text="")
# sidebar.operator(SaveContainerLayersCache.bl_idname,
# icon=SaveContainerLayersCache.bl_icon, text="")
# sidebar.operator(RemoveContainerMissingLayers.bl_idname,
# icon=RemoveContainerMissingLayers.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(SaveSelectedLayersCache.bl_idname,
# icon=SaveSelectedLayersCache.bl_icon, text="")
# sidebar.operator(RemoveSelectedLayers.bl_idname,
# icon=RemoveSelectedLayers.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(RenameLayer.bl_idname,
# icon=RenameLayer.bl_icon, text="")
# sidebar.operator(ResampleLayer.bl_idname,
# icon=ResampleLayer.bl_icon, text="")
# sidebar.operator(RetimeLayer.bl_idname,
# icon=RetimeLayer.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(SignScalar.bl_idname,
# icon=SignScalar.bl_icon, text="")
# sidebar.operator(FillByThreshold.bl_idname,
# icon=FillByThreshold.bl_icon, text="")
# sidebar.operator(FillByRange.bl_idname,
# icon=FillByRange.bl_icon, text="")
# sidebar.operator(FillByLabel.bl_idname,
# icon=FillByLabel.bl_icon, text="")
# sidebar.separator()
# layout.separator()
def VIEW3D_TOPBAR(self, context):
layout = self.layout
layout.operator(SliceViewer.bl_idname)
node_menu = NodeMenu(
menu_items=MENU_ITEMS
)
def add():
node_menu.register()
bpy.types.VIEW3D_HT_header.append(VIEW3D_TOPBAR)
bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR)
bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PROP)
bpy.types.NODE_MT_editor_menus.append(NODE_HEAD)
bpy.types.NODE_MT_context_menu.append(NODE_CONTEXT)
def remove():
bpy.types.VIEW3D_HT_header.remove(VIEW3D_TOPBAR)
bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR)
bpy.types.NODE_PT_node_tree_properties.remove(NODE_PROP)
bpy.types.NODE_MT_editor_menus.remove(NODE_HEAD)
bpy.types.NODE_MT_context_menu.remove(NODE_CONTEXT)
node_menu.unregister()

87
bioxelnodes/node_menu.py Normal file
View File

@@ -0,0 +1,87 @@
import bpy
from .operators.node import AddNode
class NodeMenu():
def __init__(
self,
menu_items,
) -> None:
self.menu_items = menu_items
self.class_prefix = f"BIOXELNODES_MT"
root_label = "Bioxel Nodes"
menu_classes = []
self._create_menu_class(
items=menu_items,
label=root_label,
menu_classes=menu_classes
)
self.menu_classes = menu_classes
idname = f"{self.class_prefix}_{root_label.replace(' ', '').upper()}"
icon = "FILE_VOLUME"
def drew_menu(self, context):
if (bpy.context.area.spaces[0].tree_type == 'GeometryNodeTree'):
layout = self.layout
layout.separator()
layout.menu(idname,
icon=icon)
self.drew_menu = drew_menu
def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None):
idname_namespace = idname_namespace or self.class_prefix
idname = f"{idname_namespace}_{label.replace(' ', '').upper()}"
# create submenu class if item is menu.
for item in items:
item_items = item.get('items') if item != 'separator' else None
if item_items:
menu_class = self._create_menu_class(
menu_classes=menu_classes,
items=item_items,
label=item.get('label') or 'CustomNodes',
icon=item.get('icon') or 0,
idname_namespace=idname
)
item['menu_class'] = menu_class
# create menu class
class Menu(bpy.types.Menu):
bl_idname = idname
bl_label = label
def draw(self, context):
layout = self.layout
for item in items:
if item == "separator":
layout.separator()
elif item.get('menu_class'):
layout.menu(
item.get('menu_class').bl_idname,
icon=item.get('icon') or 'NONE'
)
else:
op = layout.operator(
AddNode.bl_idname,
text=item.get('label'),
icon=item.get('icon') or 'NONE'
)
op.node_name = item['name']
op.node_label = item.get('label') or ""
op.node_description = item.get(
'node_description') or ""
menu_classes.append(Menu)
return Menu
def register(self):
for cls in self.menu_classes:
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(self.drew_menu)
def unregister(self):
bpy.types.NODE_MT_add.remove(self.drew_menu)
for cls in reversed(self.menu_classes):
bpy.utils.unregister_class(cls)

View File

@@ -1,146 +0,0 @@
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
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',
'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': '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': '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': 'Shader',
'icon': 'SHADING_RENDERED',
'items': [
{
'label': 'Segment Shader',
'icon': 'OUTLINER_OB_VOLUME',
'node_type': 'BioxelNodes_SegmentShader',
'node_description': ''
}
]
},
{
'label': 'Slicer',
'icon': 'CLIPUV_DEHLT',
'items': [
{
'label': 'RAS Slicer',
'icon': 'EMPTY_DATA',
'node_type': 'BioxelNodes_RAS-Slicer',
'node_description': ''
},
{
'label': 'Axis Slicer',
'icon': 'OUTLINER_OB_EMPTY',
'node_type': 'BioxelNodes_AxisSlicer',
'node_description': '',
},
{
'label': 'Box Slicer',
'icon': 'MOD_WIREFRAME',
'node_type': 'BioxelNodes_BoxSlicer',
'node_description': '',
}
]
},
]
custom_nodes = CustomNodes(
menu_items=MENU_ITEMS,
nodes_file=Path(__file__).parent/"assets/Nodes.blend",
root_label='BioxelNodes',
root_icon="FILE_VOLUME",
)

View File

View File

@@ -0,0 +1,627 @@
import bpy
import bmesh
from ..constants import COMPONENT_OUTPUT_NODES
from ..utils import get_cache_dir, get_use_link, select_object
from ..bioxel.io import load_container, save_container
from ..bioxelutils.container import container_to_obj, obj_to_container
from ..bioxelutils.node import add_node_to_graph
from ..bioxelutils.common import (get_container_layer_objs, get_container_obj, get_node_type,
get_nodes_by_type, get_output_node, is_missing_layer,
move_node_between_nodes,
move_node_to_node,
get_all_layer_objs)
from .layer import RemoveLayers, SaveLayersCache
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"
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container = obj_to_container(container_obj)
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())
select_object(container_obj)
if is_first_import:
bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT',
preset="preview_c")
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 ExtractNodeObject():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
# nodes = [node for node in context.selected_nodes
# if get_node_type(node).removeprefix("BioxelNodes_") in COMPONENT_OUTPUT_NODES]
if len(context.selected_nodes) == 0:
self.report({"WARNING"}, "No node selected.")
return {'FINISHED'}
selected_node = context.selected_nodes[0]
container_node_group = container_obj.modifiers[0].node_group
container_output_node = get_output_node(container_node_group)
if len(container_output_node.inputs[0].links) == 0:
pre_socket = None
else:
pre_socket = container_output_node.inputs[0].links[0].from_socket
container_node_group.links.new(selected_node.outputs[0],
container_output_node.inputs[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"{container_obj.name}_{self.object_type}"
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]
fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}",
node_group,
node_label=f"Fetch {self.object_type}",
use_link=get_use_link())
fetch_mesh_node.inputs[0].default_value = container_obj
node_group.links.new(fetch_mesh_node.outputs[0], output_node.inputs[0])
for modifier in obj.modifiers:
bpy.ops.object.modifier_apply(modifier=modifier.name)
obj.data.materials.clear()
if pre_socket:
container_node_group.links.new(pre_socket,
container_output_node.inputs[0])
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractNodeMesh(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Mesh"
class ExtractNodeShapeWire(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_shape_wire"
bl_label = "Extract Shape Wire"
bl_description = "Extract Shape Wire"
bl_icon = "FILE_VOLUME"
object_type = "ShapeWire"
class ExtractNodeBboxWire(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_bbox_wire"
bl_label = "Extract Bbox Wire"
bl_description = "Extract Bbox Wire"
bl_icon = "MESH_CUBE"
object_type = "BboxWire"
class ExtractObject():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.active_object
obj.name = f"{container_obj.name}_{self.object_type}"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = obj.modifiers[0]
node_group = modifier.node_group
output_node = get_output_node(node_group)
fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}",
node_group,
node_label=f"Fetch {self.object_type}",
use_link=get_use_link())
fetch_mesh_node.inputs[0].default_value = container_obj
if self.object_type == "Mesh":
node_group.links.new(
fetch_mesh_node.outputs[0], output_node.inputs[0])
else:
curve_to_mesh_node = node_group.nodes.new(
"GeometryNodeCurveToMesh")
node_group.links.new(
fetch_mesh_node.outputs[0], curve_to_mesh_node.inputs[0])
node_group.links.new(
curve_to_mesh_node.outputs[0], output_node.inputs[0])
for modifier in obj.modifiers:
bpy.ops.object.modifier_apply(modifier=modifier.name)
obj.data.materials.clear()
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractMesh(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Mesh"
class ExtractShapeWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_shape_wire"
bl_label = "Extract Shape Wire"
bl_description = "Extract Shape Wire"
bl_icon = "FILE_VOLUME"
object_type = "ShapeWire"
class ExtractBboxWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_bbox_wire"
bl_label = "Extract Bbox Wire"
bl_description = "Extract Bbox Wire"
bl_icon = "MESH_CUBE"
object_type = "BboxWire"
class AddCutter():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
# TODO: do not use operator to create obj
if self.cutter_type == "plane":
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "cylinder":
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.cutter_type == "cube":
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "sphere":
bpy.ops.mesh.primitive_ico_sphere_add(
radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "pie":
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
pie_obj = bpy.context.active_object
orig_data = pie_obj.data
# Create mesh
pie_mesh = bpy.data.meshes.new('Pie')
pie_obj.data = pie_mesh
bpy.data.meshes.remove(orig_data)
# # 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
# vcos = [container_obj.matrix_world @
# v.co for v in container_obj.data.vertices]
# def find_center(l): return (max(l) + min(l)) / 2
# x, y, z = [[v[i] for v in vcos] for i in range(3)]
# center = [find_center(axis) for axis in [x, y, z]]
# cutter_obj.location = center
name = f"{self.cutter_type.capitalize()}_Cutter"
cutter_obj.name = name
cutter_obj.data.name = name
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'
cutter_obj.lineart.usage = 'EXCLUDE'
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
cut_nodes = get_nodes_by_type(node_group,
'BioxelNodes_Cut')
if len(cut_nodes) == 0:
cutter_node = add_node_to_graph("ObjectCutter",
node_group,
node_label=f"{self.cutter_type.capitalize()} Cutter",
use_link=get_use_link())
cutter_node.inputs[0].default_value = self.cutter_type.capitalize()
cutter_node.inputs[1].default_value = cutter_obj
cut_node = add_node_to_graph("Cut",
node_group,
use_link=get_use_link())
output_node = get_output_node(node_group)
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))
else:
bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT',
node_name="ObjectCutter",
node_label=name)
node = bpy.context.active_node
node.inputs[0].default_value = self.cutter_type.capitalize()
node.inputs[1].default_value = cutter_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 current container"
bl_icon = "MESH_PLANE"
cutter_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 current container"
bl_icon = "MESH_CYLINDER"
cutter_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 current container"
bl_icon = "MESH_CUBE"
cutter_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 current container"
bl_icon = "MESH_UVSPHERE"
cutter_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 current container"
bl_icon = "MESH_CONE"
cutter_type = "pie"
class AddSlicer(bpy.types.Operator):
bl_idname = "bioxelnodes.add_slicer"
bl_label = "Add a Slicer"
bl_description = "Add a slicer to current container"
bl_icon = "TEXTURE"
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_plane_add(size=2,
enter_editmode=False,
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
slicer_obj = bpy.context.active_object
slicer_obj.name = "Slicer"
slicer_obj.data.name = "Slicer"
slicer_obj.visible_camera = False
slicer_obj.visible_diffuse = False
slicer_obj.visible_glossy = False
slicer_obj.visible_transmission = False
slicer_obj.visible_volume_scatter = False
slicer_obj.visible_shadow = False
slicer_obj.hide_render = True
slicer_obj.display_type = 'WIRE'
slicer_obj.lineart.usage = 'EXCLUDE'
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
slice_node = add_node_to_graph("Slice",
node_group,
use_link=get_use_link())
slice_node.inputs[1].default_value = slicer_obj
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(slice_node.outputs[0],
output_node.inputs[0])
move_node_to_node(slice_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],
slice_node.inputs[0])
node_group.links.new(slice_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(slice_node,
[pre_output_node, output_node])
return {'FINISHED'}
class AddLocator(bpy.types.Operator):
bl_idname = "bioxelnodes.add_locator"
bl_label = "Add a Locator"
bl_description = "Add a locator to current container"
bl_icon = "EMPTY_AXIS"
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.object.empty_add(type='ARROWS',
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
locator_obj = bpy.context.active_object
locator_obj.name = "Locator"
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
parent_node = add_node_to_graph("TransformParent",
node_group,
node_label="Transform Parent",
use_link=get_use_link())
parent_node.inputs[1].default_value = locator_obj
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(parent_node.outputs[0],
output_node.inputs[0])
move_node_to_node(parent_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],
parent_node.inputs[0])
node_group.links.new(parent_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(parent_node,
[pre_output_node, output_node])
return {'FINISHED'}
class ContainerProps(bpy.types.Operator):
bl_idname = "bioxelnodes.container_props"
bl_label = "Change Container Properties"
bl_description = "Change current ontainer properties"
bl_icon = "FILE_TICK"
scene_scale: bpy.props.FloatProperty(name="Scene Scale",
soft_min=0.0001, soft_max=10.0,
min=1e-6, max=1e6,
default=0.01) # type: ignore
step_size: bpy.props.FloatProperty(name="Step Size",
soft_min=0.1, soft_max=100.0,
min=0.1, max=1e2,
default=1) # type: ignore
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container_obj.scale[0] = self.scene_scale
container_obj.scale[1] = self.scene_scale
container_obj.scale[2] = self.scene_scale
container_obj["scene_scale"] = self.scene_scale
container_obj["step_size"] = self.step_size
for layer_obj in get_container_layer_objs(container_obj):
layer_obj.data.render.space = 'WORLD'
layer_obj.data.render.step_size = self.scene_scale * self.step_size
return {'FINISHED'}
def invoke(self, context, event):
container_obj = get_container_obj(context.object)
if container_obj is None:
return self.execute(context)
else:
self.scene_scale = container_obj.get("scene_scale") or 0.01
self.step_size = container_obj.get("step_size") or 1
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
def get_container_layers(context, layer_filter=None):
def _layer_filter(layer_obj, context):
return True
layer_filter = layer_filter or _layer_filter
container_obj = context.object
layer_objs = get_container_layer_objs(container_obj)
return [obj for obj in layer_objs if layer_filter(obj, context)]
class SaveContainerLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_container_layers_cache"
bl_label = "Save Container Layers' Cache"
bl_description = "Save all current container layers' cache to directory."
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all container layers."
def get_layers(self, context):
def is_not_missing(layer_obj, context):
return not is_missing_layer(layer_obj)
return get_container_layers(context, is_not_missing)
class RemoveContainerMissingLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_container_missing_layers"
bl_label = "Remove Container Missing Layers"
bl_description = "Remove all current container missing layers"
bl_icon = "BRUSH_DATA"
success_msg = "Successfully removed all container missing layers."
def get_layers(self, context):
def is_missing(layer_obj, context):
return is_missing_layer(layer_obj)
return get_container_layers(context, is_missing)
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove all **Missing** layers?")
return {'RUNNING_MODAL'}

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

@@ -0,0 +1,983 @@
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 ..bioxelutils.common import (get_all_layer_objs, get_container_obj,
get_layer_obj, is_incompatible)
from ..bioxelutils.container import (Container,
add_layers,
container_to_obj)
from ..bioxel.layer import Layer
from ..bioxel.parse import (DICOM_EXTS, SUPPORT_EXTS,
get_ext, parse_volumetric_data)
from ..utils import (get_cache_dir,
progress_update, progress_bar,
select_object)
# 3rd-party
import SimpleITK as sitk
import transforms3d
def get_layer_shape(bioxel_size: float, orig_shape: tuple, orig_spacing: tuple):
shape = (int(orig_shape[0] / bioxel_size * orig_spacing[0]),
int(orig_shape[1] / bioxel_size * orig_spacing[1]),
int(orig_shape[2] / bioxel_size * orig_spacing[2]))
return (shape[0] if shape[0] > 0 else 1,
shape[1] if shape[1] > 0 else 1,
shape[2] if shape[2] > 0 else 1)
def get_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0):
size = (float(shape[0] * bioxel_size * scale),
float(shape[1] * bioxel_size * scale),
float(shape[2] * bioxel_size * scale))
return size
"""
ImportVolumetricData
-> ParseVolumetricData -> ImportVolumetricDataDialog
FH_ImportVolumetricData
start import parse data execute import
"""
class ImportVolumetricData():
bl_options = {'UNDO'}
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
read_as = "SCALAR"
def execute(self, context):
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported format.")
return {'CANCELLED'}
bpy.ops.bioxelnodes.parse_volumetric_data('INVOKE_DEFAULT',
filepath=self.filepath,
skip_read_as=True,
read_as=self.read_as)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class ImportAsScalar(bpy.types.Operator, ImportVolumetricData):
bl_idname = "bioxelnodes.import_as_scalar"
bl_label = "Import as Scalar"
bl_description = "Import Volumetric Data to Container as Scalar"
bl_icon = "EVENT_S"
read_as = "SCALAR"
class ImportAsLabel(bpy.types.Operator, ImportVolumetricData):
bl_idname = "bioxelnodes.import_as_label"
bl_label = "Import as Label"
bl_description = "Import Volumetric Data to Container as Label"
bl_icon = "EVENT_L"
read_as = "LABEL"
class ImportAsColor(bpy.types.Operator, ImportVolumetricData):
bl_idname = "bioxelnodes.import_as_color"
bl_label = "Import as Color"
bl_description = "Import Volumetric Data to Container as Color"
bl_icon = "EVENT_C"
read_as = "COLOR"
class BIOXELNODES_FH_ImportVolumetricData(bpy.types.FileHandler):
bl_idname = "BIOXELNODES_FH_ImportVolumetricData"
bl_label = "File handler for dicom import"
bl_import_operator = "bioxelnodes.parse_volumetric_data"
bl_file_extensions = ";".join(SUPPORT_EXTS)
@classmethod
def poll_drop(cls, context):
if not context.area:
return False
if context.area.type == 'VIEW_3D':
return True
elif context.area.type == 'NODE_EDITOR':
container_obj = get_container_obj(context.object)
return container_obj is not None
else:
return False
def get_series_ids(self, context):
items = []
for index, series_id in enumerate(self.series_ids):
items.append((
series_id.id,
series_id.label,
"",
index
))
return items
class ParseVolumetricData(bpy.types.Operator):
bl_idname = "bioxelnodes.parse_volumetric_data"
bl_label = "Import Volumetric Data (BioxelNodes)"
bl_description = "Import Volumetric Data as Layer"
bl_options = {'UNDO'}
meta = None
label_count = 0
dtype = 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
skip_read_as: bpy.props.BoolProperty(name="Skip Read As",
default=False,
options={"SKIP_SAVE"}) # type: ignore
skip_series_select: bpy.props.BoolProperty(name="Skip Sries Select",
default=True,
options={"SKIP_SAVE"}) # type: ignore
read_as: bpy.props.EnumProperty(name="Read as",
default="SCALAR",
items=[("SCALAR", "Scalar", ""),
("LABEL", "Label", ""),
("COLOR", "Color", "")]) # 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):
if is_incompatible():
self.report({"ERROR"},
"Current addon verison is not compatible to this file. If you insist on editing this file please keep the same addon version")
return {'CANCELLED'}
if not self.filepath:
self.report({"WARNING"}, "No file selected.")
return {'CANCELLED'}
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
if ext not in SUPPORT_EXTS:
self.report({"WARNING"}, "Not supported format.")
return {'CANCELLED'}
print("Collecting Meta Data...")
def parse_volumetric_data_func(self, context, cancel):
def progress_callback(factor, text):
if cancel():
raise CancelledByUser
progress_update(context, factor, text)
try:
series_id = self.series_id if self.series_id != "empty" else ""
data, meta = parse_volumetric_data(data_file=self.filepath,
series_id=series_id,
progress_callback=progress_callback)
except CancelledByUser:
return
except Exception as e:
self.has_error = e
return
if cancel():
return
self.meta = meta
self.label_count = int(np.max(data))
self.dtype = data.dtype
# 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.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)
progress_update(context, 1.0)
# 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}")
if self.read_as == "LABEL":
if self.label_count > 100 or self.dtype.kind not in ["i", "u"]:
self.report({"ERROR"}, "Invaild label data.")
return {'CANCELLED'}
if self.label_count == 0:
self.report({"ERROR"}, "Get no label.")
return {'CANCELLED'}
orig_shape = self.meta['xyz_shape']
orig_spacing = self.meta['spacing']
min_log10 = math.floor(math.log10(min(*orig_spacing)))
max_log10 = math.floor(math.log10(max(*orig_spacing)))
# min_space = min(*orig_spacing)
# max_space = max(*orig_spacing)
if orig_spacing[2] == 1 and min_log10 < -1:
orig_spacing = (orig_spacing[0] * math.pow(10, -min_log10-2),
orig_spacing[1] * math.pow(10, -min_log10-2),
1)
elif min_log10 > 0:
orig_spacing = (orig_spacing[0] * math.pow(10, -min_log10-1),
orig_spacing[1] * math.pow(10, -min_log10-1),
orig_spacing[2] * math.pow(10, -min_log10-1))
elif max_log10 < 0:
orig_spacing = (orig_spacing[0] * math.pow(10, -max_log10-1),
orig_spacing[1] * math.pow(10, -max_log10-1),
orig_spacing[2] * math.pow(10, -max_log10-1))
bioxel_size = max(min(*orig_spacing), 1.0)
layer_shape = get_layer_shape(bioxel_size,
orig_shape,
orig_spacing)
layer_size = get_layer_size(layer_shape,
bioxel_size,
0.01)
min_log10 = math.floor(math.log10(min(*layer_size)))
max_log10 = math.floor(math.log10(max(*layer_size)))
if min_log10 > 0:
scene_scale = math.pow(10, -min_log10-2)
elif max_log10 < 0:
scene_scale = math.pow(10, -max_log10-2)
else:
scene_scale = 0.01
if context.area.type == "NODE_EDITOR":
container_obj = context.object
container_name = container_obj.name
container_obj_name = container_name
else:
container_name = self.meta['name']
container_obj_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=container_obj_name,
read_as=self.read_as,
label_count=self.label_count,
scene_scale=scene_scale
)
self.report({"INFO"}, "Successfully Readed.")
return {'FINISHED'}
def invoke(self, context, event):
# why not report in execute?
# If this operator is executing, a new execute will when pre-one done.
if context.window_manager.bioxelnodes_progress_factor < 1:
print("A process is executing, please wait for it to finish.")
return {'CANCELLED'}
if not self.filepath:
return self.execute(context)
data_path = Path(self.filepath).resolve()
ext = get_ext(data_path)
if context.area.type == "NODE_EDITOR":
title = f"Add to **{context.object.name}**"
else:
title = "Init a Container"
# Series Selection
if ext in DICOM_EXTS:
data_dirpath = data_path.parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_ids = reader.GetGDCMSeriesIDs(str(data_dirpath))
series_items = {}
for series_id in series_ids:
series_files = reader.GetGDCMSeriesFileNames(
str(data_dirpath), series_id)
single = sitk.ImageFileReader()
single.SetFileName(series_files[0])
single.LoadPrivateTagsOn()
single.ReadImageInformation()
def get_meta(key):
try:
stirng = single.GetMetaData(key).removesuffix(" ")
stirng.encode('utf-8')
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"
label = "{:<20} {:>1}".format(f"{study_description}>{series_description}({series_modality})",
f"({size_x}x{size_y})x{count}")
series_items[series_id] = label
for series_id, label in series_items.items():
series_item = self.series_ids.add()
series_item.id = series_id
series_item.label = label
if len(series_items.keys()) > 1:
self.skip_series_select = False
context.window_manager.invoke_props_dialog(self,
width=400,
title=title)
return {'RUNNING_MODAL'}
elif len(series_items.keys()) == 1:
self.series_id = list(series_items.keys())[0]
else:
self.report({"ERROR"}, "Get no vaild series.")
return {'CANCELLED'}
if self.skip_read_as:
return self.execute(context)
else:
context.window_manager.invoke_props_dialog(self,
width=400,
title=title)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
if not self.skip_read_as:
layout.label(
text='What kind is the data read as?')
layout.prop(self, "read_as")
if not self.skip_series_select:
layout.label(text='Which series to import?')
layout.prop(self, "series_id")
def get_frame_sources(self, context):
items = [("-1", "First Frame (1 frame)", "")]
orig_shape = tuple(self.orig_shape)
if self.frame_count > 1:
items.append(("0", f"Frames ({self.frame_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count > 1:
items.append(("4", f"Channels ({self.channel_count} frames)", ""))
elif self.frame_count == 1 and self.channel_count == 1:
items.append(("1", f"X-axis ({orig_shape[0]} frames)", ""))
items.append(("2", f"Y-axis ({orig_shape[1]} frames)", ""))
items.append(("3", f"Z-axis ({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
label_count: bpy.props.IntProperty() # type: ignore
smooth: bpy.props.IntProperty(name="Smooth Size (Larger takes longer time)",
default=0) # type: ignore
read_as: bpy.props.EnumProperty(name="Read as",
default="SCALAR",
items=[("SCALAR", "Scalar", ""),
("LABEL", "Label", ""),
("COLOR", "Color", "")]) # 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
remap: bpy.props.BoolProperty(name="Remap to 0~1",
default=False) # type: ignore
split_channel: bpy.props.BoolProperty(name="Split Channels",
default=False) # type: ignore
frame_source: bpy.props.EnumProperty(name="Frame From",
items=get_frame_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.lower()
if cancel():
return
# change shape as sequence or not
if self.frame_source == "-1":
data = data[0:1, :, :, :, :]
elif self.frame_source == "0":
# frame as frame
pass
elif self.frame_source == "1":
# X as frame
data = data.transpose(1, 0, 2, 3, 4)
shape = (1, shape[1], shape[2])
elif self.frame_source == "2":
# Y as frame
data = data.transpose(2, 1, 0, 3, 4)
shape = (shape[0], 1, shape[2])
elif self.frame_source == "3":
# Z as frame
data = data.transpose(3, 1, 2, 0, 4)
shape = (shape[0], shape[1], 1)
else:
# channel as frame
data = data.transpose(4, 1, 2, 3, 0)
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}...")
print(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)
label_data = data == np.full_like(data, i+1)
# label_data = label_data.astype(np.float32)
try:
layer = Layer(data=label_data,
name=name_i,
kind=kind)
layer.resize(shape=shape,
smooth=self.smooth,
progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except CancelledByUser:
return
except Exception as e:
self.has_error = e
return
if kind == "color":
if np.issubdtype(np.uint8, data.dtype):
data = np.multiply(data, 1.0 / 256,
dtype=np.float32)
elif data.dtype.kind in ['u', 'i']:
# Convert the normalized array to float dtype
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
# Avoid division by zero if all values are the same
if max_val != min_val:
# Normalize the array to the range (0,1)
data = (data - min_val) / (max_val - min_val)
else:
# If all values are the same, the normalized array will be all zeros
data = np.zeros_like(data, dtype=np.float32)
else:
data = data.astype(np.float32)
name = self.layer_name or "Color"
if data.shape[4] == 1:
data = np.repeat(data, repeats=3, axis=4)
elif data.shape[4] == 2:
d_shape = list(data.shape)
d_shape = d_shape[:4] + [1]
zore = np.zeros(tuple(d_shape), dtype=np.float32)
data = np.concatenate((data, zore), axis=-1)
elif data.shape[4] > 3:
data = data[:, :, :, :, :3]
if cancel():
return
progress_update(context, 0.2,
f"Processing {name}...")
progress_callback = progress_callback_factory(name,
0.2,
0.7)
try:
layer = Layer(data=data,
name=name,
kind=kind)
layer.resize(shape=shape,
progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except CancelledByUser:
return
except Exception as e:
self.has_error = e
return
elif kind == "scalar":
name = self.layer_name or "Scalar"
if self.remap:
# Convert the normalized array to float dtype
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
# Avoid division by zero if all values are the same
if max_val != min_val:
# Normalize the array to the range (0,1)
data = (data - min_val) / (max_val - min_val)
else:
# If all values are the same, the normalized array will be all zeros
data = np.zeros_like(data, dtype=np.float32)
if self.split_channel:
progress_step = 0.7/self.channel_count
for i in range(self.channel_count):
if cancel():
return
name_i = f"{name}_{i+1}"
progress = 0.2 + i*progress_step
progress_update(context, progress,
f"Processing {name_i}...")
progress_callback = progress_callback_factory(name_i,
progress,
progress_step)
try:
layer = Layer(data=data[:, :, :, :, i:i+1],
name=name_i,
kind=kind)
layer.resize(shape=shape,
progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except 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)
layer.resize(shape=shape,
progress_callback=progress_callback)
layer.affine = affine
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.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)
progress_update(context, 1.0)
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())
else:
name = self.container_name or "Container"
container = Container(name=name,
layers=self.layers)
step_size = container.layers[0].bioxel_size[0]*10
container_obj = container_to_obj(container,
scene_scale=self.scene_scale,
step_size=step_size,
cache_dir=get_cache_dir())
select_object(container_obj)
# Change render setting for better result
if is_first_import:
bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT',
preset="balance")
bpy.context.scene.render.engine = 'CYCLES'
bpy.ops.bioxelnodes.slice_viewer('EXEC_DEFAULT')
self.report({"INFO"}, "Successfully Imported")
return {'FINISHED'}
def invoke(self, context, event):
layer_kind = self.read_as.capitalize()
title = f"Add to **{self.container_obj_name}**, As {layer_kind}" \
if self.container_obj_name != "" else f"Init a Container, As {layer_kind}"
context.window_manager.invoke_props_dialog(self,
width=500,
title=title)
return {'RUNNING_MODAL'}
def draw(self, context):
layer_shape = get_layer_shape(self.bioxel_size,
self.orig_shape,
self.orig_spacing)
orig_shape = tuple(self.orig_shape)
# change shape as sequence or not
channel_count = self.channel_count
frame_count = self.frame_count
if self.frame_source == "-1":
frame_count = 1
pass
elif self.frame_source == "0":
# frame as frame
pass
elif self.frame_source == "1":
frame_count = orig_shape[0]
layer_shape = (1, layer_shape[1], layer_shape[2])
elif self.frame_source == "2":
frame_count = orig_shape[1]
layer_shape = (layer_shape[0], 1, layer_shape[2])
elif self.frame_source == "3":
frame_count = orig_shape[2]
layer_shape = (layer_shape[0], layer_shape[1], 1)
else:
channel_count = 1
if self.read_as == "SCALAR":
layer_count = channel_count if self.split_channel else 1
channel_count = 1
elif self.read_as == "LABEL":
layer_count = self.label_count
channel_count = 1
else:
layer_count = 1
channel_count = 3
bioxel_count = layer_shape[0] * layer_shape[1] * layer_shape[2]
orig_shape_text = f"[{self.frame_count}, ({orig_shape[0]},{orig_shape[1]},{orig_shape[2]}), {self.channel_count}]"
layer_shape_text = f"{layer_count} x [{frame_count}, ({layer_shape[0]},{layer_shape[1]},{layer_shape[2]}), {channel_count}]"
if bioxel_count > 100000000:
layer_shape_text += "**TOO LARGE!**"
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"
layout = self.layout
if self.container_obj_name == "":
panel = layout.box()
panel.prop(self, "container_name")
panel.prop(self, "scene_scale")
panel.label(text=layer_size_text)
panel = layout.box()
panel.prop(self, "layer_name")
panel.prop(self, "bioxel_size")
row = panel.row()
row.prop(self, "orig_spacing")
panel.prop(self, "frame_source")
if self.read_as == "SCALAR":
panel.prop(self, "split_channel",
text=f"Split Channel as Multi Layer")
elif self.read_as == "LABEL":
panel.prop(self, "smooth")
panel.label(
text="Dimension Order: [Frame, (X-axis,Y-axis,Z-axis), Channel]")
panel.label(
text=f"Shape from {orig_shape_text} to {layer_shape_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,619 @@
from pathlib import Path
import bpy
import numpy as np
from ..exceptions import NoContent
from ..bioxel.layer import Layer
from ..bioxelutils.node import add_node_to_graph
from ..bioxelutils.common import (get_container_obj, get_layer_kind, get_layer_label, get_layer_name,
get_layer_prop_value,
get_container_layer_objs,
get_node_type, is_missing_layer, move_node_to_node, set_layer_prop_value)
from ..bioxelutils.layer import layer_to_obj, obj_to_layer
from ..utils import get_cache_dir, copy_to_dir, get_use_link
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):
kind = get_layer_prop_value(layer_obj, "kind")
name = get_layer_prop_value(layer_obj, "name")
if kind == "label":
items.append((layer_obj.name,
name,
""))
return items
class FetchLayer(bpy.types.Operator):
bl_idname = "bioxelnodes.fetch_layer"
bl_label = "Fetch Layer"
bl_description = "Fetch layer from current container"
bl_icon = "NODE"
bl_options = {'UNDO'}
layer_obj_name: bpy.props.StringProperty(
options={"HIDDEN"}) # type: ignore
@classmethod
def description(cls, context, properties):
layer_obj_name = properties.layer_obj_name
layer_obj = bpy.data.objects.get(layer_obj_name)
return "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}"
for prop in ["kind",
"bioxel_size",
"shape",
"frame_count",
"channel_count",
"min", "max"]])
@property
def layer_obj(self):
return bpy.data.objects.get(self.layer_obj_name)
def execute(self, context):
if self.layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if is_missing_layer(self.layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT',
node_name="FetchLayer",
node_label="Fetch Layer")
node = bpy.context.active_node
node.inputs[0].default_value = self.layer_obj
node.label = get_layer_label(self.layer_obj)
return {"FINISHED"}
def get_selected_layers(context, layer_filter=None):
def _layer_filter(layer_obj, context):
return True
layer_filter = layer_filter or _layer_filter
select_objs = []
# node_group = context.space_data.edit_tree
for node in context.selected_nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
layer_obj = node.inputs[0].default_value
if layer_obj is not None:
if layer_filter(layer_obj, context):
select_objs.append(layer_obj)
return list(set(select_objs))
def get_selected_layer(context, layer_filter=None):
layer_objs = get_selected_layers(context, layer_filter)
return layer_objs[0] if len(layer_objs) > 0 else None
class OutputLayerOperator():
new_layer_name: bpy.props.StringProperty(name="New Name",
options={"SKIP_SAVE"}) # type: ignore
def operate(self, orig_layer: Layer, context):
"""do the operation"""
return orig_layer
def add_layer_node(self, context, layer):
orig_node = context.selected_nodes[0]
layer_obj = layer_to_obj(layer,
container_obj=context.object,
cache_dir=get_cache_dir())
node_group = context.space_data.edit_tree
fetch_node = add_node_to_graph("FetchLayer",
node_group,
use_link=get_use_link())
fetch_node.label = get_layer_prop_value(layer_obj, "name")
fetch_node.inputs[0].default_value = layer_obj
move_node_to_node(fetch_node, orig_node, (0, -100))
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if is_missing_layer(layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
orig_layer = obj_to_layer(layer_obj)
try:
new_layer = self.operate(orig_layer, context)
except NoContent as e:
self.report({"WARNING"}, e.message)
return {'FINISHED'}
self.add_layer_node(context, new_layer)
return {'FINISHED'}
class LayerOperator():
def operate(self, layer_obj: bpy.types.Object, context):
"""do the operation"""
...
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if is_missing_layer(layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
self.operate(layer_obj, context)
return {'FINISHED'}
class RetimeLayer(bpy.types.Operator, LayerOperator):
bl_idname = "bioxelnodes.retime_layer"
bl_label = "Retime Sequence"
bl_description = "Retime layer time sequence"
bl_icon = "TIME"
frame_duration: bpy.props.IntProperty(name="Frames") # type: ignore
frame_start: bpy.props.IntProperty(name="Start") # type: ignore
frame_offset: bpy.props.IntProperty(name="Offset") # type: ignore
sequence_mode: bpy.props.EnumProperty(name="Mode",
default="REPEAT",
items=[("CLIP", "Clip", ""),
("EXTEND", "Extend", ""),
("REPEAT", "Repeat", ""),
("PING_PONG", "Ping-Pong", "")]) # type: ignore
def operate(self, layer_obj, context):
layer_obj.data.frame_duration = self.frame_duration
layer_obj.data.frame_start = self.frame_start
layer_obj.data.frame_offset = self.frame_offset
layer_obj.data.sequence_mode = self.sequence_mode
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
self.frame_duration = layer_obj.data.frame_duration
self.frame_start = layer_obj.data.frame_start
self.frame_offset = layer_obj.data.frame_offset
self.sequence_mode = layer_obj.data.sequence_mode
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Retime {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class RelocateLayer(bpy.types.Operator):
bl_idname = "bioxelnodes.relocate_layer"
bl_label = "Relocate Layer Cache"
bl_description = "Relocate layer cache"
bl_icon = "FILE"
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
self.operate(layer_obj, context)
return {'FINISHED'}
def operate(self, layer_obj, context):
layer_obj.data.filepath = self.filepath
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class RenameLayer(bpy.types.Operator, LayerOperator):
bl_idname = "bioxelnodes.rename_layer"
bl_label = "Rename Layer"
bl_description = "Rename layer"
bl_icon = "FILE_FONT"
new_name: bpy.props.StringProperty(name="New Name",
options={"SKIP_SAVE"}) # type: ignore
def operate(self, layer_obj, context):
name = f"{layer_obj.parent.name}_{self.new_name}"
layer_obj.name = name
layer_obj.data.name = name
set_layer_prop_value(layer_obj, "name", self.new_name)
node_group = context.space_data.edit_tree
for node in node_group.nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node.label = self.new_name
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
self.new_name = get_layer_name(layer_obj)
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Rename {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class ResampleLayer(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.resample_layer"
bl_label = "Resample Value"
bl_description = "Resample value"
bl_icon = "ALIASED"
smooth: bpy.props.IntProperty(name="Smooth Size",
default=0,
soft_min=0, soft_max=5,
options={"SKIP_SAVE"}) # type: ignore
bioxel_size: bpy.props.FloatProperty(
name="Bioxel Size",
soft_min=0.1, soft_max=10.0,
default=1,
) # type: ignore
@staticmethod
def get_new_shape(orig_shape, orig_size, new_size):
return (int(orig_shape[0]*orig_size/new_size),
int(orig_shape[1]*orig_size/new_size),
int(orig_shape[2]*orig_size/new_size))
def operate(self, orig_layer: Layer, context):
new_layer = orig_layer.copy()
new_shape = self.get_new_shape(new_layer.shape,
new_layer.bioxel_size[0],
self.bioxel_size)
new_layer.resize(new_shape, self.smooth)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_R-{self.bioxel_size:.2f}"
return new_layer
def draw(self, context):
layer_obj = get_selected_layer(context)
orig_shape = get_layer_prop_value(layer_obj, "shape")
orig_size = get_layer_prop_value(layer_obj, "bioxel_size")
new_shape = self.get_new_shape(orig_shape,
orig_size,
self.bioxel_size)
orig_shape = tuple(orig_shape)
bioxel_count = new_shape[0] * new_shape[1] * new_shape[2]
layer_shape_text = f"Shape from {str(orig_shape)} to {str(new_shape)}"
if bioxel_count > 100000000:
layer_shape_text += "**TOO LARGE!**"
layout = self.layout
layout.prop(self, "new_layer_name")
layout.prop(self, "bioxel_size")
layout.prop(self, "smooth")
layout.label(text=layer_shape_text)
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
self.bioxel_size = get_layer_prop_value(layer_obj,
"bioxel_size")
context.window_manager.invoke_props_dialog(self,
title=f"Resample {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class SignScalar(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.sign_scalar"
bl_label = "Sign Value"
bl_description = "Sign value"
bl_icon = "REMOVE"
def operate(self, orig_layer: Layer, context):
new_layer = orig_layer.copy()
new_layer.data = -orig_layer.data
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_Sign"
return new_layer
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Sign {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class FillOperator(OutputLayerOperator):
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 Aera",
default=True,
) # type: ignore
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Fill {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class FillByThreshold(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_threshold"
bl_label = "Fill Value by Threshold"
bl_description = "Fill value by threshold"
bl_icon = "EMPTY_SINGLE_ARROW"
threshold: bpy.props.FloatProperty(
name="Threshold",
soft_min=0, soft_max=1024,
default=128,
) # type: ignore
def operate(self, orig_layer: Layer, context):
data = np.amax(orig_layer.data, -1)
mask = data <= self.threshold \
if self.invert else data > self.threshold
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{self.threshold:.2f}"
return new_layer
class FillByRange(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_range"
bl_label = "Fill Value by Range"
bl_description = "Fill value by range"
bl_icon = "IPO_CONSTANT"
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
def operate(self, orig_layer: Layer, context):
data = np.amax(orig_layer.data, -1)
mask = (data <= self.from_min) | (data >= self.from_max) if self.invert else \
(data > self.from_min) & (data < self.from_max)
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{self.from_min:.2f}-{self.from_max:.2f}"
return new_layer
class FillByLabel(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_label"
bl_label = "Fill Value by Label"
bl_description = "Fill value by label"
bl_icon = "MESH_CAPSULE"
smooth: bpy.props.IntProperty(name="Smooth Size",
default=0,
soft_min=0, soft_max=5,
options={"SKIP_SAVE"}) # type: ignore
label_obj_name: bpy.props.EnumProperty(name="Label Layer",
items=get_label_layer_selection) # type: ignore
def operate(self, orig_layer: Layer, context):
label_obj = bpy.data.objects.get(self.label_obj_name)
if label_obj is None:
raise NoContent("Cannot find any label layer.")
label_layer = obj_to_layer(label_obj)
label_layer.resize(orig_layer.shape, self.smooth)
mask = np.amax(label_layer.data, -1)
if self.invert:
mask = 1 - mask
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{label_layer.name}"
return new_layer
class CombineLabels(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.combine_labels"
bl_label = "Combine Labels"
bl_description = "Combine all selected labels"
bl_icon = "MOD_BUILD"
def execute(self, context):
def layer_filter(layer_obj, context):
return get_layer_kind(layer_obj) == "label"
label_objs = get_selected_layers(context, layer_filter)
if len(label_objs) < 2:
self.report({"WARNING"}, "Not enough layers.")
return {'FINISHED'}
base_obj = label_objs[0]
label_objs = label_objs[1:]
base_layer = obj_to_layer(base_obj)
new_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)
new_layer.data = np.maximum(
new_layer.data, label_layer.data)
label_names.append(label_layer.name)
new_layer.name = self.new_layer_name \
or f"C-{'-'.join(label_names)}"
self.add_layer_node(context, new_layer)
return {'FINISHED'}
class BatchLayerOperator():
success_msg = "Successfully done selected layers."
fail_msg = "fails."
def execute(self, context):
layer_objs = self.get_layers(context)
fails = []
for layer_obj in layer_objs:
try:
self.operate(layer_obj, context)
except:
fails.append(layer_obj)
if len(fails) == 0:
self.report({"INFO"}, self.success_msg)
else:
self.report(
{"WARNING"}, f"{','.join([layer_obj.name for layer_obj in fails])} {self.fail_msg}")
return {'FINISHED'}
class SaveLayersCache(BatchLayerOperator):
fail_msg = "fail to save."
cache_dir: bpy.props.StringProperty(
name="Cache Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def operate(self, layer_obj, context):
output_dir = bpy.path.abspath(self.cache_dir)
source_dir = bpy.path.abspath(layer_obj.data.filepath)
source_path: Path = Path(source_dir).resolve()
is_sequence = layer_obj.data.is_sequence
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))
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
class RemoveLayers(BatchLayerOperator):
fail_msg = "fail to remove."
def operate(self, layer_obj, context):
for node_group in bpy.data.node_groups:
for node in node_group.nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node_group.nodes.remove(node)
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
if cache_filepath.is_file():
if layer_obj.data.is_sequence:
for f in cache_filepath.parent.iterdir():
f.unlink(missing_ok=True)
else:
cache_filepath.unlink(missing_ok=True)
# also remove layer object
bpy.data.volumes.remove(layer_obj.data)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove them?")
return {'RUNNING_MODAL'}
class SaveSelectedLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_selected_layers_cache"
bl_label = "Save Selected Layers Cache"
bl_description = "Save selected layers' Cache"
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all selected layers."
def get_layers(self, context):
def is_not_missing(layer_obj, context):
return not is_missing_layer(layer_obj)
return get_selected_layers(context, is_not_missing)
class RemoveSelectedLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_selected_layers"
bl_label = "Remove Selected Layers"
bl_description = "Remove selected layers"
bl_icon = "TRASH"
success_msg = "Successfully removed all selected layers."
def get_layers(self, context):
return get_selected_layers(context)

View File

@@ -0,0 +1,310 @@
import webbrowser
import bpy
from pathlib import Path
import shutil
from ..bioxelutils.common import get_all_layer_objs, get_node_lib_path, get_node_version, is_missing_layer, set_file_prop
from .layer import RemoveLayers, SaveLayersCache
from ..constants import LATEST_NODE_LIB_PATH, VERSIONS
from ..utils import get_cache_dir
class ReLinkNodeLib(bpy.types.Operator):
bl_idname = "bioxelnodes.relink_node_lib"
bl_label = "Relink Node Library"
bl_description = "Relink all nodes to addon library source"
bl_options = {'UNDO'}
index: bpy.props.IntProperty() # type: ignore
def execute(self, context):
node_version = VERSIONS[self.index]['node_version']
lib_path = get_node_lib_path(node_version) \
if self.index != 0 else LATEST_NODE_LIB_PATH
node_libs = []
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
node_lib = node_group.library
if node_lib:
node_libs.append(node_lib)
node_libs = list(set(node_libs))
for node_lib in node_libs:
node_lib.filepath = str(lib_path)
# FIXME: may cause crash
node_lib.reload()
set_file_prop("node_version", node_version)
self.report({"INFO"}, f"Successfully relinked.")
return {'FINISHED'}
class SaveNodeLib(bpy.types.Operator):
bl_idname = "bioxelnodes.save_node_lib"
bl_label = "Save Node Library"
bl_description = "Save node library file to local"
bl_options = {'UNDO'}
lib_dir: bpy.props.StringProperty(
name="Library Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def execute(self, context):
node_version = get_node_version()
if node_version is None:
node_version = VERSIONS[0]["node_version"]
else:
if node_version not in [v["node_version"] for v in VERSIONS]:
node_version = VERSIONS[0]["node_version"]
if node_version == VERSIONS[0]["node_version"]:
lib_path = LATEST_NODE_LIB_PATH
else:
lib_path = get_node_lib_path(node_version)
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_dir = bpy.path.abspath(self.lib_dir)
local_lib_path: Path = Path(lib_dir,
f"BioxelNodes_{version_str}.blend").resolve()
node_lib_path: Path = lib_path
blend_path = Path(bpy.path.abspath("//")).resolve()
if local_lib_path != node_lib_path:
shutil.copy(node_lib_path, local_lib_path)
libs = []
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
if node_group.library:
libs.append(node_group.library)
libs = list(set(libs))
for lib in libs:
lib.filepath = bpy.path.relpath(str(local_lib_path),
start=str(blend_path))
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
@classmethod
def poll(cls, context):
return bpy.data.is_saved
class CleanTemp(bpy.types.Operator):
bl_idname = "bioxelnodes.clear_temp"
bl_label = "Clean Temp"
bl_description = "Clean all cache in temp (include other project cache)"
def execute(self, context):
cache_dir = get_cache_dir()
try:
shutil.rmtree(cache_dir)
self.report({"INFO"}, f"Successfully cleaned temp.")
return {'FINISHED'}
except:
self.report({"WARNING"},
"Fail to clean temp, you may do it manually.")
return {'CANCELLED'}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message="All temp files will be removed, include other project cache, do you still want to clean?")
return {'RUNNING_MODAL'}
class RenderSettingPreset(bpy.types.Operator):
bl_idname = "bioxelnodes.render_setting_preset"
bl_label = "Render Setting Presets"
bl_description = "Render setting presets for bioxel"
bl_options = {'UNDO'}
PRESETS = {
"performance": "Performance",
"balance": "Balance",
"quality": "Quality",
}
preset: bpy.props.EnumProperty(name="Preset",
default="balance",
items=[(k, v, "")
for k, v in PRESETS.items()]) # type: ignore
def execute(self, context):
if self.preset == "performance":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.volumetric_shadow_samples = 32
bpy.context.scene.eevee.volumetric_samples = 64
bpy.context.scene.eevee.volumetric_ray_depth = 1
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.shading_system = True
bpy.context.scene.cycles.volume_bounces = 0
bpy.context.scene.cycles.transparent_max_bounces = 4
bpy.context.scene.cycles.volume_preview_step_rate = 4
bpy.context.scene.cycles.volume_step_rate = 4
elif self.preset == "balance":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.volumetric_shadow_samples = 64
bpy.context.scene.eevee.volumetric_samples = 128
bpy.context.scene.eevee.volumetric_ray_depth = 8
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.shading_system = True
bpy.context.scene.cycles.volume_bounces = 4
bpy.context.scene.cycles.transparent_max_bounces = 8
bpy.context.scene.cycles.volume_preview_step_rate = 1
bpy.context.scene.cycles.volume_step_rate = 1
elif self.preset == "quality":
# EEVEE
# 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.volumetric_ray_depth = 16
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.shading_system = True
bpy.context.scene.cycles.volume_bounces = 8
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_preview_step_rate = 0.5
bpy.context.scene.cycles.volume_step_rate = 0.5
return {'FINISHED'}
class SliceViewer(bpy.types.Operator):
bl_idname = "bioxelnodes.slice_viewer"
bl_label = "Slice Viewer"
bl_description = "A preview scene setting for viewing slicers"
bl_icon = "FILE_VOLUME"
def execute(self, context):
# 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 = 128
bpy.context.scene.eevee.volumetric_ray_depth = 1
bpy.context.scene.eevee.use_volumetric_shadows = False
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.spaces[0].shading.type = 'MATERIAL'
area.spaces[0].shading.studio_light = 'studio.exr'
area.spaces[0].shading.studiolight_intensity = 2.5
area.spaces[0].shading.use_scene_lights = False
area.spaces[0].shading.use_scene_world = False
return {'FINISHED'}
def get_all_layers(layer_filter=None):
def _layer_filter(layer_obj):
return True
layer_filter = layer_filter or _layer_filter
layer_objs = get_all_layer_objs()
return [obj for obj in layer_objs if layer_filter(obj)]
class SaveAllLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_all_layers_cache"
bl_label = "Save All Layers Cache"
bl_description = "Save all cache of this file"
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all layers."
def get_layers(self, context):
def is_not_missing(layer_obj):
return not is_missing_layer(layer_obj)
return get_all_layers(is_not_missing)
class RemoveAllMissingLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_all_missing_layers"
bl_label = "Remove All Missing Layers"
bl_description = "Remove all current container missing layers"
bl_icon = "BRUSH_DATA"
success_msg = "Successfully removed all missing layers."
def get_layers(self, context):
def is_missing(layer_obj):
return is_missing_layer(layer_obj)
return get_all_layers(is_missing)
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove all **Missing** layers?")
return {'RUNNING_MODAL'}
class Help(bpy.types.Operator):
bl_idname = "bioxelnodes.help"
bl_label = "Need Help?"
bl_description = "Online Manual for Beginner"
bl_icon = "HELP"
def execute(self, context):
webbrowser.open(
'https://omoolab.github.io/BioxelNodes/latest/', new=2)
return {'FINISHED'}
class AddEeveeEnv(bpy.types.Operator):
bl_idname = "bioxelnodes.add_eevee_env"
bl_label = "Add EEVEE Env Light"
bl_description = "To make volume shadow less dark"
def execute(self, context):
bpy.ops.object.light_add(
type='POINT', align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
light_obj = bpy.context.active_object
light_obj.name = "EeveeEnv"
light_obj.data.shadow_soft_size = 100
light_obj.data.energy = 1e+06
light_obj.data.color = (0.1, 0.1, 0.1)
light_obj.data.use_shadow = False
light_obj.data.diffuse_factor = 0
light_obj.data.specular_factor = 0
light_obj.data.transmission_factor = 0
light_obj.hide_select = True
light_obj.lock_location[0] = True
light_obj.lock_location[1] = True
light_obj.lock_location[2] = True
light_obj.lock_rotation[0] = True
light_obj.lock_rotation[1] = True
light_obj.lock_rotation[2] = True
light_obj.lock_scale[0] = True
light_obj.lock_scale[1] = True
light_obj.lock_scale[2] = True
bpy.context.scene.eevee.use_volumetric_shadows = True
return {'FINISHED'}

View File

@@ -0,0 +1,60 @@
import bpy
from ..bioxelutils.common import is_incompatible, local_lib_not_updated
from ..bioxelutils.node import assign_node_tree, get_node_tree
from ..utils import get_use_link
class AddNode(bpy.types.Operator):
bl_idname = "bioxelnodes.add_node"
bl_label = "Add Node"
bl_options = {"REGISTER", "UNDO"}
node_name: bpy.props.StringProperty(
default='',
) # type: ignore
node_label: bpy.props.StringProperty(
default=''
) # type: ignore
node_description: bpy.props.StringProperty(
default="",
) # type: ignore
@property
def node_type(self):
return f"BioxelNodes_{self.node_name}"
def execute(self, context):
space = context.space_data
if space.type != "NODE_EDITOR":
self.report({"ERROR"}, "Not in node editor.")
return {'CANCELLED'}
if not space.edit_tree.is_editable:
self.report({"ERROR"}, "Not editable.")
return {'CANCELLED'}
if is_incompatible():
self.report({"ERROR"},
"Current addon verison is not compatible to this file. If you insist on editing this file please keep the same addon version.")
return {'CANCELLED'}
node_tree = get_node_tree(self.node_type, get_use_link())
bpy.ops.node.add_node(
'INVOKE_DEFAULT',
type='GeometryNodeGroup',
use_transform=True
)
node = bpy.context.active_node
assign_node_tree(node, node_tree)
node.label = self.node_label
node.show_options = False
if local_lib_not_updated():
self.report({"WARNING"},
"Local node library version does not match the current addon version, which may cause problems, please save the node library again.")
return {"FINISHED"}

View File

@@ -1,23 +1,29 @@
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
change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting on First Import",
default=True,
) # type: ignore
node_import_method: bpy.props.EnumProperty(name="Node Import Method",
default="LINK",
items=[("LINK", "Link", ""),
("APPEND", "Append (Reuse Data)", "")]) # 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, "change_render_setting")
layout.prop(self, "node_import_method")

44
bioxelnodes/props.py Normal file
View File

@@ -0,0 +1,44 @@
import bpy
from .bioxelutils.common import get_node_type
class BIOXELNODES_UL_layer_list(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
layout.label(text=item.label, translate=False, icon_value=icon)
class BIOXELNODES_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
def select_layer(self, context):
layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL
layer_list = layer_list_UL.layer_list
layer_list_active = layer_list_UL.layer_list_active
if len(layer_list) > 0 and layer_list_active != -1 and layer_list_active < len(layer_list):
layer_obj = bpy.data.objects[layer_list[layer_list_active].obj_name]
node_group = context.space_data.edit_tree
for node in node_group.nodes:
node.select = False
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node.select = True
layer_list_UL.layer_list_active = -1
class BIOXELNODES_Layer(bpy.types.PropertyGroup):
obj_name: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
info_text: bpy.props.StringProperty() # type: ignore
class BIOXELNODES_LayerListUL(bpy.types.PropertyGroup):
layer_list: bpy.props.CollectionProperty(
type=BIOXELNODES_Layer) # type: ignore
layer_list_active: bpy.props.IntProperty(default=-1,
update=select_layer,
options=set()) # type: ignore

View File

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

View File

@@ -1,135 +1,72 @@
from pathlib import Path
import shutil
import bpy
import mathutils
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)
if not target_path.exists():
raise Exception
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 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 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 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 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 progress_update(context, factor, text=""):
context.window_manager.bioxelnodes_progress_factor = factor
context.window_manager.bioxelnodes_progress_text = text
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 get_preferences():
return bpy.context.preferences.addons[__package__].preferences
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)
def get_cache_dir():
preferences = get_preferences()
cache_path = Path(preferences.cache_dir, 'VDBs')
cache_path.mkdir(parents=True, exist_ok=True)
return str(cache_path)
def get_use_link():
preferences = get_preferences()
return preferences.node_import_method == "LINK"

96
build.py Normal file
View File

@@ -0,0 +1,96 @@
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",
"tifffile==2024.7.24"]
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()

99
docs/SARS-Cov-2.md Normal file
View File

@@ -0,0 +1,99 @@
# SARS-Cov-2
![alt text](assets/SARS-Cov-2/image.png)
![alt text](assets/SARS-Cov-2/image-1.png)
In this tutorial, we'll make a structural visualzation of the SARS-Cov-2 Virus. Please see [step by step](step_by_step.md) to make sure you have a basic understanding of how to use the addon, and make sure the addon version is v1.0.2 or higher!
Research data is from paper published in September 2020 in Cell Press ([https://www.cell.com/cell/fulltext/S0092-8674(20)31159-4](<https://www.cell.com/cell/fulltext/S0092-8674(20)31159-4>)) by Li Sai team at Tsinghua University. This research revealed the molecular assembly of the authentic SARS-CoV-2 virus using cryoelectron tomography (cryo-ET) and subtomogram averaging (STA). Here is an official video of this study
<iframe width="560" height="315" src="https://www.youtube.com/embed/JSKl5VY4k5w?si=t4LhrCRebxrX_Saf&amp;controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
"It remains enigmatic how coronaviruses pack the ∼30-kb RNA into the ∼80-nm-diameter viral lumen. Are the RNPs ordered relative to each other to avoid RNA entangling, knotting, or even damage or are they involved in virus assembly?" Three assembly classes were proposed in the paper, we will make a visualzation of the "eggs-in-a-nest"-shaped RNPs assembly, based on real cryo-ET data.
## Download and import data
The data of this research is hosted on EMDB ([https://www.ebi.ac.uk/emdb](https://www.ebi.ac.uk/emdb)), and the website of SARS-Cov-2 virus is [here](https://www.ebi.ac.uk/emdb/EMD-30430). Open the page, find "Download" button and select the first selection "3D Volume (.map.gz)".
In case you can't download data, you can download here.
[SARS-Cov-2.map](https://drive.google.com/file/d/1LMybsmTVbwQ38_eqAx6hbZTc5p2fdcjK/view?usp=sharing)
After downloading, put it into any directory and import the data. In the import options, it is recommended to adjust the Bioxel Size to 5, in order to reduce the shape of the data to increase the speed of calculation and rendering. If you are confident in the performance of your hardware, you can leave the original 2.72 unchanged. After adjusting the Bioxel Size, the shape of the converted data will be calculated below based on the current bioxel size. The amount of data will increase exponentially with larger shape, and may result in OOM (out of memory).
## Cutout the Virus
After importing, you can get the virus by creating and connecting nodes and setting parameters as shown below
![alt text](assets/SARS-Cov-2/image-2.png)
Place the light and the camera in the way as below. The backlight is a area light, color white, intensity 500W, spread 90°, and inside the virus, place a point light, color `FFD08D`, intensity 5W. Camera focal length is 200 mm. the background color is pure black. The Look of the color management is High Contrast.
![alt text](assets/SARS-Cov-2/image-3.png)
Continue editing the node, followed by "Set Properies" and "Set Color by Ramp 4" node, the parameters of these two nodes are set as shown in the figure, where the color part, from top to bottom, is set to `E1D0FC` (1.0), `FFE42D` (0.5), `3793FF` (0.5), `FFFF8EC` (0.1) (alpha value in parentheses). Color alpha also affects density.
![alt text](assets/SARS-Cov-2/image-4.png)
If you feel that rendering is too slow, see [here](improve_performance.md) for suggestions
## Color the RNPs alone
The current rendering is pretty good, but considering that my goal is to make it clear how the RNPs are arranged within the virus, the RNPs should be colored differently than the others. So how to color the RNPs alone?
First of all, we have to separate the RNPs. The value of RNPs is between the membrane and S protein, so it is very difficult to separate. The good thing is that the virus is spherical, we can separate the RNPs part of the virus from the rest by using a sphere cutter. To do so, in the Geometry Nodes panel menu of the container, click **Bioxel Nodes > Add a Cutter > Sphere Cutter**, and adjust the scale and position of the newly created sphere object named "Sphere_Cutter" appropriately, so that it can just separate the internal RNPs and membrane of the virus.
![alt text](assets/SARS-Cov-2/image-5.png)
The process of changing the position of the "Sphere_Cutter" object can be very laggy, then you can turn off "With Surface" and do it in Slice Viewer mode. Once you are happy with it, you can restore the settings.
Then we turn on Invert in the "Sphere Cutter" node and you can see the opposite result. This way we've managed to separate the two, next let's color them separately and finally merge them with Join Component. Create and join the nodes as shown below, where the second Cutter node is copied and the color value in Set Color is `FFDDFE` (0.5). If all is well, the rendering should look like below.
![alt text](assets/SARS-Cov-2/image-6.png)
At this point, the rendering of a SARS-Cov-2 virus is complete!
## Render RNPs and Membranes
Realistic rendering is great, but Non-photorealistic rendering (NPR) is better for presenting structural information. So I will render ultrastructure of the RNPs assembly with toon shader.
![alt text](assets/SARS-Cov-2/image-7.png)
The silhouettes of RNPs uses Blender's Line Art feature, but Line Art must be based on a mesh. Select the nodes that are extruding the RNPs (i.e., the "Cut" nodes that are in charge of cutting out the RNPs), and right-click, click **Bioxel Nodes > Extract Mesh**. This gives you a new mesh object of the RNPs.
Extract the membrane mesh in the same way.
The membrane has a much higher value than the rest of the virus, so let's create a new "Cutout" node, also after the ReCenter, but with the threshold adjusted to 0.13. After the Cutout, connect to the "To Surface" node and parameterize it as shown in the figure, noting that the Remove Island Threshold is set to 1000 so that any fragments smaller than 1000 pts be eliminated. Then select the "To Surface" node, and perform the operation of extracting the mesh.
![alt text](assets/SARS-Cov-2/image-8.png)
The mesh of the membrane needs to be cut in half, this can be done with the Box Trim brush in sculpt mode or with the Boolean modifier. the mesh of the RNPs may also need some repair work, I cleaned up some of the broken surfaces of the RNPs. all the meshes are now ready to be used.
![alt text](assets/SARS-Cov-2/image-9.png)
For Line Art, please see the video tutorial from Blender Secrets.
<iframe width="560" height="315" src="https://www.youtube.com/embed/aIWdBq7-ias?si=CrcStx5VVJBwDpzu&amp;controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
Finally, an infographic on the ultrastructure of RNPs is done!
![alt text](assets/SARS-Cov-2/image-1.png)
If you have trouble following the docs, you can download the project files.
[SARS-Cov-2.zip](https://drive.google.com/file/d/15GpIoIjVAE-Jr98zWo7oupuk1KfRVPmk/view?usp=sharing)
## Homework
Other data relevant to the study are provided in the paper, you could try visualizing them.
![alt text](assets/SARS-Cov-2/image-10.png)
Go to the official EMDB website https://www.ebi.ac.uk/emdb/ and enter the EMD number in the search box to get them.
The Electron Microscopy Data Bank (EMDB) is a public repository for cryogenic-sample Electron Microscopy (cryoEM) volumes and representative tomograms of macromolecular complexes and subcellular structures. If you want to get data on other virus, just enter their name in the search box. For example, hepatitis B virus, enter it into the search box, then select Virus in the Sample Type on the left side, and download the data you need (it is better to find the corresponding paper to clarify the data information).
**Try to visualize hepatitis B virus.**
![alt text](assets/SARS-Cov-2/image-11.png)

118
docs/advanced_usage.md Normal file
View File

@@ -0,0 +1,118 @@
# Advanced Usage
## Concepts
In the previous section, I mentioned some key concepts, I'll describe them in detail so that you can better understand the following operations.
### Container
First is the **Container**, it is the easiest to describe, you can think of it as a scene, or a solver container. it is the stage that carries all stuffs.
### Layer
Second is the **Layer**, which represents different fields of data under the same location in container of the same subject. Just as view layers in any map app, one as roads, one as satellite view, while in Bioxel, one as MRI, one as CT, one as electrical signals, etc. Some layers are for material, such as color, density, etc., we call them **Material Layer**. One container contains different layers of one subject each. Any external data imported into the container is converted into the layer.
### Component
Finally, the **Component**, which is a fragment of **Material Layer**. Multiple materials (Matters) may exist within the same area with different meanings of presentation, like objects in blender, each representing a part of the scene. Bioxel Nodes provides a series of nodes and tools that allow the user to create material layers (Components) based on the other layers (serve as source data), In the previous section, we used the CT data (layer) to make the material (matter) of skull, this process known as materialization.
## Attache the Muscles to the Skull
Multiple components can exist in one container, just as multiple objects can exist in one scene. They can be controlled individually or combined with each other to build complex scenes. Next, let's try cutouting muscle from the VHP anatomical images and attaching the muscles to the skull that created in the previous section. The anatomical images are very large, so I've cropped and scaled them down to 1/4 of their original size for ease of use. Download the file and unzip it into a new directory.
[VHP_M_AnatomicalImages_Head.zip](https://drive.google.com/file/d/164AjQX0tmgUpWZlleJ5i1IKHco4ytDu2/view?usp=drive_link)
You can also download the data directly from the official website, [https://data.lhncbc.nlm.nih.gov/public/Visible-Human/Male-Images/PNG_format/head/index.html](https://data.lhncbc.nlm.nih.gov/public/Visible-Human/Male-Images/PNG_format/head/index.html)
First import the CT data as described in [step by step](step_by_step.md). Then import the anatomical images, but unlike before, this time from the Geometry Nodes menu of the existing container (not the top menu), click **Bioxel Nodes > Import Volumetric Data (Init) > as Color**, locate to the unzipped folder, and select any .png file (do not multi-select, and do not select the folder). This way the imported layer will be in the same container as the previous CT data. You can also import by dragging it into the container geometry node interface instead of the 3D Viewport interface. Also note that **as Color** (not as Scalar) is clicked because the anatomical images are RGB data.
![alt text](assets/advanced_usage/image.png)
Let's take more time in the import options this time 🫡, first let's talk about what the **Original Spacing** is in the options, you can simply think of it as the original "pixel size" of a image, since the volume is 3D, its pixels (i.e., voxels) are cuboids with a length, width, and height rather than 2D rectangles. And their length, width, and height size is the **Spacing**.
Normally, Bioxel Nodes can read the spacing from the data header, and you don't need to fill in the value, but there is no record of that in any PNG image, so you need to input it manually. in the official description of the VHP, it is mentioned that each pixel of the anatomical images corresponds to the size of .33mm, and the thickness of each slice is 1mm, so the Original Spacing of the anatomical images should be set to `(0.33, 0.33, 1)`.
However, the file I provided is scaled by 1/4 for each image, so the X and Y axes need to be multiplied by 4. So the final Original Spacing should be set to `(1.33, 1.33, 1)`, which should be understandable, right? (If you are importing an official file, then it should still be set to `(0.33, 0.33, 1)`)
Layer Name should be set to "Anatomical Images", other options remain default, click Ok.
![alt text](assets/advanced_usage/image-1.png)
After importing, connect the "Cutout by Hue" node (Add > Bioxel Nodes > Component > Cutout by Hue) after the newly created "Fetch Layer" node, and turn on the Slice Viewer mode preview, as shown below.
![alt text](assets/advanced_usage/image-2.png)
In addition to spacing, the Dicom file also records the subject's position and orientation, but those information is apparently not recorded in PNG format. This results in a mismatch between the CT data and the anatomical images in position. We need to manually align this two layers: First, set the threshold of the "Cutout by Threshold" node to 0.2 so that the full head can be cutouted and displayed. Then add "ReCenter" node before "Cutout by Hue" node and "Cutout by Threshold" node each, then add Locater to the "anatomical images" workflow (see [step by step](step_by_step.md) for details), and finally add "Join Component" node (Add > Bioxel Nodes > Component > Join Component), and connect them as shown below, with the parameters set as shown.
![alt text](assets/advanced_usage/image-3.png)
The "Join Component" node allows you to merge two components together. Move and rotate the Locater object so that the two components are exactly the same in position. If you're having trouble matching them up, here's the exact transform values to fill in the Locater object properties.
![alt text](assets/advanced_usage/image-4.png)
Next let's cutout the muscles individually.
First let's talk about the "Cutout by Hue" node. Literally, it cutout regions by hue of color. If you select this node and press "M" key to temporarily mute it, you'll see that the data becomes a cube with blue parts in Slice Viewer mode. the default value of Hue in the "Cutout by Hue" node is red, meaning that the node retains areas of the body that are close to red, and strip out the blue, which is on the opposite side of the red in the hue ring, leaving the human body parts.
If you turn down the value of Sensitivity, you'll see that the visabile part is decreasing, leaving only the part more closer to red, and the muscle happens to be red, so we can easily cutout the muscle. The color of the specimen tissue is not as vibrant as it is in the live state, and we can fine-tune the color to restore the vibrancy by using the "Set Color by Color" node (Add > Bioxel Nodes > Property > Set Color by Color).
To finally put it all together, connect and set the nodes as shown below, where the two colors in "Set Color by Ramp 2" node are `E7D2C5` and `FFEDEC`. If everything is working correctly, you'll get the rendering below.
![alt text](assets/advanced_usage/image-5.png)
You can also add "Transfrom" and "Cutter" nodes at the end and all components.
![alt text](assets/advanced_usage/image-6.png)
The multimodal data of VHP do not have any deformation or scaling differences, but only positional differences, so all data can be aligned easily. However, in most cases, the differences of different modalities are so great that they need to be registered first, which is not possible with Bioxel Nodes at the moment (but it is in plan). Nevertheless, you can build different components based on the same layer and combine them together. For example, based on the CT data, with the "Cutout by Range" node, you can eliminate the low value regions (fat), eliminate the high value regions (bone), and you can still get the muscle. And then attach it to the skull.
![alt text](assets/advanced_usage/image-7.png)
However, the CT values of brain and muscle are very close to each other, so it is extremely difficult to separate the brain from the muscle by threshold. So how should cutout the brain?
## Cutout the Brain
We can use AI technology to realize the division of brain regions, this process is called Segmentation, and the brain regions are as label. There are many models and methods for medical image segmentation, here we recommend [Total Segmentator](https://github.com/wasserth/TotalSegmentator).
It is an out-of-the-box CT/MRI medical image segmentation Python library. You can deploy Total Segmentator locally or process your data through the official online site [https://totalsegmentator.com](https://totalsegmentator.com).
Let me demonstrate the operation, we are still using the CT data from VHP. Click on "Drop DICOM.zip or ..." and select the VHP_M_CT_Head.zip file. Enter "brain" below "Selected task", press Enter to confirm. Finally click "Process data" button.
![alt text](assets/advanced_usage/image-8.png)
Wait for the uploading and processing, after all, you will be redirected to the following page, click "Download results" button, download and unzip segmentations.zip to any folder directory.
![alt text](assets/advanced_usage/image-9.png)
In case you can't get the brain segment (label), you can download it directly (without unzipping it).
[VHP_M_CT_Head_Brain.nii.gz](https://drive.google.com/file/d/1KGiZ3G11YLXkGszSrvWQ9kTF4TMpxUPd/view?usp=drive_link)
Next we import the brain segment (label) into the container in the same way as we imported the anatomical imagess(under the same container), but this time selecting **as Label**, with the Layer Name set to "Brain", and leaving the other options as default. Once the import is complete, a new "Fetch Layer" node will be added. Create and set the node as shown below, and you should be able to see the surface of brain.
![alt text](assets/advanced_usage/image-10.png)
Do you see the "step" like shape on surface? this is due to the low precision of the AI segmentation results. So How to addressing the issue? Select the Fetch Layer node of the brain label, right-click to pop up menu, click **Bioxel Nodes > Resample Value**, in the dialog box, change the "Smooth Size" to 3 or higher (the higher it is, the slower the calculation will be), click OK and wait for the calculation to finish. When it is done, a new layer will be added, replace the original one, is it smoother? The following figure lists different levels of smoothing.
![alt text](assets/advanced_usage/image-11.png)
The smoother it is, the more comfortable it looks, but it also means that more details are lost and the calculation is slower.
But if you cut the brain, you will find that the cross-section is homogeneous, this is because the label type layer is does not contain any modality data itself. Bioxel Nodes provides a function to fill in any layer values by label, if you set the value below brain tissue in the non-brain region of CT data, then any "Cutout" node will only keep the brain region.
The operation is like this, select the "Fetch Layer" node of the CT data, right-click to pop up the menu, click **Bioxel Nodes > Fill Value by Label**, in the dialog box, Label is set to the newly imported brain label layer, Fill Value is set to -100 (as long as it is less than the Hu value of the brain tissue), click OK. wait for the data processing, a new data layer will be added to the container when it is finished.
As shown in the following figure, connect the nodes and set the node parameters, in which the two colors of "Set Color by Ramp 2" node are `B26267` and `EBC5B7`, and the "Cutout by Range" and "Set Color by Ramp 2" node are changed to "Value" mode (the default is "Factor" mode), because the maximum and minimum values of the brain are too close to each other and difficult to adjust in "Factor" mode. To make the brain tissue look more transparent, I add the "Set Properties" (Add > Bioxel Nodes > Property > Set Properties) node at the end and changed the "Density" to 0.5.
![alt text](assets/advanced_usage/image-12.png)
(Still feel not smooth enough? It's really hard to get rid of "step" surface completely at the moment)
Now the brain is done. Finally we combine all together and you get something like the following.
![alt text](assets/advanced_usage/image-13.png)
🤗 If you've followed the docs up to this point, give yourself a round of applause, you've mastered this addon! If you have trouble following the docs instruction, you can download the project files to understand how the nodes work.
[VHP.zip](https://drive.google.com/file/d/1EHB7sxNSvJNwmoTfFnpyOSsm2M_2Y0hO/view?usp=sharing)
AI segmentation is an essential part of volumetric data visualization. Therefore, the future Bioxel Nodes will integrate some commonly used AI segmentation models, so that users can complete the volumetric data visualization in place!

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
docs/assets/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.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: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

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