Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions applications/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ add_holohub_application(async_buffer_deadline)
add_holohub_application(basic_networking_ping DEPENDS
OPERATORS basic_network)

add_holohub_application(bci_visualization DEPENDS
OPERATORS volume_renderer)

add_holohub_application(body_pose_estimation DEPENDS
OPERATORS OPTIONAL dds_video_subscriber dds_video_publisher)

Expand Down
26 changes: 26 additions & 0 deletions applications/bci_visualization/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Update copyright year.

The copyright header must include the current year (2026).

🔎 Proposed fix
-# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.

As per pipeline failure logs.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
🧰 Tools
🪛 GitHub Actions: Check Compliance

[error] 1-1: Copyright header incomplete: current year not included in the header.

🤖 Prompt for AI Agents
In applications/bci_visualization/CMakeLists.txt around line 1, the SPDX
copyright header currently lists the year 2025; update the year to 2026 so the
header reads "... Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights
reserved." and ensure formatting/spelling of the SPDX line remains unchanged.

# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

cmake_minimum_required(VERSION 3.20)
project(bci_visualization LANGUAGES NONE)

find_package(holoscan 2.0 REQUIRED CONFIG
PATHS "/opt/nvidia/holoscan" "/workspace/holoscan-sdk/install")

add_subdirectory(operators)

# Enable the operators
add_library(bci_visualization INTERFACE)
target_link_libraries(bci_visualization INTERFACE holoscan::core holoscan::ops::holoviz color_buffer_passthrough)
37 changes: 37 additions & 0 deletions applications/bci_visualization/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1

# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS base

ARG DEBIAN_FRONTEND=noninteractive

# Install curl for downloading data files
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

# Add schemas directory to PYTHONPATH to enable importing operators
ENV PYTHONPATH=$PYTHONPATH:/workspace/holohub

# Install Python dependencies
RUN pip install nibabel nilearn

# Install Kernel's dependencies
RUN pip install \
numpy \
scipy \
h5py
54 changes: 54 additions & 0 deletions applications/bci_visualization/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# BCI Visualization

## Running
1. Download data
* Please download data from [here](https://drive.google.com/drive/folders/1RpQ6UzjIZAr90FdW9VIbtTFYR6-up7w2) and put everything under `data/bci_visualization`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download link is not accessible for public. Can we hosted them somewhere else?
We cannot give public access to the content on NVIDIA GDrive.

* Includes an activation volume and a segmentation volume.
2. Run application
```bash
./holohub run bci_visualization
```
This command will build the docker and run the application.

## Expected Results

![Example output for BCI Visualization](docs/brain.png)
### Components

1. **VoxelStreamToVolumeOp** (`operators/voxel_stream_to_volume/voxel_stream_to_volume.py`): Bridge operator that converts sparse voxel data to dense volume
- Inputs: `affine_4x4`, `hb_voxel_data`
- Outputs: `volume`, `spacing`, `permute_axis`, `flip_axes`, `mask_volume`, `mask_spacing`, `mask_permute_axis`, `mask_flip_axes`

2. **VolumeRendererOp**: ClaraViz-based volume renderer
- Renders the 3D volume with transfer functions
- Supports interactive camera control

3. **HolovizOp**: Interactive visualization
- Displays the rendered volume
- Provides camera pose feedback


## Data Flow

```
Reconstruction → VoxelStreamToVolume → VolumeRenderer → Holoviz
↑ ↓
└─── camera ────┘
```

## Volume Renderer Configuration
Here are some of the important config:
1. `timeSlot`: Rendering time in ms. The longer the better the quality.
2. `TransferFunction`

a. `activeRegions`: (0: SKIN, 1: SKULL, 2: CSF, 3: GRAY MATER, 4: WHITE MATTER, 5: AIR). Here, we select [3, 4] for our ROI. Set everything else as opacity=0 (default).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax: Typo in 'GRAY MATER' should be 'GRAY MATTER'

Suggested change
a. `activeRegions`: (0: SKIN, 1: SKULL, 2: CSF, 3: GRAY MATER, 4: WHITE MATTER, 5: AIR). Here, we select [3, 4] for our ROI. Set everything else as opacity=0 (default).
a. `activeRegions`: (0: SKIN, 1: SKULL, 2: CSF, 3: GRAY MATTER, 4: WHITE MATTER, 5: AIR). Here, we select [3, 4] for our ROI. Set everything else as opacity=0 (default).


b. `blendingProfile`: If there're overlapping components configured, how to blend.

c. `range`: Volume's value within this range to be configured.

d. `opacityProfile`: How opacity is being applied within this range. Select `Square` for constant.

e. `diffuseStart/End`: The component's color. Linear interpolation between start/end.

Note: there are three components configured. First is for overall ROI, set color as white. Second is for positive activation, set as red. Third is for negative activation, set as blue.
240 changes: 240 additions & 0 deletions applications/bci_visualization/bci_visualization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""
SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES.
SPDX-License-Identifier: Apache-2.0

BCI Visualization Application - streams synthetic voxel data and renders as 3D volume.
"""

import os
import argparse
from pathlib import Path


from holoscan.core import Application
from holoscan.operators import HolovizOp
from holoscan.resources import CudaStreamPool, UnboundedAllocator
from holoscan.schedulers import EventBasedScheduler, MultiThreadScheduler
# Import local operators
from operators.voxel_stream_to_volume import VoxelStreamToVolumeOp

from holohub.color_buffer_passthrough import ColorBufferPassthroughOp
from holohub.volume_renderer import VolumeRendererOp

class BciVisualizationApp(Application):
"""BCI Visualization Application with ClaraViz."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please add another line or two expanding on the application overview? Would be nice to include the compatible device (Kernel Flow2) and high level behavior (replay device recording, reconstruct activation map volume, render with ClaraViz / HoloViz). Can keep detailed explanation in README


def __init__(self,
argv=None,
*args,
render_config_file,
density_min,
density_max,
label_path=None,
roi_labels=None,
mask_path=None,
**kwargs,
):
self._rendering_config = render_config_file
self._density_min = density_min
self._density_max = density_max
self._label_path = label_path
self._roi_labels = roi_labels
self._mask_path = mask_path

super().__init__(argv, *args, **kwargs)

def compose(self):
volume_allocator = UnboundedAllocator(self, name="allocator")
cuda_stream_pool = CudaStreamPool(
self,
name="cuda_stream",
dev_id=0,
stream_flags=0,
stream_priority=0,
reserved_size=1,
max_size=5,
)

# Selection
selected_channel = 0 # 0: HbO, 1: HbR

# Operators
voxel_to_volume_args = {
"selected_channel": selected_channel,
}
if self._label_path:
voxel_to_volume_args["label_path"] = self._label_path
if self._roi_labels:
voxel_to_volume_args["roi_labels"] = self._roi_labels
if self._mask_path:
voxel_to_volume_args["mask_nifti_path"] = self._mask_path

voxel_to_volume = VoxelStreamToVolumeOp(
self,
name="voxel_to_volume",
pool=volume_allocator,
**voxel_to_volume_args,
)

volume_renderer_args = {}
if self._density_min:
volume_renderer_args["density_min"] = self._density_min
if self._density_max:
volume_renderer_args["density_max"] = self._density_max

volume_renderer = VolumeRendererOp(
self,
name="volume_renderer",
config_file=self._rendering_config,
allocator=volume_allocator,
alloc_width=1024,
alloc_height=768,
cuda_stream_pool=cuda_stream_pool,
**volume_renderer_args,
)

holoviz = HolovizOp(
self,
name="holoviz",
window_title="BCI Visualization with ClaraViz",
enable_camera_pose_output=True,
cuda_stream_pool=cuda_stream_pool,
)

color_buffer_passthrough = ColorBufferPassthroughOp(
self,
name="color_buffer_passthrough",
)

# Connect operators

kernel_data = Path("/workspace/holohub/data/kernel")
from streams.snirf import SNIRFStream
stream = SNIRFStream(kernel_data / "data.snirf")
from reconstruction import ReconstructionApplication
reconstruction_application = ReconstructionApplication(
stream=stream,
jacobian_path=kernel_data / "flow_mega_jacobian.npy",
channel_mapping_path=kernel_data / "flow_channel_map.json",
voxel_info_dir=kernel_data / "voxel_info",
coefficients_path=kernel_data / "extinction_coefficients_mua.csv",
use_gpu=True,
)
reconstruction_application.compose(self, voxel_to_volume)

# voxel_to_volume → volume_renderer
self.add_flow(voxel_to_volume, volume_renderer, {
("volume", "density_volume"),
("spacing", "density_spacing"),
("permute_axis", "density_permute_axis"),
("flip_axes", "density_flip_axes"),
})
# Add mask connections to VolumeRendererOp
self.add_flow(voxel_to_volume, volume_renderer, {
("mask_volume", "mask_volume"),
("mask_spacing", "mask_spacing"),
("mask_permute_axis", "mask_permute_axis"),
("mask_flip_axes", "mask_flip_axes"),
})

# volume_renderer ↔ holoviz
self.add_flow(volume_renderer, color_buffer_passthrough,
{("color_buffer_out", "color_buffer_in")})
self.add_flow(color_buffer_passthrough, holoviz, {("color_buffer_out", "receivers")})
self.add_flow(holoviz, volume_renderer, {("camera_pose_output", "camera_pose")})


def main():


parser = argparse.ArgumentParser(description="BCI Visualization Application", add_help=False)
parser.add_argument(
"-c",
"--config",
action="store",
dest="config",
help="Name of the renderer JSON configuration file to load",
)

parser.add_argument(
"-i",
"--density_min",
action="store",
type=int,
dest="density_min",
help="Set the minimum of the density element values. If not set this is calculated from the"
"volume data. In practice CT volumes have a minimum value of -1024 which corresponds to"
"the lower value of the Hounsfield scale range usually used.",
)
parser.add_argument(
"-a",
"--density_max",
action="store",
type=int,
dest="density_max",
help="Set the maximum of the density element values. If not set this is calculated from the"
"volume data. In practice CT volumes have a maximum value of 3071 which corresponds to"
"the upper value of the Hounsfield scale range usually used.",
)

parser.add_argument(
"-l",
"--label_path",
action="store",
type=str,
dest="label_path",
help="Path to the NPZ file containing brain anatomy labels. If not provided, uses default path.",
)

parser.add_argument(
"-r",
"--roi_labels",
action="store",
type=str,
dest="roi_labels",
help="Comma-separated list of label values to use as ROI (e.g., '3,4'). Default is '3,4'.",
)

parser.add_argument(
"-m",
"--mask_path",
action="store",
type=str,
dest="mask_path",
help="Path to the mask NIfTI file containing 3D integer labels (I, J, K). Optional.",
)

parser.add_argument(
"-h", "--help", action="help", default=argparse.SUPPRESS, help="Help message"
)

args = parser.parse_args()

# Parse roi_labels from comma-separated string to list of integers
roi_labels = None
if args.roi_labels:
try:
roi_labels = [int(label.strip()) for label in args.roi_labels.split(',')]
except ValueError:
print(f"Warning: Invalid roi_labels format '{args.roi_labels}'. Expected comma-separated integers.")
roi_labels = None

app = BciVisualizationApp(
render_config_file=args.config,
density_min=args.density_min,
density_max=args.density_max,
label_path=args.label_path,
roi_labels=roi_labels,
mask_path=args.mask_path,
)

app.scheduler(EventBasedScheduler(app, worker_thread_number=5, stop_on_deadlock=True))

app.run()

print("BCI Visualization Application has finished running.")


if __name__ == "__main__":
main()

Loading
Loading