85 Commits

Author SHA1 Message Date
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
91 changed files with 10320 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

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

@@ -0,0 +1,156 @@
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.minimum_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.minimum_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 = ski.resize(data[f, :, :, :],
# shape,
# preserve_range=True,
# anti_aliasing=data.dtype.kind != "b")
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)

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

@@ -0,0 +1,434 @@
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:
return filepath.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(" ")
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,260 @@
from ast import literal_eval
from pathlib import Path
import bpy
from ..constants import 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 = get_node_lib_path(addon_version)
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

View File

@@ -0,0 +1,171 @@
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,
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
try:
output_node = get_nodes_by_type(node_group,
'NodeGroupOutput')[0]
except:
output_node = node_group.nodes.new("NodeGroupOutput")
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,70 @@
from pathlib import Path
import bpy
from .common import get_file_prop, get_node_lib_path, set_file_prop
from ..exceptions import Incompatible, NoFound
from ..constants import VERSIONS
def get_node_group(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 = get_node_lib_path(addon_version)
if get_file_prop("node_version") is None:
set_file_prop("node_version", addon_version)
local_lib_path = 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_path = lib_path
break
# local lib first
lib_path = local_lib_path or addon_lib_path
bpy.ops.wm.append('EXEC_DEFAULT',
directory=f"{lib_path.as_posix()}/NodeTree",
filename=node_type,
link=use_link,
use_recursive=True,
do_reuse_local_id=True)
node_group = bpy.data.node_groups.get(node_type)
if node_group is None:
raise NoFound('No custom node found')
return node_group
def assign_node_group(node, node_type: str):
node.node_tree = bpy.data.node_groups[node_type]
node.width = 200.0
node.name = node_type
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
get_node_group(node_type, use_link)
node = node_group.nodes.new("GeometryNodeGroup")
assign_node_group(node, node_type)
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.0"
name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender"
maintainer = "Ma Nan <icrdr2010@outlook.com>"
type = "add-on"
website = "https://omoolab.github.io/BioxelNodes/latest"
tags = ["Geometry Nodes", "Render", "Import-Export"]
blender_version_min = "4.2.0"
license = ["SPDX:MIT"]
copyright = ["2024 OmooLab"]
platforms = ["windows-x64", "linux-x64", "macos-arm64", "macos-x64"]
wheels = [
"./wheels/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-macosx_11_0_arm64.whl",
"./wheels/SimpleITK-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/lxml-5.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"

226
bioxelnodes/constants.py Normal file
View File

@@ -0,0 +1,226 @@
from pathlib import Path
VERSIONS = [{"label": "Current", "node_version": (1, 0, 0)},
{"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()
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': '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 Surface',
'icon': 'OUTLINER_OB_MESH',
'name': 'FetchSurface',
'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)

419
bioxelnodes/menus.py Normal file
View File

@@ -0,0 +1,419 @@
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, ExtractShapeWire,
SaveContainerLayersCache, RemoveContainerMissingLayers,
SaveContainer, LoadContainer,
AddPieCutter, AddPlaneCutter,
AddCylinderCutter, AddCubeCutter, AddSphereCutter,
ExtractBboxWire, ExtractSurface, ExtractVolume, ContainerProps)
from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor)
from .operators.misc import (CleanTemp,
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)
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(ExtractSurface.bl_idname)
layout.operator(ExtractVolume.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.operator(SaveNodeLib.bl_idname)
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.menu(RenderSettingMenu.bl_idname)
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(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,528 @@
import bpy
import bmesh
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_nodes_by_type, 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 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_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])
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractSurface(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.fetch_mesh"
bl_label = "Extract Surface"
bl_description = "Extract Surface"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Surface"
class ExtractVolume(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.pick_volume"
bl_label = "Extract Volume"
bl_description = "Extract Volume"
bl_icon = "OUTLINER_OB_VOLUME"
object_type = "Volume"
class ExtractShapeWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.pick_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.pick_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=name,
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_nodes_by_type(node_group,
'NodeGroupOutput')[0]
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 = name
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_nodes_by_type(node_group,
'NodeGroupOutput')[0]
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_nodes_by_type(node_group,
'NodeGroupOutput')[0]
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'}

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

@@ -0,0 +1,967 @@
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 Label"
bl_description = "Import Volumetric Data to Container as Label"
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", "Labels", ""),
("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'}
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(" ")
if stirng in ["No study description",
"No series description",
""]:
return "Unknown"
else:
return stirng
except:
return "Unknown"
study_description = get_meta("0008|1030")
series_description = get_meta("0008|103e")
series_modality = get_meta("0008|0060")
size_x = get_meta("0028|0011")
size_y = get_meta("0028|0010")
count = get_meta("0020|0013")
count = count if count != "Unknown" else 1
# some series image count = 0 ????
if int(count) == 0:
continue
# series_id cannot be "" in blender selection
if series_id == "":
series_id = "empty"
series_items[series_id] = "{:<20} {:>1}".format(f"{study_description}>{series_description}({series_modality})",
f"({size_x}x{size_y})x{count}")
for key, value in series_items.items():
series_item = self.series_ids.add()
series_item.id = key
series_item.label = value
if len(series_items.keys()) > 1:
self.skip_series_select = False
context.window_manager.invoke_props_dialog(self,
width=400,
title=title)
return {'RUNNING_MODAL'}
elif len(series_items.keys()) == 1:
self.series_id = list(series_items.keys())[0]
else:
self.report({"ERROR"}, "Get no vaild series.")
return {'CANCELLED'}
if self.skip_read_as:
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
read_as: bpy.props.EnumProperty(name="Read as",
default="SCALAR",
items=[("SCALAR", "Scalar", ""),
("LABEL", "Labels", ""),
("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)
try:
layer = Layer(data=data == np.full_like(data, 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
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'
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")
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,612 @@
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, LayerOperator):
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 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 Iteration",
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:
scalar_min = get_layer_prop_value(layer_obj, "min")
self.fill_value = min(scalar_min, 0)
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, 3)
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, 3)
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 Iteration",
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, 3)
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,254 @@
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 NODE_LIB_DIRPATH, 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)
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"]
lib_path = get_node_lib_path(node_version)
lib_dir = bpy.path.abspath(self.lib_dir)
local_lib_path: Path = Path(lib_dir, lib_path.name).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'}

View File

@@ -0,0 +1,60 @@
import bpy
from ..bioxelutils.common import is_incompatible, local_lib_not_updated
from ..bioxelutils.node import assign_node_group, get_node_group
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'}
get_node_group(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_group(node, self.node_type)
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()

3
docs/advanced.md Normal file
View File

@@ -0,0 +1,3 @@
# Advanced
WIP

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
docs/assets/cover.png Normal file

Binary file not shown.

After

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,39 @@
# Improve Performance
Volume reconstruction and volume rendering are very consuming computation, I believe that this add-on's experience is terrible on poorly hardware. here are tips to improve the add-on performance.
## Use Low-Res Data as Preview
The addon provides data resample function, the operation is as follows. First, in the container's geometry node, select the layer you need to resample, right-click **Bioxel Nodes > Resample Value**, in the dialog box, change the "Bioxel Size" value to twice or more than the current value, click OK. the addon will create a low-res version of the layer and load it into the container's geometry node.
![alt text](assets/improve_performance/image.png)
You can see that the speed of the reconstruction has been reduced from 240 ms to 37 ms, making it possible to compute in almost real-time, and improving the speed of the feedback of the changing the parameters. Once you are satisfied with the adjustment, you can then connect the original layer to the nodes. This tip can greatly improve the operation experience of the node.
## Raise the Step Rate of a Container
Step rate is a key setting in volume rendering. The higher the step rate, the faster the rendering will be, but at the same time, the thinner the volume will look. If you want to have a very thin effect, or if you don't need to cut through component, you can always set the step rate high, you can adjust step rate in the render settings globaly. However, I recommend you to adjust the step rate of the container separately, so that it doesn't affect the rendering of the other volume in the Blender file, do as follows.
In the container's geometry nodes panel menu, click **Bioxel Nodes > Change Container Properties**, in the dialog box, raise the "Step Rate" value (up to 100) and click OK.
![alt text](assets/improve_performance/image-1.png)
You can see that the rendering speed is much higher, but at the same time the cuts look blurry and transparent.
## Balance rendering settings
I list some of the settings that affect the volume rendering most, so you can find the most balanced settings for your needs.
- **Light Paths > Max Bounces > Volume** affects the number of bounces a volume has, the higher the value, the more transparent it will look.
- **Light Paths > Max Bounces > Transparent** affects the number of times transparent surfaces are transmitted, the higher the value, the more transparent it will look.
- **Volumes > Step Rate Render | Viewport** affects the volume rendering step, the smaller the value, the more detailed the volume will look
Bioxel Nodes provides some render setting presets for quick setup. In the top menu, click **Bioxel Nodes > Render Setting Presets**, including Performance (left), Balance (center), and Quality (right). Here is a comparison of them.
![alt text](assets/improve_performance/image-2.png)
## Rendering with EEVEE
EEVEE doesn't render volume as well as Cycles, but it does have the advantage of rendering the volumetric data, and is even a better choice if you want to get a clear view of slice. And EEVEE's real-time rendering make it possible to create interactive stuff in Blender.

66
docs/index.md Normal file
View File

@@ -0,0 +1,66 @@
[中文文档](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

19
docs/installation.md Normal file
View File

@@ -0,0 +1,19 @@
# Installation
**Currently only support Blender 4.2 or above, make sure you have the correct version of Blender.**
## Install in Blender (recommended)
This is the most recommended way, in the top menu, click **Edit > Preferences**, in "Get Extensions" Section, enter "bio" in the search box, then click Install. due to the add-on size (25MB~50MB) you may need to wait a while.
![alt text](assets/installation/image.png)
## Blender Official Extensions Website
Or you can visit the Blender official extensions website at [https://extensions.blender.org/add-ons/bioxelnodes/](https://extensions.blender.org/add-ons/bioxelnodes/)
Click Get Add-on, open Blender, drag in Blender and follow the instructions to install.
## Manual Install
You can also install _BioxelNodes\_{version}.zip_ manually by downloading from [bere](https://github.com/OmooLab/BioxelNodes/releases/latest).
In the top menu, click **Edit > Preferences**, **Add-ons > Install from Disk** and select the Zip file you just downloaded.

15
docs/qa.md Normal file
View File

@@ -0,0 +1,15 @@
# Q & A
## Nothing in the scene when rendering?
If you're using the Cycles renderer with GPU, make sure your GPU supports "Optix", as Bioxel Nodes relies on OSL (Open shader language) for volumetric rendering, otherwise you'll have to use the CPU for rendering.
If you're using the EEVEE renderer, this issue is due to EEVEE not loading shaders for some unknown reason, save the file and restart Blender to fix it.
## After updating the addon, the nodes in the file have turned red?
This is because the version of the nodes in the file does not match the current version of the addon after updating.
If you still want to edit the file with Bioxel Nodes, you can only roll back the addon version to the version that corresponds to the nodes.
If you just want the file to work and you won't rely on Bioxel Nodes any more, then you can just click **Bioxel Nodes > Relink Node Library >** in the top menu and select the corresponding version. Check to see if the nodes are working and rendering properly, and when everything is OK, click **Bioxel Nodes > Save Node Library** and select the save location in the dialog box.

101
docs/step_by_step.md Normal file
View File

@@ -0,0 +1,101 @@
# Step by Step
## Download Data
Here is the open data from [Visible Human Project (VHP)](https://www.nlm.nih.gov/research/visible/visible_human.html) for you to learn how to use this addon. It is a CT scan image of a male head, download and unzip it to any location for later usage.
[VHP_M_CT_Head.zip](https://drive.google.com/file/d/1kcnEwwDj-5X4U8330G_FNfWFS8f-892R/view?usp=sharing)
VHP original radiology data was shared in the proprietary format that predated DICOM, complicating its use. Now all data has been harmonized to standard DICOM and released in [NCI Imaging Data Commons (IDC)](https://portal.imaging.datacommons.cancer.gov/), you also can download data from IDC by yourself.
## Import Data
In the top menu, click **Bioxel Nodes > Import Volumetric Data (Init) > as Scalar**, locate to the unzipped folder, select any DICOM file (don't select more than one, and don't select the folder either), and click **Import as Scalar**.
![alt text](assets/step_by_step/image.png)
You can also choose to import data by dragging one DICOM file into the 3D viewport (some DICOM files don't have extension suffix and can't be dragged in).
The process of reading the data may take a while, the bottom right corner of the Blender interface will show you the progress of the reading, and a dialog box will pop up when the reading is successful. Ignore all the options for now and just click OK, I will explain the purpose of these options later.
The import process involves data conversion, so there is more waiting time, and the progress of the import is displayed in the lower right corner of the interface. After importing, the addon will create a new object, with a new geometry nodes that will serve as a "workbench" for manipulating the data, the new object is called the **Container**. The newly imported data is stored in the Container as a **Layer**, which is loaded into the Container's Geometry node via the "Fetch Layer" node (the red one), and be converted into a **Component** that can be rendered.
![alt text](assets/step_by_step/image-1.png)
Next, let's preview the data in Blender.
In the container's geometry nodes panel menu, click **Bioxel Nodes > Add a Slicer**. The addon will insert a node named "Slice" between the "Fetch Layer" and the "Output" and create a new plane object named "Slicer". Click the "Slice Viewer" button in the upper right corner of the 3D viewport to enter the preview mode, then move and rotate the "Slicer" object. You will see a slice image of the data, which should be familiar to anyone who has used any DICOM viewing software.
![alt text](assets/step_by_step/image-2.png)
(If the volume disappears when you click the "Slice Viewer" button, save the file and restart Blender, it should be fixed, or you can turn on the Cycles rendering to see the slicer plane as well. The issue is caused by EEVEE's failure of reloading the shaders.)
The "Slicer" node is used to display the slices of the data, with an external object as the location of the slice plane. This step is not necessary for visualization, but it provides a quick way to preview the data in Blender for user perception. Next, let's turn the volumetric data into a renderable object.
## Cutout the skull
Bone tends to have much higher CT values than soft tissue, so you can split bone and soft tissue by setting a threshold. In the Geometry Nodes panel menu of the container, click **Add > Bioxel Nodes > Component > Cutout by Threshold** to add a "Cutout" node and connect it between the "Fetch Layer" node and the "Output", and then set the "Threshold" parameter of the "Cutout by Threshold" node to 0.3 and turn on the "With Surface" option. Switch to viewport shading to "Render Preview". The node graph and the render result are shown below.
![alt text](assets/step_by_step/image-3.png)
If you want the render result to be consistent with the above image, you also need to change the default light object type from "Point" to "Area" to increase the brightness, and change the Look in Color Management to "High Contrast".
You can understand the role of the "Threshold" parameter in shaping the output by changing it. If you feel very laggy while draging the parameter, you can temporarily turn off the "With Surface" option in the "Cutout" node. When you are satisfied, turn it on again. In addition, the volume rendering is very computationally intensive, which is also a major cause of the lag, so you can adjust the parameters in "Slice Viewer" mode first, and then do the Cycles rendering when you are satisfied.
You may consider using GPU for faster rendering, but please note that Bioxel Nodes only supports Optix GPUs due to its dependency on OSL (Open shader language), and the first time you turn on GPU rendering, you may need to wait for Blender to load the appropriate dependencies (the screen will get stuck), so please be patient.
Although the output component looks like a mesh object, it retains its internal information through its volume, so when you cut through the component, you should see an inhomogeneous cross-section made up of volume, rather than just an empty shell like any mesh object. Let's cut the skull to see its complex internal structure.
## Cut and Color
In the container's geometry nodes panel menu, click **Bioxel Nodes > Add a Cutter > Plane Cutter**, the addon will insert a "Cut" node and a "Object Cutter" node. Also, it will create a new plane object named "Plane Cutter" to the scene, at this point you should be able to see that the skull has been cut through as expected.
Just like the "Slicer" object, move and rotate the "Plane Cutter" and the position and direction of the cut will change accordingly. Please adjust the Cutter object to a vertical orientation so that it cuts the skull vertically, as shown below.
![alt text](assets/step_by_step/image-4.png)
The CT value of the skull is inhomogeneous, which reflects the difference in substance density of the bone. We can enhance the display of this difference by coloring. In the Geometry Nodes panel menu of the container, click **Add > Bioxel Nodes > Propetry > Set Color by Ramp 5** to add the "Set Color" node and connect it after any node. Set the node's parameters "From Min" to 0.3 and "From Max" to 0.5, as shown below.
![alt text](assets/step_by_step/image-5.png)
When rendering is turned on, you can clearly see that the calvaria and teeth are colored red, meaning that the bone in these areas is denser. You can try to adjust the parameters of the "Set Color" node to recognize their roles. If you feel laggy when draging the parameters, you can temporarily turn off "With Surface" and switch to "Slice Viewer" mode.
## Transformation
You may find that the position of the skull is a bit off the origin, this is due to the fact that the addon keeps the position information from the original data record during the import process. If you need to change the position, do not move the container (object) directly as you would in 3D viewport; the addon provides dedicated "Transform" node to handle transformations.
In the Geometry Nodes panel menu of the container, click **Bioxel Nodes > Add a Locator**, the addon will insert the "Transform Parent" node and create a new empty object named "Locator". If you move, rotate, or scale the "Locator", the skull will Transform as well. If you also want the origin of the rotational transformation to be set at the geometric center of the skull, just add a "ReCenter" node (Add > Bioxel Nodes > Transform > ReCenter) in front of the "Transform Parent" node, as shown below.
![alt text](assets/step_by_step/image-6.png)
Being used to moving objects directly in the 3D viewport, you may find it strange to have an extra step to transform component like this. This is in consideration of the fact that there may be multiple components involved in one container with different transform needs, as well as future development plans for resampling mechanism, which I'll explain in more detail later.
## Surface Mesh
A mesh, made up of vertexs and faces, is the "greatest common" in the 3D world. Therefore, in order to be compatible with other 3D workflow, the addon provides the "To Surface" node (Add > Bioxel Nodes > Component > ToSurface), which converts the component into a mesh. Note that the surface mesh is not editable in the container's geometry node, and can only be connected to "Transform" and "Shader" nodes.
As shown below, you can connect "Slime Shader" node (Add > Bioxel Nodes > Surface > Slime Shader) after the "To Surface" node to give the surface a shader, or connect "Transform" nodes.
![alt text](assets/step_by_step/image-7.png)
If you want to edit the mesh, click **Bioxel Nodes > Extract from Container > Extract Surface** in the Geometry Nodes panel menu of the container. The addon will create a new object with Geometry Nodes, in which a "Fetch Surface" node to read the surface mesh. You can apply this geometry node to edit the mesh as usual. Or you can leave the geometry node in place and keep it connected to the original container in real time.
![alt text](assets/step_by_step/image-8.png)
(To show it better, I made an X-axis offset to the newly created mesh, otherwise the container and the mesh are perfectly overlapping.)
## Save Temporary Files
The layers cache is stored in a temporary folder, while the addon's custom nodes are linked to the node library file in addon directory. Both of them are exist locally by default, and if you only deliver the Blender file to other devices, the file will not work properly because the resources are missing.
Therefore, you need to save all temporary files before you deliver the Blender file. The procedure is simple:
1. Save the Blender file.
2. in the top menu, click **Bioxel Nodes > Save Node Library**, and set the relative path.
3. in the top menu, click **Bioxel Nodes > Save All Layers Cache** and set the relative path.
Zip the Blender file together with the local node library file and the layer cache files (there may be more than one).
![alt text](assets/step_by_step/image-9.png)
🤗 If you can follow the documentation up to this point, you're already started!

View File

@@ -0,0 +1,20 @@
[data-md-color-scheme="slate"] {
--md-code-hl-string-color: #8b8b8b;
--md-code-hl-keyword-color: white;
--md-code-hl-number-color: white;
--md-typeset-a-color: white;
}
[data-md-color-scheme="default"] {
--md-code-hl-string-color: #444444;
--md-code-hl-keyword-color: black;
--md-code-hl-number-color: black;
}
[data-md-color-scheme=slate][data-md-color-primary=black] {
--md-typeset-a-color: white;
}
[data-md-color-scheme=default][data-md-color-primary=black] {
--md-typeset-a-color: black;
}

39
docs/support_format.md Normal file
View File

@@ -0,0 +1,39 @@
# Support Format
Can't wait to play with the addon using your own data? Here's a list of the formats currently supported.
| 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 |
Despite my best efforts, I still can't achieve perfect support for all volumetric data formats, and I will continue to work on this.
## Open Databases
If you don't have volumetric data, then you can download from some open databases.
**Note that just because they are open and available for download does not mean you can use them for anything! Be sure to look at the description of the available scopes from website.**
| Source | Object |
| ------------------------------------------------------------------------------------ | ------------------ |
| [MorphoSource](https://www.morphosource.org/) | Open Research Data |
| [Dryad](https://datadryad.org) | Open Research Data |
| [Cell Image Library](http://cellimagelibrary.org/home) | Cells |
| [OpenOrganelle](https://openorganelle.janelia.org/datasets) | Cells |
| [Allen Cell Explorer](https://www.allencell.org/3d-cell-viewer.html) | Cells |
| [EMDB](https://www.ebi.ac.uk/emdb/) | Protein, Viruses |
| [IDC](https://portal.imaging.datacommons.cancer.gov/explore/) | Medical Images |
| [Embodi3D](https://www.embodi3d.com/files/category/37-medical-scans/) | Medical Images |
| [Github](https://github.com/sfikas/medical-imaging-datasets) | Medical Images |
| [NIHR](https://nhsx.github.io/open-source-imaging-data-sets/) | Medical Images |
| [Medical Segmentation Decathlon](http://medicaldecathlon.com/) | Medical Images |
| [Visible Human Project](https://www.nlm.nih.gov/research/visible/visible_human.html) | Medical Images |

88
mkdocs.yml Normal file
View File

@@ -0,0 +1,88 @@
site_name: Bioxel Nodes Handbook
site_url: https://omoolab.github.io/BioxelNodes/
site_author: OmooLab
site_description: Bioxel Nodes is a Blender add-on for scientific volumetric data visualization.
repo_name: OmooLab/BioxelNodes
repo_url: https://github.com/OmooLab/BioxelNodes
nav:
- index.md
- Getting Started:
- Installation: installation.md
- Step by Step: step_by_step.md
- Support Format: support_format.md
- Improve Performance: improve_performance.md
- Advanced: advanced.md
- Q & A: qa.md
theme:
name: material
favicon: assets/logo.png
logo: assets/logo.png
language: en
font:
text: Open Sans
code: Cascadia Code
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
primary: black
toggle:
icon: fontawesome/solid/circle-half-stroke
name: Switch to light mode
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: black
toggle:
icon: fontawesome/regular/moon
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
toggle:
icon: fontawesome/solid/moon
name: Switch to system preference
features:
- header.autohide
- navigation.footer
- navigation.indexes
- navigation.sections
- navigation.tabs
- navigation.tabs.sticky
- content.code.copy
- content.code.select
extra:
version:
provider: mike
default: latest
extra_css:
- stylesheets/extra.css
copyright: Copyright &copy; 2020 - 2024 Omoolab
plugins:
- search
- mkdocstrings
markdown_extensions:
- attr_list
- md_in_html
- mkdocs-click
- admonition
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true

1690
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

30
pyproject.toml Normal file
View File

@@ -0,0 +1,30 @@
[tool.poetry]
name = "bioxelnodes"
version = "1.0.0"
description = ""
authors = ["Ma Nan <icrdr2010@outlook.com>"]
license = "MIT"
readme = "README.md"
[tool.poetry.dependencies]
python = ">=3.11.0,<3.12.dev0"
simpleitk = "2.3.1"
pyometiff = "1.0.0"
mrcfile = "1.5.1"
h5py = "3.11.0"
transforms3d = "0.4.2"
[tool.poetry.group.dev.dependencies]
bpy = "^4.2.0"
pytest = "^8.3.2"
pytest-dependency = "^0.6.0"
pytest-cov = "^5.0.0"
mkdocs = "^1.6.0"
mkdocs-material = "^9.5.30"
autopep8 = "^2.3.1"
mike = "^2.1.2"
tomlkit = "^0.13.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

5
requirements.txt Normal file
View File

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

0
tests/__init__.py Normal file
View File