Add unit tests for bioxel module and documentation

- Created README.md for test instructions and categories.
- Implemented comprehensive unit tests for the Data, Layer, and utility functions in the bioxel module.
- Added tests for reading NRRD and DICOM formats, data properties, and edge cases.
- Included tests for real data files to ensure functionality with actual datasets.
This commit is contained in:
2026-01-22 09:38:07 +08:00
parent a558d1ae30
commit ced0898f84
11 changed files with 1502 additions and 275 deletions

2
.gitignore vendored
View File

@@ -136,3 +136,5 @@ pythonlib*
*.blend1
blendcache_*
*.cats.txt~
tests/data

View File

@@ -24,6 +24,11 @@ uv sync
uv run build.py <platform> # platforms: windows-x64, linux-x64, macos-arm64, macos-x64
```
### Unit Test
```bash
uv run pytest tests/
```
### Code Quality
```bash
# Format code (autopep8)

View File

@@ -0,0 +1,34 @@
from .layer import Layer
from .container import Container
from .io import load_container, save_container
from .data import (
Data,
read,
read_meta,
calc_layer_shape,
calc_layer_size,
get_ext,
SUPPORT_EXTS,
DICOM_EXTS,
OME_EXTS,
MRC_EXTS,
SEQUENCE_EXTS
)
__all__ = [
'Layer',
'Container',
'load_container',
'save_container',
'Data',
'read',
'read_meta',
'calc_layer_shape',
'calc_layer_size',
'get_ext',
'SUPPORT_EXTS',
'DICOM_EXTS',
'OME_EXTS',
'MRC_EXTS',
'SEQUENCE_EXTS'
]

View File

@@ -0,0 +1,831 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Callable, List
import numpy as np
import SimpleITK as sitk
from pyometiff import OMETIFFReader
import mrcfile
import transforms3d
from .layer import Layer
# 类型定义
ProgressCallback = Optional[Callable[[float, str], None]]
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",
]
@dataclass
class Data:
filepath: str
series_id: str
_data: Optional[np.ndarray] = field(default=None, repr=False)
_meta: Optional[dict] = field(default=None, repr=False)
@property
def data(self) -> np.ndarray:
if self._data is None:
self.load_data()
return self._data
@property
def meta(self) -> dict:
if self._meta is None:
self.load_meta()
return self._meta
@property
def name(self) -> str:
return self.meta["name"]
@property
def description(self) -> str:
return self.meta["description"]
@property
def shape(self) -> tuple:
return (
self.meta["frame_count"],
*self.meta["xyz_shape"],
self.meta["channel_count"],
)
@property
def xyz_shape(self) -> tuple:
return self.meta["xyz_shape"]
@property
def spacing(self) -> tuple:
return self.meta["spacing"]
@property
def affine(self) -> np.ndarray:
return self.meta["affine"]
@property
def frame_count(self) -> int:
return self.meta["frame_count"]
@property
def channel_count(self) -> int:
return self.meta["channel_count"]
@property
def dtype(self) -> np.dtype:
return self.meta.get("dtype", np.float32)
def load_meta(self, progress_callback: ProgressCallback = None):
data, meta = self._parse_file(progress_callback)
self._meta = meta
del data
def load_data(self, progress_callback: ProgressCallback = None):
if not self.is_loaded():
data, meta = self._parse_file(progress_callback)
self._data = data
self._meta = meta
def is_loaded(self) -> bool:
return self._data is not None
def to_layers(
self,
kind: str = "scalar",
layer_name: str = "",
bioxel_size: float = 1.0,
smooth: int = 0,
remap: bool = False,
split_channel: bool = False,
frame_source: str = "-1",
progress_callback: ProgressCallback = None,
) -> List[Layer]:
from .layer import Layer
data = self.data.copy()
data, layer_shape = self._transform_shape(data, frame_source)
data = self._preprocess(data, kind, remap)
mat_scale = transforms3d.zooms.zfdir2aff(bioxel_size)
affine = np.dot(self.meta["affine"], mat_scale)
base_name = layer_name or kind.capitalize()
if kind == "label":
return self._create_label_layers(
data, base_name, layer_shape, affine, smooth, progress_callback
)
elif kind == "color":
return self._create_color_layers(
data, base_name, layer_shape, affine, progress_callback
)
elif kind == "scalar":
return self._create_scalar_layers(
data,
base_name,
layer_shape,
affine,
smooth,
split_channel,
progress_callback,
)
return []
def _parse_file(self, progress_callback: ProgressCallback = None):
data_path = Path(self.filepath).resolve()
ext = self._get_ext(data_path)
if progress_callback:
progress_callback(0.0, "Reading Data...")
is_sequence = False
sequence = None
if ext in SEQUENCE_EXTS:
sequence = self._collect_sequence(data_path)
if sequence and 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)
if data is None and ext in MRC_EXTS and not is_sequence:
data, name, spacing = self._parse_mrc(data_path)
if data is None and ext in OME_EXTS and not is_sequence:
data, name, spacing = self._parse_ome_tiff(data_path, progress_callback)
if data is None:
data, name, description, spacing, origin, direction = self._parse_sitk(
data_path, ext, is_sequence, sequence, progress_callback
)
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],
"dtype": data.dtype,
}
return data, meta
def _parse_mrc(self, data_path: Path):
print("Parsing with mrcfile...")
with mrcfile.open(data_path, "r") as mrc:
mrc_data = mrc.data
if mrc.is_single_image():
data = np.expand_dims(np.asarray(mrc_data), axis=0)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=-1)
elif mrc.is_image_stack():
data = np.expand_dims(np.asarray(mrc_data), axis=-1)
data = np.expand_dims(data, axis=-1)
elif mrc.is_volume():
data = np.expand_dims(np.asarray(mrc_data), axis=0)
data = np.expand_dims(data, axis=-1)
elif mrc.is_volume_stack():
data = np.expand_dims(np.asarray(mrc_data), axis=-1)
name = self._get_file_no_digits_name(data_path)
spacing = (mrc.voxel_size.x, mrc.voxel_size.y, mrc.voxel_size.z)
return data, name, spacing
def _parse_ome_tiff(self, data_path: Path, progress_callback=None):
print("Parsing with OMETIFFReader...")
reader = OMETIFFReader(fpath=data_path)
ome_image, metadata, xml_metadata = reader.read()
if progress_callback:
progress_callback(0.5, "Transpose to 'TXYZC'...")
try:
ome_order = metadata["DimOrder BF Array"]
data = self._transpose_ome_image(ome_image, ome_order)
try:
spacing = (
metadata["PhysicalSizeX"],
metadata["PhysicalSizeY"],
metadata["PhysicalSizeZ"],
)
except:
spacing = (1, 1, 1)
name = self._get_file_no_digits_name(data_path)
except Exception as e:
print(f"Error parsing OME-TIFF: {e}")
data = np.zeros((1, 1, 1, 1, 1), dtype=np.float32)
name = ""
spacing = (1, 1, 1)
return data, name, spacing
def _parse_sitk(
self,
data_path: Path,
ext: str,
is_sequence: bool,
sequence: Optional[list],
progress_callback=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), self.series_id
)
reader.SetFileNames(series_files)
itk_image = reader.Execute()
name, description = self._extract_dicom_meta(reader, data_dirpath)
elif ext in SEQUENCE_EXTS and is_sequence and sequence:
sequence_list = sequence if sequence else []
itk_image = sitk.ReadImage(sequence_list)
name = self._get_file_no_digits_name(data_path)
description = ""
else:
itk_image = sitk.ReadImage(data_path)
name = self._get_filename(data_path)
description = ""
if progress_callback:
progress_callback(0.5, "Transpose to 'TXYZC'...")
data = self._convert_sitk_to_numpy(itk_image, ext, is_sequence)
if itk_image.GetDimension() >= 3 and ext not in SEQUENCE_EXTS:
spacing = tuple(itk_image.GetSpacing())
origin = tuple(itk_image.GetOrigin())
direction = tuple(itk_image.GetDirection())
else:
spacing = (1, 1, 1)
origin = (0, 0, 0)
direction = (1, 0, 0, 0, 1, 0, 0, 0, 1)
return data, name, description, spacing, origin, direction
def _transpose_ome_image(self, ome_image, ome_order: str):
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)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=-1)
elif ome_image.ndim == 3:
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)
data = np.expand_dims(data, axis=-1)
elif ome_image.ndim == 4:
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)
elif ome_image.ndim == 5:
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)
return data
def _convert_sitk_to_numpy(self, itk_image, ext: str, is_sequence: bool):
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)
else:
data = np.transpose(data)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=0)
elif itk_image.GetDimension() == 3:
if ext not in SEQUENCE_EXTS:
itk_image = sitk.DICOMOrient(itk_image, "RAS")
data = sitk.GetArrayFromImage(itk_image)
if data.ndim == 4:
data = np.transpose(data, (2, 1, 0, 3))
else:
data = np.transpose(data)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=0)
elif itk_image.GetDimension() == 4:
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)
return data
def _extract_dicom_meta(self, reader, data_dirpath: Path):
def get_meta(key):
try:
string = reader.GetMetaData(0, key).removesuffix(" ")
string.encode("utf-8")
if string in ["No study description", "No series description", ""]:
return None
else:
return string
except:
return None
study_description = get_meta("0008|1030") or data_dirpath.name
series_description = get_meta("0008|103e")
series_modality = get_meta("0008|0060")
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 = study_description.replace(" ", "-")
description = description.replace(" ", "-")
return name, description
@staticmethod
def _get_ext(filepath: Path) -> str:
if filepath.name.endswith(".nii.gz"):
return ".nii.gz"
elif filepath.name.endswith(".img.gz"):
return ".img.gz"
elif filepath.name.endswith(".gipl.gz"):
return ".gipl.gz"
elif filepath.name.endswith(".ome.tiff"):
return ".ome.tiff"
elif filepath.name.endswith(".ome.tif"):
return ".ome.tif"
elif filepath.name.endswith(".mrc.gz"):
return ".mrc.gz"
elif filepath.name.endswith(".map.gz"):
return ".map.gz"
else:
suffix = filepath.suffix
return "" if len(suffix) > 5 else suffix
@staticmethod
def _get_filename(filepath: Path) -> str:
ext = Data._get_ext(filepath)
return filepath.name.removesuffix(ext)
@staticmethod
def _get_file_no_digits_name(filepath: Path) -> str:
prefix, digits, suffix = Data._get_filename_parts(filepath)
prefix = Data._remove_end_str(prefix, "_")
prefix = Data._remove_end_str(prefix, ".")
prefix = Data._remove_end_str(prefix, "-")
prefix = Data._remove_end_str(prefix, " ")
return prefix + suffix
@staticmethod
def _get_filename_parts(filepath: Path) -> tuple:
def has_digits(s):
return any(char.isdigit() for char in s)
name = Data._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 = ""
started = False
for char in number_part[::-1]:
if char.isdigit():
started = True
digits += char
else:
if started:
break
else:
suffix += char
digits = digits[::-1]
prefix_parts_count += len(number_part) - len(digits) - len(suffix)
prefix = name[:prefix_parts_count]
suffix = name[prefix_parts_count + len(digits) :]
return prefix, digits, suffix
@staticmethod
def _get_file_index(filepath: Path) -> int:
prefix, digits, suffix = Data._get_filename_parts(filepath)
return int(digits) if digits != "" else 0
@staticmethod
def _collect_sequence(filepath: Path):
file_dict = {}
for f in filepath.parent.iterdir():
if (
f.is_file()
and Data._get_ext(filepath) == Data._get_ext(f)
and Data._get_file_no_digits_name(filepath)
== Data._get_file_no_digits_name(f)
):
index = Data._get_file_index(f)
file_dict[index] = f
for key in list(file_dict.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
@staticmethod
def _remove_end_str(string: str, end: str) -> str:
while string.endswith(end) and len(string) > 0:
string = string.removesuffix(end)
return string
def _transform_shape(self, data, frame_source: str) -> tuple:
orig_shape = self.xyz_shape
if frame_source == "-1":
data = data[0:1, :, :, :, :]
new_shape = orig_shape
elif frame_source == "0":
new_shape = orig_shape
elif frame_source == "1":
data = data.transpose(1, 0, 2, 3, 4)
new_shape = (1, orig_shape[1], orig_shape[2])
elif frame_source == "2":
data = data.transpose(2, 1, 0, 3, 4)
new_shape = (orig_shape[0], 1, orig_shape[2])
elif frame_source == "3":
data = data.transpose(3, 1, 2, 0, 4)
new_shape = (orig_shape[0], orig_shape[1], 1)
else:
data = data.transpose(4, 1, 2, 3, 0)
new_shape = orig_shape
return data, new_shape
def _preprocess(self, data, kind: str, remap: bool) -> np.ndarray:
if kind == "color":
if np.issubdtype(data.dtype, np.uint8):
data = np.multiply(data, 1.0 / 256, dtype=np.float32)
elif data.dtype.kind in ["u", "i"]:
data = data.astype(np.float32)
min_val, max_val = data.min(), data.max()
if max_val != min_val:
data = (data - min_val) / (max_val - min_val)
else:
data = np.zeros_like(data, dtype=np.float32)
else:
data = data.astype(np.float32)
if data.shape[4] == 1:
data = np.repeat(data, repeats=3, axis=4)
elif data.shape[4] == 2:
zeros_shape = list(data.shape[:4]) + [1]
zeros = np.zeros(zeros_shape, dtype=np.float32)
data = np.concatenate((data, zeros), axis=-1)
elif data.shape[4] > 3:
data = data[:, :, :, :, :3]
elif kind == "scalar":
if remap:
data = data.astype(np.float32)
min_val, max_val = data.min(), data.max()
if max_val != min_val:
data = (data - min_val) / (max_val - min_val)
else:
data = np.zeros_like(data, dtype=np.float32)
elif kind == "label":
data = data.astype(int)
return data
def _create_progress_cb_factory(self, progress_callback):
def factory(name, progress, progress_step):
def callback(frame, total):
if progress_callback:
sub_progress = progress + frame * (progress_step / total)
progress_callback(
sub_progress, f"Processing {name} Frame {frame+1}..."
)
return callback
return factory
def _create_label_layers(
self,
data,
base_name,
layer_shape,
affine,
smooth,
progress_callback: ProgressCallback,
):
from .layer import Layer
layers = []
label_count = int(np.max(data))
if label_count == 0:
return layers
progress_step = 0.7 / label_count
cb_factory = self._create_progress_cb_factory(progress_callback)
for i in range(label_count):
name_i = f"{base_name}_{i+1}"
progress = 0.2 + i * progress_step
if progress_callback:
progress_callback(progress, f"Processing {name_i}...")
label_data = data == np.full_like(data, i + 1)
progress_cb = cb_factory(name_i, progress, progress_step)
layer = Layer(data=label_data, name=name_i, kind="label")
layer.resize(
shape=layer_shape, smooth=smooth, progress_callback=progress_cb
)
layer.affine = affine
layers.append(layer)
return layers
def _create_color_layers(
self, data, base_name, layer_shape, affine, progress_callback: ProgressCallback
):
from .layer import Layer
if progress_callback:
progress_callback(0.2, f"Processing {base_name}...")
cb_factory = self._create_progress_cb_factory(progress_callback)
progress_cb = cb_factory(base_name, 0.2, 0.7)
layer = Layer(data=data, name=base_name, kind="color")
layer.resize(shape=layer_shape, progress_callback=progress_cb)
layer.affine = affine
return [layer]
def _create_scalar_layers(
self,
data,
base_name,
layer_shape,
affine,
smooth,
split_channel,
progress_callback: ProgressCallback,
):
from .layer import Layer
layers = []
cb_factory = self._create_progress_cb_factory(progress_callback)
if split_channel:
channel_count = data.shape[-1]
progress_step = 0.7 / channel_count
for i in range(channel_count):
name_i = f"{base_name}_{i+1}"
progress = 0.2 + i * progress_step
if progress_callback:
progress_callback(progress, f"Processing {name_i}...")
progress_cb = cb_factory(name_i, progress, progress_step)
layer = Layer(
data=data[:, :, :, :, i : i + 1], name=name_i, kind="scalar"
)
layer.resize(
shape=layer_shape, smooth=smooth, progress_callback=progress_cb
)
layer.affine = affine
layers.append(layer)
else:
if progress_callback:
progress_callback(0.2, f"Processing {base_name}...")
progress_cb = cb_factory(base_name, 0.2, 0.7)
layer = Layer(data=data, name=base_name, kind="scalar")
layer.resize(
shape=layer_shape, smooth=smooth, progress_callback=progress_cb
)
layer.affine = affine
layers.append(layer)
return layers
# 模块级函数签名
def read(
filepath: str, series_id: str = "", progress_callback: ProgressCallback = None
) -> Data:
filepath = Path(filepath).resolve()
if not filepath.exists():
raise FileNotFoundError(f"File not found: {filepath}")
data_obj = Data(filepath=filepath, series_id=series_id)
data_obj.load_data(progress_callback)
return data_obj
def read_meta(filepath: str, series_id: str = "") -> Data:
data_obj = Data(filepath=filepath, series_id=series_id)
data_obj.load_meta()
return data_obj
def calc_layer_shape(
bioxel_size: float, orig_shape: tuple, orig_spacing: tuple
) -> 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 calc_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0) -> tuple:
size = (
float(shape[0] * bioxel_size * scale),
float(shape[1] * bioxel_size * scale),
float(shape[2] * bioxel_size * scale),
)
return size
def get_ext(filepath: Path) -> str:
if filepath.name.endswith(".nii.gz"):
return ".nii.gz"
elif filepath.name.endswith(".img.gz"):
return ".img.gz"
elif filepath.name.endswith(".gipl.gz"):
return ".gipl.gz"
elif filepath.name.endswith(".ome.tiff"):
return ".ome.tiff"
elif filepath.name.endswith(".ome.tif"):
return ".ome.tif"
elif filepath.name.endswith(".mrc.gz"):
return ".mrc.gz"
elif filepath.name.endswith(".map.gz"):
return ".map.gz"
else:
suffix = filepath.suffix
return "" if len(suffix) > 5 else suffix

View File

@@ -1,6 +1,12 @@
import copy
import numpy as np
# 条件导入 openvdb(只在 Blender 环境中存在)
try:
import openvdb as vdb
except ImportError:
vdb = None
from . import scipy
from . import scipy as ndi

View File

@@ -6,7 +6,11 @@ import matplotlib.pyplot as plt
import bpy
import numpy as np
try:
import openvdb as vdb
except ImportError:
vdb = None
from .bioxel.layer import Layer
from .utils import ndarray_to_png
@@ -57,12 +61,10 @@ def cache_layer_data(layer: Layer, cache_path: str):
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
else: # 颜色类型
grid = vdb.Vec3SGrid()
grid.copyFromArray(
data[f, :, :, :, :].copy().astype(np.float32))
grid.copyFromArray(data[f, :, :, :, :].copy().astype(np.float32))
# 仅设置transform,不存储metadata
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.transform = vdb.createLinearTransform(layer.affine.transpose())
grid.name = layer.kind
# 保存序列帧VDB
@@ -272,6 +274,5 @@ def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]:
added_ids.append(cache_id)
set_layer_caches(existing_data)
print(
f"Successfully added {len(added_ids)} layers to the internal text datablock")
print(f"Successfully added {len(added_ids)} layers to the internal text datablock")
return added_ids

View File

@@ -105,7 +105,7 @@ def add_bioxel_node(name: str):
bpy.ops.object.modifier_add_node_group(
asset_library_type="CUSTOM",
asset_library_identifier="O Bioxel",
relative_asset_identifier=f"Nodes.blend\\NodeTree\\{name}",
relative_asset_identifier=f"BioxelNodes.blend\\NodeTree\\{name}",
)
bpy.ops.object.modifier_remove(modifier=name)
try:

View File

@@ -12,37 +12,20 @@ import transforms3d
from ..props import BIOXEL_Series
from ..utils import get_layer_obj, wrapped_label
from ..bioxel.layer import Layer
from ..bioxel.parse import DICOM_EXTS, SUPPORT_EXTS, get_ext, parse_volumetric_data
from ..bioxel import (
DICOM_EXTS,
SUPPORT_EXTS,
read,
read_meta,
calc_layer_shape,
calc_layer_size,
get_ext,
)
from ..utils import get_cache_dir, progress_update, progress_bar
from ..layer import get_layer_caches, save_layers_to_json
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
"""
ImportData -> ParseVolumetricData -> ImportDataDialog
start import parse data execute import
@@ -211,11 +194,7 @@ class ParseVolumetricData(bpy.types.Operator):
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,
)
data = read_meta(self.filepath, series_id=series_id)
except KeyboardInterrupt:
return
except Exception as e:
@@ -225,8 +204,8 @@ class ParseVolumetricData(bpy.types.Operator):
if cancel():
return
self.meta = meta
self.label_count = int(np.max(data))
self.meta = data.meta
self.label_count = int(np.max(data.data))
self.dtype = data.dtype
# Init cancel flag
@@ -334,8 +313,8 @@ class ParseVolumetricData(bpy.types.Operator):
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)
layer_shape = calc_layer_shape(bioxel_size, orig_shape, orig_spacing)
layer_size = calc_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)))
@@ -547,231 +526,25 @@ class ImportDataDialog(bpy.types.Operator):
def execute(self, context):
def import_volumetric_data_func(self, context, cancel):
progress_update(context, 0.0, "Parsing Volumetirc Data...")
def progress_callback(factor, text):
if cancel():
raise KeyboardInterrupt("Cancelled by user")
progress_update(context, factor * 0.2, text)
def progress_callback_factory(layer_name, progress, progress_step):
def progress_callback(frame, total):
if cancel():
raise KeyboardInterrupt("Cancelled by user")
sub_progress_step = progress_step / total
sub_progress = progress + frame * sub_progress_step
progress_update(
context,
sub_progress,
f"Processing {layer_name} Frame {frame+1}...",
)
print(f"Processing {layer_name} Frame {frame+1}...")
return progress_callback
try:
data, meta = parse_volumetric_data(
data_file=self.filepath,
series_id=self.series_id,
progress_callback=progress_callback,
)
data = read(self.filepath, self.series_id, progress_callback)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
if cancel():
return
shape = get_layer_shape(
self.bioxel_size, self.orig_shape, self.orig_spacing
)
mat_scale = transforms3d.zooms.zfdir2aff(self.bioxel_size)
affine = np.dot(meta["affine"], mat_scale)
kind = self.read_as.lower()
if cancel():
return
# change shape as sequence or not
if self.frame_source == "-1":
data = data[0:1, :, :, :, :]
elif self.frame_source == "0":
# frame as frame
pass
elif self.frame_source == "1":
# X as frame
data = data.transpose(1, 0, 2, 3, 4)
shape = (1, shape[1], shape[2])
elif self.frame_source == "2":
# Y as frame
data = data.transpose(2, 1, 0, 3, 4)
shape = (shape[0], 1, shape[2])
elif self.frame_source == "3":
# Z as frame
data = data.transpose(3, 1, 2, 0, 4)
shape = (shape[0], shape[1], 1)
else:
# channel as frame
data = data.transpose(4, 1, 2, 3, 0)
layers = []
if kind == "label":
name = self.layer_name or "Label"
data = data.astype(int)
label_count = int(np.max(data))
progress_step = 0.7 / label_count
for i in range(label_count):
if cancel():
return
name_i = f"{name}_{i+1}"
progress = 0.2 + i * progress_step
progress_update(context, progress, f"Processing {name_i}...")
progress_callback = progress_callback_factory(
name_i, progress, progress_step
)
label_data = data == np.full_like(data, i + 1)
# label_data = label_data.astype(np.float32)
try:
layer = Layer(data=label_data, name=name_i, kind=kind)
layer.resize(
shape=shape,
self.layers = data.to_layers(
kind=self.read_as.lower(),
layer_name=self.layer_name,
bioxel_size=self.bioxel_size,
smooth=self.smooth,
progress_callback=progress_callback,
remap=self.remap,
split_channel=self.split_channel,
frame_source=self.frame_source,
progress_callback=lambda f, t: progress_callback(0.2 + f * 0.7, t),
)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
if kind == "color":
if np.issubdtype(np.uint8, data.dtype):
data = np.multiply(data, 1.0 / 256, dtype=np.float32)
elif data.dtype.kind in ["u", "i"]:
# Convert the normalized array to float dtype
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
# Avoid division by zero if all values are the same
if max_val != min_val:
# Normalize the array to the range (0,1)
data = (data - min_val) / (max_val - min_val)
else:
# If all values are the same, the normalized array will be all zeros
data = np.zeros_like(data, dtype=np.float32)
else:
data = data.astype(np.float32)
# Gamma Correct
# data = data ** 2.2
name = self.layer_name or "Color"
if data.shape[4] == 1:
data = np.repeat(data, repeats=3, axis=4)
elif data.shape[4] == 2:
d_shape = list(data.shape)
d_shape = d_shape[:4] + [1]
zore = np.zeros(tuple(d_shape), dtype=np.float32)
data = np.concatenate((data, zore), axis=-1)
elif data.shape[4] > 3:
data = data[:, :, :, :, :3]
if cancel():
return
progress_update(context, 0.2, f"Processing {name}...")
progress_callback = progress_callback_factory(name, 0.2, 0.7)
try:
layer = Layer(data=data, name=name, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
elif kind == "scalar":
name = self.layer_name or "Scalar"
if self.remap:
# Convert the normalized array to float dtype
data = data.astype(np.float32)
min_val = data.min()
max_val = data.max()
# Avoid division by zero if all values are the same
if max_val != min_val:
# Normalize the array to the range (0,1)
data = (data - min_val) / (max_val - min_val)
else:
# If all values are the same, the normalized array will be all zeros
data = np.zeros_like(data, dtype=np.float32)
if self.split_channel:
progress_step = 0.7 / self.channel_count
for i in range(self.channel_count):
if cancel():
return
name_i = f"{name}_{i+1}"
progress = 0.2 + i * progress_step
progress_update(context, progress, f"Processing {name_i}...")
progress_callback = progress_callback_factory(
name_i, progress, progress_step
)
try:
layer = Layer(
data=data[:, :, :, :, i : i + 1], name=name_i, kind=kind
)
layer.resize(
shape=shape, progress_callback=progress_callback
)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
self.has_error = e
return
else:
if cancel():
return
progress_update(context, 0.2, f"Processing {name}...")
progress_callback = progress_callback_factory(name, 0.2, 0.7)
try:
layer = Layer(data=data, name=name, kind=kind)
layer.resize(shape=shape, progress_callback=progress_callback)
layer.affine = affine
layers.append(layer)
except KeyboardInterrupt:
return
except Exception as e:
@@ -781,7 +554,6 @@ class ImportDataDialog(bpy.types.Operator):
if cancel():
return
self.layers = layers
progress_update(context, 0.9, "Creating Layers...")
self.is_cancelled = False
@@ -851,7 +623,7 @@ class ImportDataDialog(bpy.types.Operator):
return {"RUNNING_MODAL"}
def draw(self, context):
layer_shape = get_layer_shape(
layer_shape = calc_layer_shape(
self.bioxel_size, self.orig_shape, self.orig_spacing
)

View File

@@ -16,13 +16,11 @@ class RenameLayer(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
new_name: bpy.props.StringProperty(
name="New name", default="") # type: ignore
new_name: bpy.props.StringProperty(name="New name", default="") # type: ignore
def invoke(self, context, event):
caches = get_layer_caches()
entry = next((c for c in caches if str(
c.get("id", "")) == self.cache_id), None)
entry = next((c for c in caches if str(c.get("id", "")) == self.cache_id), None)
if entry:
self.new_name = str(entry.get("name", ""))
return context.window_manager.invoke_props_dialog(self)
@@ -127,8 +125,7 @@ class AddLayerNode(bpy.types.Operator):
layer_node.inputs["Frame Count"].default_value = entry["frame_count"]
layer_node.inputs["Animation"].default_value = True
else:
hidden_sockets = hidden_sockets + \
["Frame Count", "Frame Offset", "Cycle"]
hidden_sockets = hidden_sockets + ["Frame Count", "Frame Offset", "Cycle"]
# 将特定属性隐藏到hidden面板
for socket_name in hidden_sockets:
@@ -177,8 +174,7 @@ class RelocatePath(bpy.types.Operator):
return {"CANCELLED"}
caches = get_layer_caches()
entry = next((c for c in caches if str(
c.get("id", "")) == self.cache_id), None)
entry = next((c for c in caches if str(c.get("id", "")) == self.cache_id), None)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
@@ -228,8 +224,7 @@ class SaveLayer(bpy.types.Operator):
def invoke(self, context, event):
# Default to Blender file directory if possible
blend_dir = Path(
bpy.data.filepath).parent if bpy.data.filepath else Path.cwd()
blend_dir = Path(bpy.data.filepath).parent if bpy.data.filepath else Path.cwd()
context.window_manager.fileselect_add(self)
if not self.directory:
self.directory = str(blend_dir)
@@ -244,8 +239,7 @@ class SaveLayer(bpy.types.Operator):
return {"CANCELLED"}
caches = get_layer_caches()
entry = next((c for c in caches if str(
c.get("id")) == self.cache_id), None)
entry = next((c for c in caches if str(c.get("id")) == self.cache_id), None)
cache_id = entry.get("id")
if not entry:

43
tests/README.md Normal file
View File

@@ -0,0 +1,43 @@
# BioxelNodes Unit Tests
This directory contains unit tests for the bioxel module.
## Test Data
Test files should be placed in `tests/data/`:
## Running Tests
Run all tests:
```bash
uv run pytest
```
Run specific test file:
```bash
uv run pytest tests/test_bioxel.py
```
Run specific test class:
```bash
uv run pytest tests/test_bioxel.py::TestDataClass
```
Run with verbose output:
```bash
uv run pytest -v
```
Run with coverage:
```bash
uv run pytest --cov=bioxelnodes.bioxel --cov-report=html
```
## Test Categories
- **TestDataClass**: Data class initialization and properties
- **TestDataToLayers**: Data.to_layers() method
- **TestUtilityFunctions**: Utility functions
- **TestLayerClass**: Layer class functionality
- **TestConstants**: Module constants
- **TestEdgeCases**: Edge cases and error handling

539
tests/test_bioxel.py Normal file
View File

@@ -0,0 +1,539 @@
"""
Unit tests for bioxel module.
Tests cover:
- Data class functionality
- Parsing NRRD and DICOM formats
- Layer creation and transformation
- Utility functions
"""
from bioxelnodes.bioxel import (
Data,
read,
read_meta,
calc_layer_shape,
calc_layer_size,
Layer,
)
import pytest
import numpy as np
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
# Test data files
NRRD_FILE = Path(__file__).parent / "data/VHP_M.nrrd"
DICOM_FILE = Path(__file__).parent / "data/VHP_M_CT_Head/IM000001.dcm"
DICOM_DIR = Path(__file__).parent / "data/VHP_M_CT_Head/"
class TestDataClass:
"""Test Data class initialization and properties"""
def test_data_initialization(self):
"""Test basic Data initialization with NRRD"""
data = Data(filepath=str(NRRD_FILE), series_id="")
assert data.filepath is not None
assert data.series_id == ""
assert data._data is None
assert data._meta is None
def test_read_meta_nrrd(self):
"""Test reading only meta from NRRD file"""
data_obj = read_meta(str(NRRD_FILE))
assert data_obj._data is None
assert data_obj._meta is not None
assert "name" in data_obj.meta
assert "spacing" in data_obj.meta
assert "affine" in data_obj.meta
assert "xyz_shape" in data_obj.meta
assert "frame_count" in data_obj.meta
assert "channel_count" in data_obj.meta
assert "dtype" in data_obj.meta
def test_read_meta_dicom(self):
"""Test reading only meta from DICOM file"""
data_obj = read_meta(str(DICOM_FILE), series_id="")
assert data_obj._data is None
assert data_obj._meta is not None
assert "name" in data_obj.meta
def test_read_full_data_nrrd(self):
"""Test reading full NRRD data"""
data_obj = read(str(NRRD_FILE))
assert data_obj._data is not None
assert data_obj._meta is not None
assert isinstance(data_obj.data, np.ndarray)
assert data_obj.data.ndim == 5
def test_read_full_data_dicom(self):
"""Test reading full DICOM data"""
data_obj = read(str(DICOM_FILE), series_id="")
assert data_obj._data is not None
assert data_obj._meta is not None
assert isinstance(data_obj.data, np.ndarray)
def test_data_properties_nrrd(self):
"""Test Data property accessors"""
data_obj = read(str(NRRD_FILE))
assert hasattr(data_obj, "name")
assert hasattr(data_obj, "description")
assert hasattr(data_obj, "shape")
assert hasattr(data_obj, "xyz_shape")
assert hasattr(data_obj, "spacing")
assert hasattr(data_obj, "affine")
assert hasattr(data_obj, "frame_count")
assert hasattr(data_obj, "channel_count")
assert hasattr(data_obj, "dtype")
assert isinstance(data_obj.shape, tuple)
assert isinstance(data_obj.xyz_shape, tuple)
assert isinstance(data_obj.spacing, tuple)
assert isinstance(data_obj.affine, np.ndarray)
assert isinstance(data_obj.dtype, np.dtype)
def test_data_lazy_loading(self):
"""Test lazy loading behavior"""
data_obj = Data(filepath=str(NRRD_FILE), series_id="")
assert data_obj.is_loaded() is False
data_obj.load_meta()
assert data_obj._data is None
assert data_obj._meta is not None
data_obj.load_data()
assert data_obj._data is not None
assert data_obj.is_loaded() is True
def test_shape_property(self):
"""Test shape property returns TXYZC format"""
data_obj = read(str(NRRD_FILE))
shape = data_obj.shape
assert len(shape) == 5
assert shape[0] == data_obj.meta["frame_count"]
assert shape[1:4] == data_obj.meta["xyz_shape"]
assert shape[4] == data_obj.meta["channel_count"]
def test_load_meta_method(self):
"""Test load_meta() method exists and works"""
data_obj = Data(filepath=str(NRRD_FILE), series_id="")
assert hasattr(data_obj, "load_meta")
data_obj.load_meta()
assert data_obj._meta is not None
assert data_obj._data is None
class TestDataToLayers:
"""Test Data.to_layers() method"""
def test_to_layers_dicom_scalar(self):
"""Test converting DICOM data to scalar layers"""
data_obj = read(str(DICOM_FILE), series_id="")
layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0)
assert len(layers) == 1
assert layers[0].kind == "scalar"
assert isinstance(layers[0], Layer)
def test_to_layers_nrrd_scalar(self):
"""Test converting NRRD data to scalar layers"""
data_obj = read(str(NRRD_FILE))
layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0)
assert len(layers) == 1
assert layers[0].kind == "scalar"
assert isinstance(layers[0], Layer)
def test_to_layers_with_remap(self):
"""Test data remapping"""
data_obj = read(str(NRRD_FILE))
layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, remap=True)
assert len(layers) == 1
assert layers[0].data.dtype == np.float32
assert np.all(layers[0].data >= 0)
assert np.all(layers[0].data <= 1)
def test_to_layers_with_bioxel_size(self):
"""Test with different bioxel sizes"""
data_obj = read(str(NRRD_FILE))
layers_1x = data_obj.to_layers(kind="scalar", bioxel_size=1.0)
layers_2x = data_obj.to_layers(kind="scalar", bioxel_size=2.0)
assert len(layers_1x) == 1
assert len(layers_2x) == 1
# Larger bioxel size means smaller layer
shape_1x = layers_1x[0].shape
shape_2x = layers_2x[0].shape
assert shape_2x[0] <= shape_1x[0]
assert shape_2x[1] <= shape_1x[1]
assert shape_2x[2] <= shape_1x[2]
def test_to_layers_with_smooth(self):
"""Test with smoothing parameter"""
data_obj = read(str(NRRD_FILE))
layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, smooth=1)
assert len(layers) == 1
assert isinstance(layers[0], Layer)
def test_to_layers_frame_source_first_frame(self):
"""Test frame_source = '-1' (first frame only)"""
data_obj = read(str(NRRD_FILE))
layers = data_obj.to_layers(kind="scalar", bioxel_size=2.0, frame_source="-1")
assert len(layers) == 1
assert layers[0].frame_count == 1
class TestUtilityFunctions:
"""Test utility functions"""
def test_calc_layer_shape(self):
"""Test layer shape calculation"""
shape = calc_layer_shape(
bioxel_size=1.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0)
)
assert len(shape) == 3
assert shape[0] == 100
assert shape[1] == 100
assert shape[2] == 100
def test_calc_layer_shape_small_bioxel(self):
"""Test layer shape with small bioxel size"""
shape = calc_layer_shape(
bioxel_size=2.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0)
)
assert len(shape) == 3
assert shape[0] == 50
assert shape[1] == 50
assert shape[2] == 50
def test_calc_layer_shape_minimum(self):
"""Test layer shape doesn't go below 1"""
shape = calc_layer_shape(
bioxel_size=100.0, orig_shape=(100, 100, 100), orig_spacing=(1.0, 1.0, 1.0)
)
assert shape == (1, 1, 1)
def test_calc_layer_size(self):
"""Test layer size calculation"""
size = calc_layer_size(shape=(100, 100, 100), bioxel_size=1.0, scale=1.0)
assert len(size) == 3
assert size[0] == 100.0
assert size[1] == 100.0
assert size[2] == 100.0
def test_calc_layer_size_with_scale(self):
"""Test layer size with scale factor"""
size = calc_layer_size(shape=(100, 100, 100), bioxel_size=1.0, scale=0.01)
assert len(size) == 3
assert size[0] == 1.0
assert size[1] == 1.0
assert size[2] == 1.0
class TestLayerClass:
"""Test Layer class functionality"""
def test_layer_initialization(self):
"""Test Layer initialization"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
layer = Layer(data=data, name="test_layer", kind="scalar")
assert layer.name == "test_layer"
assert layer.kind == "scalar"
assert np.array_equal(layer.data, data)
def test_layer_properties(self):
"""Test Layer property accessors"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
layer = Layer(
data=data, name="test_layer", kind="scalar", affine=np.identity(4)
)
assert hasattr(layer, "bioxel_size")
assert hasattr(layer, "shape")
assert hasattr(layer, "dtype")
assert hasattr(layer, "origin")
assert hasattr(layer, "euler")
assert hasattr(layer, "frame_count")
assert hasattr(layer, "channel_count")
assert hasattr(layer, "min")
assert hasattr(layer, "max")
def test_layer_shape_property(self):
"""Test layer shape returns XYZ (not TXYZC)"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
layer = Layer(data=data, name="test", kind="scalar")
shape = layer.shape
assert len(shape) == 3
assert shape[0] == 8
assert shape[1] == 8
assert shape[2] == 8
def test_layer_affine_property(self):
"""Test affine transformation matrix"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
affine = np.array(
[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
)
layer = Layer(data=data, name="test", kind="scalar", affine=affine)
result_affine = layer.affine
assert result_affine.shape == (4, 4)
assert np.allclose(result_affine, affine)
def test_layer_min_max(self):
"""Test layer min/max properties"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
layer = Layer(data=data, name="test", kind="scalar")
assert isinstance(layer.min, float)
assert isinstance(layer.max, float)
assert layer.min <= layer.max
def test_layer_copy(self):
"""Test layer deep copy"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
layer = Layer(data=data, name="test", kind="scalar")
layer_copy = layer.copy()
assert layer_copy.name == layer.name
assert layer_copy.kind == layer.kind
assert np.array_equal(layer_copy.data, layer.data)
assert layer_copy is not layer
layer_copy.data[0, 0, 0, 0, 0] = 999.0
assert layer.data[0, 0, 0, 0, 0] != 999.0
def test_layer_invalid_data_shape(self):
"""Test Layer raises error for invalid data shape"""
data = np.random.rand(8, 8, 8).astype(np.float32)
with pytest.raises(Exception):
Layer(data=data, name="test", kind="scalar")
def test_layer_invalid_affine_shape(self):
"""Test Layer raises error for invalid affine shape"""
data = np.random.rand(1, 8, 8, 8, 1).astype(np.float32)
affine = np.identity(3)
with pytest.raises(Exception):
Layer(data=data, name="test", kind="scalar", affine=affine)
class TestConstants:
"""Test module constants"""
def test_support_exts_exists(self):
"""Test SUPPORT_EXTS constant"""
from bioxelnodes.bioxel import SUPPORT_EXTS
assert isinstance(SUPPORT_EXTS, list)
assert len(SUPPORT_EXTS) > 0
assert ".h5" in SUPPORT_EXTS
assert ".nrrd" in SUPPORT_EXTS
assert ".nii.gz" in SUPPORT_EXTS
assert ".dcm" in SUPPORT_EXTS
def test_dicom_exts_exists(self):
"""Test DICOM_EXTS constant"""
from bioxelnodes.bioxel import DICOM_EXTS
assert isinstance(DICOM_EXTS, list)
assert "" in DICOM_EXTS
assert ".dcm" in DICOM_EXTS
assert ".DCM" in DICOM_EXTS
def test_ome_exts_exists(self):
"""Test OME_EXTS constant"""
from bioxelnodes.bioxel import OME_EXTS
assert isinstance(OME_EXTS, list)
assert ".tiff" in OME_EXTS
assert ".ome.tiff" in OME_EXTS
def test_mrc_exts_exists(self):
"""Test MRC_EXTS constant"""
from bioxelnodes.bioxel import MRC_EXTS
assert isinstance(MRC_EXTS, list)
assert ".mrc" in MRC_EXTS
class TestEdgeCases:
"""Test edge cases and error handling"""
def test_nonexistent_file(self):
"""Test handling of nonexistent file"""
with pytest.raises(FileNotFoundError):
data_obj = read("/nonexistent/file.nii.gz", "")
def test_frame_count_property_multiframe(self):
"""Test frame_count property for multi-frame data"""
data_obj = read(str(NRRD_FILE))
assert data_obj.frame_count >= 1
assert data_obj.frame_count == data_obj.meta["frame_count"]
def test_channel_count_property(self):
"""Test channel_count property"""
data_obj = read(str(NRRD_FILE))
assert data_obj.channel_count >= 1
assert data_obj.channel_count == data_obj.meta["channel_count"]
def test_nrrd_format_support(self):
"""Test NRRD format is in supported extensions"""
from bioxelnodes.bioxel import SUPPORT_EXTS
assert ".nrrd" in SUPPORT_EXTS
def test_dicom_format_support(self):
"""Test DICOM format is in supported extensions"""
from bioxelnodes.bioxel import SUPPORT_EXTS
assert ".dcm" in SUPPORT_EXTS
assert "" in SUPPORT_EXTS
class TestRealDataFiles:
"""Test with real data files"""
def test_nrrd_file_exists(self):
"""Test NRRD test file exists"""
assert NRRD_FILE.exists()
assert NRRD_FILE.is_file()
def test_dicom_file_exists(self):
"""Test DICOM test file exists"""
assert DICOM_FILE.exists()
assert DICOM_FILE.is_file()
def test_dicom_directory_exists(self):
"""Test DICOM directory exists"""
assert DICOM_DIR.exists()
assert DICOM_DIR.is_dir()
def test_read_nrrd_data(self):
"""Test reading real NRRD data"""
data_obj = read(str(NRRD_FILE))
assert data_obj._data is not None
assert data_obj._meta is not None
assert isinstance(data_obj.data, np.ndarray)
assert data_obj.data.size > 0
def test_read_dicom_data(self):
"""Test reading real DICOM data"""
data_obj = read(str(DICOM_FILE), series_id="")
assert data_obj._data is not None
assert data_obj._meta is not None
assert isinstance(data_obj.data, np.ndarray)
assert data_obj.data.size > 0
def test_nrrd_meta_contains_required_fields(self):
"""Test NRRD meta contains all required fields"""
data_obj = read(str(NRRD_FILE))
meta = data_obj.meta
required_fields = [
"name",
"description",
"spacing",
"affine",
"xyz_shape",
"frame_count",
"channel_count",
"dtype",
]
for field in required_fields:
assert field in meta, f"Meta missing field: {field}"
def test_dicom_meta_contains_required_fields(self):
"""Test DICOM meta contains all required fields"""
data_obj = read(str(DICOM_FILE), series_id="")
meta = data_obj.meta
required_fields = [
"name",
"description",
"spacing",
"affine",
"xyz_shape",
"frame_count",
"channel_count",
"dtype",
]
for field in required_fields:
assert field in meta, f"Meta missing field: {field}"
def test_data_to_layers_with_real_nrrd(self):
"""Test to_layers with real NRRD data"""
data_obj = read(str(NRRD_FILE))
layers = data_obj.to_layers(kind="scalar", bioxel_size=3.0)
assert len(layers) > 0
assert all(isinstance(layer, Layer) for layer in layers)
def test_data_to_layers_with_real_dicom(self):
"""Test to_layers with real DICOM data"""
data_obj = read(str(DICOM_FILE), series_id="")
layers = data_obj.to_layers(kind="scalar", bioxel_size=3.0)
assert len(layers) > 0
assert all(isinstance(layer, Layer) for layer in layers)
def test_nrrd_spacing_values(self):
"""Test NRRD spacing values are reasonable"""
data_obj = read(str(NRRD_FILE))
spacing = data_obj.spacing
assert len(spacing) == 3
assert all(s > 0 for s in spacing)
def test_dicom_spacing_values(self):
"""Test DICOM spacing values are reasonable"""
data_obj = read(str(DICOM_FILE), series_id="")
spacing = data_obj.spacing
assert len(spacing) == 3
assert all(s > 0 for s in spacing)