The Radiation Model plugin provides GPU-accelerated ray tracing for radiation simulation using Vulkan or OptiX backends. Backend selection is automatic based on available hardware: Vulkan supports all GPU vendors (NVIDIA, AMD, Intel, Apple Silicon), while OptiX provides an optimized path for NVIDIA GPUs. This documentation is based on the actual implementation.
Overview
The RadiationModel class provides advanced radiation modeling and ray tracing capabilities for realistic light interaction simulations in plant canopies and scenes.
Requirements
The radiation plugin supports two GPU backends. At least one must be available:
Vulkan Backend (All Platforms)
Supports NVIDIA, AMD, Intel, and Apple Silicon GPUs via Vulkan compute shaders with software BVH traversal. Vulkan headers and the glslang shader compiler are bundled with Helios, so no external Vulkan SDK installation is required on Windows.
| Requirement | macOS | Linux | Windows |
| GPU | Any with Vulkan support | Any with Vulkan support | Any with Vulkan support |
| Dependencies | brew install vulkan-loader molten-vk | sudo apt-get install libvulkan-dev | None (all bundled) |
OptiX Backend (NVIDIA Only)
Provides optimized ray tracing on NVIDIA GPUs using hardware RT cores. Two OptiX versions are supported:
| Backend | Driver Requirement | CUDA | Notes |
| OptiX 8.1 | NVIDIA driver >= 560 | CUDA 12.0+ | Default for modern drivers |
| OptiX 6.5 | NVIDIA driver < 560 | CUDA 9.0+ | Legacy driver support |
Backend Selection
Backend selection is automatic at runtime via GPU hardware probing. When the radiation model starts, it probes compiled-in backends in priority order (OptiX 8 -> OptiX 6 -> Vulkan) and selects the first one that is compatible with the current hardware. If no compatible GPU is found, a clear diagnostic error is raised.
You can query the active backend at runtime:
with RadiationModel(context) as radiation:
print(f"Active backend: {radiation.getBackendName()}")
You can also probe GPU availability without creating a full radiation model:
if RadiationModel.probeAnyGPUBackend():
print("GPU backend available")
else:
print("No compatible GPU backend found")
For CUDA/OptiX setup, see the comprehensive CUDA Setup Guide, which covers:
Basic Usage
from pyhelios import Context, RadiationModel
context = Context()
patch_uuid = context.addPatch(
center=vec3(0, 0, 0),
size=vec2(2, 2),
color=RGBcolor(0.3, 0.7, 0.2)
)
with RadiationModel(context) as radiation:
radiation.addRadiationBand("PAR")
source_id = radiation.addCollimatedRadiationSource()
radiation.setSourceFlux(source_id, "PAR", 1000.0)
radiation.setDirectRayCount("PAR", 100)
radiation.setDiffuseRayCount("PAR", 300)
radiation.updateGeometry()
radiation.runBand("PAR")
results = radiation.getTotalAbsorbedFlux()
print(f"Absorbed flux density: {results[0]:.2f} W/m^2")
power = sum(flux * context.getPrimitiveArea(uuid)
for flux, uuid in zip(results, context.getAllUUIDs()))
print(f"Total absorbed power: {power:.2f} W")
Units: getTotalAbsorbedFlux() returns absorbed flux density in W/m² for each primitive (the radiation_flux_<band> primitive data), summed over all bands — not power in watts. Because it is a density, it does not change when you change a primitive's size: a 1×1 m and a 2×2 m patch under the same collimated source both report 1000 W/m². Multiply by context.getPrimitiveArea(uuid) to convert to watts before summing across primitives.
Radiation Bands
Basic Band Management
radiation.addRadiationBand("PAR")
radiation.addRadiationBand("NIR")
radiation.addRadiationBand("SW")
radiation.addRadiationBand("custom", wavelength_min=400.0, wavelength_max=700.0)
radiation.copyRadiationBand("PAR", "PAR_copy")
Common Radiation Bands
bands = {
"PAR": "Photosynthetically Active Radiation (400-700 nm)",
"NIR": "Near Infrared (700-1100 nm)",
"SW": "Shortwave (300-3000 nm)",
"UV": "Ultraviolet (280-400 nm)",
"VIS": "Visible (380-750 nm)"
}
for band, description in bands.items():
radiation.addRadiationBand(band)
print(f"Added {band}: {description}")
Radiation Sources
Collimated Sources
source_id = radiation.addCollimatedRadiationSource()
source_id = radiation.addCollimatedRadiationSource(
direction=(0.3, 0.3, -0.9)
)
source_id = radiation.addCollimatedRadiationSource(
direction=vec3(0.3, 0.3, -0.9)
)
import math
source_id = radiation.addCollimatedRadiationSource(
direction=SphericalCoord(1.0, math.radians(45.0), math.radians(135.0))
)
radiation.setSourceFlux(source_id, "PAR", 1200.0)
Spherical Sources
source_id = radiation.addSphereRadiationSource(
position=(0, 0, 10),
radius=0.5
)
radiation.setSourceFlux(source_id, "PAR", 800.0)
Sun Sources
sun_id = radiation.addSunSphereRadiationSource(
radius=0.5,
zenith=45.0,
azimuth=180.0,
position_scaling=1.0,
angular_width=0.53,
flux_scaling=1.0
)
radiation.setSourceFlux(sun_id, "PAR", 1200.0)
Flux Configuration
Source Flux Management
radiation.setSourceFlux(source_id, "PAR", 1000.0)
source_ids = [source1, source2, source3]
radiation.setSourceFlux(source_ids, "PAR", 800.0)
current_flux = radiation.getSourceFlux(source_id, "PAR")
print(f"Source flux: {current_flux} W/m²")
radiation.setDiffuseRadiationFlux("PAR", 200.0)
Ray Configuration
Ray Count Settings
radiation.setDirectRayCount("PAR", 1000)
radiation.setDiffuseRayCount("PAR", 3000)
radiation.setDirectRayCount("PAR", 5000)
radiation.setDiffuseRayCount("PAR", 10000)
Ray Count Guidelines
ray_configs = {
"fast": {"direct": 100, "diffuse": 300},
"standard": {"direct": 1000, "diffuse": 3000},
"high_quality": {"direct": 5000, "diffuse": 15000},
"research": {"direct": 10000, "diffuse": 30000}
}
config = ray_configs["standard"]
radiation.setDirectRayCount("PAR", config["direct"])
radiation.setDiffuseRayCount("PAR", config["diffuse"])
Advanced Configuration
Scattering Control
radiation.setScatteringDepth("PAR", 3)
radiation.setMinScatterEnergy("PAR", 0.01)
Emission Control
radiation.enableEmission("thermal")
radiation.disableEmission("PAR")
runBand() records each band's emission state as global data named emission_enabled_<band> (uint, 1 when emission is enabled for that band and 0 otherwise). This is what lets downstream plug-ins identify which band governs longwave emission — the energy balance model reads it to select the emitting band's emissivity. You can read it back through the Context:
radiation.runBand(["PAR", "thermal"])
context.getGlobalData("emission_enabled_thermal")
context.getGlobalData("emission_enabled_PAR")
The value exists only for bands that have actually been run, since runBand() is what writes it.
Simulation Execution
Geometry Updates
radiation.updateGeometry()
radiation.updateGeometry([patch_uuid, triangle_uuid])
Running Simulations
CRITICAL PERFORMANCE NOTE: When simulating multiple radiation bands, ALWAYS run all bands in a single runBand() call rather than sequential single-band calls. This provides significant computational efficiency gains (often 2-5x faster) because:
- GPU ray tracing setup is performed once for all bands
- Scene geometry acceleration structures are reused across bands
- GPU kernel launches are batched together
- Memory transfers between CPU/GPU are minimized
- Ray traversal computations are shared between spectral bands
radiation.runBand(["PAR", "NIR", "SW"])
radiation.runBand("PAR")
radiation.runBand("NIR")
radiation.runBand("SW")
radiation.runBand("PAR")
Performance Comparison
import time
start_time = time.time()
radiation.runBand("PAR")
radiation.runBand("NIR")
radiation.runBand("SW")
sequential_time = time.time() - start_time
start_time = time.time()
radiation.runBand(["PAR", "NIR", "SW"])
multiband_time = time.time() - start_time
print(f"Sequential: {sequential_time:.2f}s")
print(f"Multi-band: {multiband_time:.2f}s")
print(f"Speedup: {sequential_time/multiband_time:.1f}x faster")
Results and Analysis
Flux Results
results = radiation.getTotalAbsorbedFlux()
all_uuids = context.getAllUUIDs()
total_power = sum(flux * context.getPrimitiveArea(uuid)
for flux, uuid in zip(results, all_uuids))
print(f"Total absorbed power: {total_power:.2f} W")
for i, uuid in enumerate(all_uuids):
if i < len(results):
flux_density = results[i]
area = context.getPrimitiveArea(uuid)
power = flux_density * area
print(f"Primitive {uuid}: {flux_density:.2f} W/m² ({power:.2f} W)")
Radiation Analysis
radiation_data = radiation.getTotalAbsorbedFlux()
import statistics
mean_flux = statistics.mean(radiation_data)
max_flux = max(radiation_data)
min_flux = min(radiation_data)
std_flux = statistics.stdev(radiation_data)
print(f"Radiation statistics (flux density):")
print(f" Mean: {mean_flux:.2f} W/m²")
print(f" Max: {max_flux:.2f} W/m²")
print(f" Min: {min_flux:.2f} W/m²")
print(f" Std Dev: {std_flux:.2f} W/m²")
Note that these statistics are unweighted: each primitive contributes equally regardless of its size. For an area-weighted scene average, divide total absorbed power by total area.
Advanced Band Management
Query Band Existence
if radiation.doesBandExist("PAR"):
print("PAR band exists")
radiation.setDirectRayCount("PAR", 1000)
else:
radiation.addRadiationBand("PAR")
Copy Band with Wavelength Range
radiation.addRadiationBand("fullspectrum", 300, 3000)
radiation.copyRadiationBand("fullspectrum", "fullspectrum_copy")
radiation.copyRadiationBand("fullspectrum", "PAR", 400, 700)
radiation.copyRadiationBand("fullspectrum", "NIR", 700, 1100)
Geometric Radiation Sources
Rectangle Sources (LED Panels, Grow Lights)
led_panel = radiation.addRectangleRadiationSource(
position=vec3(0, 0, 5),
size=vec2(2.0, 1.0),
rotation=vec3(0, 0, 0)
)
radiation.addRadiationBand("LED")
radiation.setSourceFlux(led_panel, "LED", 500.0)
led_spectrum = [
(400, 0.0), (450, 0.3), (500, 0.8),
(550, 0.5), (600, 0.2), (700, 0.0)
]
radiation.setSourceSpectrum(led_panel, led_spectrum)
Disk Sources (Spotlights, Circular LEDs)
spotlight = radiation.addDiskRadiationSource(
position=vec3(2, 2, 5),
radius=0.5,
rotation=vec3(0, 0, 0)
)
radiation.setSourceFlux(spotlight, "PAR", 800.0)
Dynamic Source Positioning
for time_step in range(24):
sun_position = calculate_sun_position(time_step)
radiation.setSourcePosition(sun_id, sun_position)
radiation.runBand("PAR")
results = radiation.getTotalAbsorbedFlux()
save_results(time_step, results)
radiation.deleteRadiationSource(temporary_source_id)
Spectral Data Management
Setting Source Spectra
sunlight_spectrum = [
(300, 0.1), (400, 0.5), (500, 1.0),
(600, 0.9), (700, 0.7), (800, 0.5)
]
radiation.setSourceSpectrum(sun_source, sunlight_spectrum)
led_sources = [led1, led2, led3]
radiation.setSourceSpectrum(led_sources, led_spectrum)
radiation.setSourceSpectrum(source_id, "D65_illuminant")
Spectrum Integration and Analysis
leaf_reflectance = [
(400, 0.08), (500, 0.10), (550, 0.45),
(600, 0.15), (650, 0.12), (700, 0.50),
(750, 0.55), (800, 0.52)
]
total = radiation.integrateSpectrum(leaf_reflectance)
print(f"Total reflectance: {total}")
par_reflectance = radiation.integrateSpectrum(leaf_reflectance, 400, 700)
print(f"PAR reflectance: {par_reflectance}")
source_weighted = radiation.integrateSpectrum(
leaf_reflectance, 400, 700, source_id=sun_id
)
camera_response = [(400, 0.2), (550, 1.0), (700, 0.3)]
camera_weighted = radiation.integrateSpectrum(
leaf_reflectance, camera_spectrum=camera_response
)
sun_par_flux = radiation.integrateSourceSpectrum(sun_id, 400, 700)
print(f"Sun PAR flux: {sun_par_flux} W/m²")
Spectrum Normalization
radiation.setSourceSpectrumIntegral(source_id, 1000.0)
radiation.setSourceSpectrumIntegral(source_id, 500.0, 400, 700)
Spectrum Manipulation
radiation.scaleSpectrum("leaf_reflectance", 1.2)
radiation.scaleSpectrum("leaf_reflectance", "bright_leaf", 1.5)
radiation.scaleSpectrumRandomly("base_leaf", "variant_leaf", 0.8, 1.2)
radiation.blendSpectra(
new_label="mixed_leaf",
spectrum_labels=["young_leaf", "mature_leaf", "old_leaf"],
weights=[0.2, 0.5, 0.3]
)
radiation.blendSpectraRandomly(
new_label="random_canopy",
spectrum_labels=["leaf_type_a", "leaf_type_b", "leaf_type_c"]
)
Diffuse Radiation
Directionally-Biased Diffuse Radiation
radiation.addRadiationBand("SW")
radiation.setDiffuseRadiationFlux("SW", 200.0)
radiation.setDiffuseRadiationExtinctionCoeff(
label="SW",
K=0.5,
peak_direction=vec3(0, 0, 1)
)
import math
radiation.setDiffuseRadiationExtinctionCoeff(
label="SW",
K=0.3,
peak_direction=SphericalCoord(1.0, math.radians(45.0), math.radians(90.0))
)
diffuse_flux = radiation.getDiffuseFlux("SW")
print(f"Diffuse flux: {diffuse_flux} W/m²")
Diffuse Spectrum Configuration
radiation.setDiffuseSpectrum("SW", "sky_spectrum")
radiation.setDiffuseSpectrum(["SW", "NIR"], "atmospheric_spectrum")
radiation.setDiffuseSpectrumIntegral(1000.0)
radiation.setDiffuseSpectrumIntegral(500.0, 400, 700)
radiation.setDiffuseSpectrumIntegral(300.0, band_label="PAR")
radiation.setDiffuseSpectrumIntegral(200.0, 400, 700, band_label="PAR")
Spectral Interpolation
Primitive-Based Spectral Assignment
leaf_patches = context.getAllUUIDs("patch")
radiation.interpolateSpectrumFromPrimitiveData(
primitive_uuids=leaf_patches,
spectra_labels=["young_leaf_spectrum", "mature_leaf_spectrum", "old_leaf_spectrum"],
values=[0.0, 50.0, 100.0],
primitive_data_query_label="leaf_age",
primitive_data_radprop_label="reflectance"
)
radiation.interpolateSpectrumFromPrimitiveData(
primitive_uuids=leaf_patches,
spectra_labels=["low_n_leaf", "medium_n_leaf", "high_n_leaf"],
values=[0.5, 2.0, 4.0],
primitive_data_query_label="nitrogen_percent",
primitive_data_radprop_label="reflectance"
)
Object-Based Spectral Assignment
tree_ids = [tree1_id, tree2_id, tree3_id]
radiation.interpolateSpectrumFromObjectData(
object_ids=tree_ids,
spectra_labels=["healthy_tree", "stressed_tree", "diseased_tree"],
values=[1.0, 0.5, 0.0],
object_data_query_label="health_index",
primitive_data_radprop_label="reflectance"
)
Camera Management
Dynamic Camera Control
radiation.addRadiationCamera("cam1", ["red", "green", "blue"],
position=vec3(0, 0, 10),
lookat_or_direction=vec3(0, 0, 0))
radiation.setCameraPosition("cam1", vec3(5, 5, 15))
position = radiation.getCameraPosition("cam1")
print(f"Camera at: {position}")
radiation.setCameraLookat("cam1", vec3(2, 2, 0))
lookat = radiation.getCameraLookat("cam1")
print(f"Camera looking at: {lookat}")
radiation.setCameraOrientation("cam1", vec3(0, 0, 1))
orientation = radiation.getCameraOrientation("cam1")
print(f"Camera orientation: {orientation}")
cameras = radiation.getAllCameraLabels()
print(f"Available cameras: {cameras}")
Solar-Induced Fluorescence (SIF) Camera (helios-core v1.3.72+)
PyHelios v0.1.21+ exposes the Fluspect-B-driven SIF camera type. Each user-defined emission band must already exist via addRadiationBand(); those bands are flagged internally as SIF-emitting and use the Fluspect-B leaf-fluorescence kernel for emission instead of Stefan-Boltzmann. Helios auto-creates internal radiation bands covering 400–750 nm at the requested excitation_bin_width_nm and reuses them across cameras with matching bin widths. Per-band emission is computed as the Fluspect-B excitation→emission kernel scaled by the van der Tol (2014) rate-coefficient quantum yield Φ_F, and injected into the standard runBand() emission loop so that escape probability and canopy reabsorption emerge naturally from the 3D ray tracer. Per-leaf biochemistry is authored via the LeafOptics plug-in — see LeafOptics → Radiation SIF integration.
from pyhelios import RadiationModel, SIFCameraProperties
with RadiationModel(context) as radiation:
radiation.addRadiationBand("F687", 685.0, 690.0)
radiation.addRadiationBand("F760", 758.0, 762.0)
sif_props = SIFCameraProperties(
camera_resolution=(512, 512),
excitation_bin_width_nm=10.0,
excitation_scattering_depth=0,
)
radiation.addSIFCamera(
"sif_cam",
emission_band_labels=["F687", "F760"],
position=vec3(0, 0, 5),
lookat_or_direction=vec3(0, 0, 0),
camera_properties=sif_props,
antialiasing_samples=64,
)
assert radiation.isSIFCamera("sif_cam")
Each emission band is locked to a single excitation bin width — re-flagging the same band from a second camera with a different bin width is an error. Set excitation_scattering_depth >= 1 to include inter-leaf scattering in the per-leaf APAR calculation; this captures NIR reflectance/transmittance contributions at the cost of additional excitation-band ray traces.
Per-leaf APAR storage scales as O(N_leaves * N_excitation_bands) where N_excitation_bands = ceil(350 / excitation_bin_width_nm). For very large canopies prefer a coarser bin width (e.g., 20 nm) to keep memory bounded.
Camera Spectral Response
radiation.setCameraSpectralResponse("cam1", "red", "custom_red_response")
radiation.setCameraSpectralResponseFromLibrary("cam1", "iPhone13")
radiation.setCameraSpectralResponseFromLibrary("cam2", "NikonD850")
radiation.setCameraSpectralResponseFromLibrary("cam3", "CanonEOS5D")
Programmatic Pixel Access
pixels = radiation.getCameraPixelData("cam1", "red")
print(f"Image size: {len(pixels)} pixels")
print(f"Mean intensity: {sum(pixels)/len(pixels)}")
enhanced_pixels = [p * 1.3 for p in pixels]
radiation.setCameraPixelData("cam1", "red", enhanced_pixels)
import numpy as np
pixel_array = np.array(pixels)
filtered = apply_custom_filter(pixel_array)
radiation.setCameraPixelData("cam1", "red", filtered.tolist())
Advanced Simulation Features
Periodic Boundary Conditions
radiation.enforcePeriodicBoundary("xy")
radiation.enforcePeriodicBoundary("x")
radiation.enforcePeriodicBoundary("y")
G-Function Calculation
g_vertical = radiation.calculateGtheta(vec3(0, 0, 1))
print(f"G-function (vertical): {g_vertical}")
g_oblique = radiation.calculateGtheta(vec3(0.5, 0.5, 0.7))
print(f"G-function (oblique): {g_oblique}")
g_horizontal = radiation.calculateGtheta(vec3(1, 0, 0))
print(f"G-function (horizontal): {g_horizontal}")
- Note
calculateGtheta() calls updateGeometry() automatically (emitting a warning) if the scene geometry has not yet been pushed to the radiation model, so an explicit prior updateGeometry() is no longer required. If the G-function is undefined — no geometry, or zero total leaf area in the scene — it raises an explicit RuntimeError rather than returning NaN.
Sky Energy and Optional Outputs
sky_energy = radiation.getSkyEnergy()
print(f"Sky energy: {sky_energy} W")
radiation.optionalOutputPrimitiveData("reflectivity")
radiation.optionalOutputPrimitiveData("transmissivity")
Spectral Modeling Workflows
Realistic Sunlight Modeling
solar_spectrum = [
(300, 0.05), (350, 0.15), (400, 0.35), (450, 0.65),
(500, 0.90), (550, 1.00), (600, 0.95), (650, 0.85),
(700, 0.75), (750, 0.70), (800, 0.65), (900, 0.55)
]
sun_id = radiation.addSunSphereRadiationSource(
radius=1.0, zenith=30.0, azimuth=180.0
)
radiation.setSourceSpectrum(sun_id, solar_spectrum)
radiation.setSourceSpectrumIntegral(sun_id, 1000.0)
radiation.setSourceSpectrumIntegral(sun_id, 500.0, 400, 700)
Multi-Spectral Leaf Modeling
young_leaf = [(400, 0.08), (550, 0.40), (700, 0.45), (800, 0.50)]
mature_leaf = [(400, 0.10), (550, 0.45), (700, 0.50), (800, 0.55)]
old_leaf = [(400, 0.06), (550, 0.30), (700, 0.35), (800, 0.40)]
for age in [10, 20, 30, 40]:
radiation.scaleSpectrum(
"young_leaf",
f"leaf_age_{age}",
scale_factor=1.0 + (age / 100.0)
)
for i in range(100):
radiation.scaleSpectrumRandomly(
"mature_leaf",
f"leaf_variant_{i}",
min_scale=0.85,
max_scale=1.15
)
Canopy Spectral Diversity
leaf_patches = context.getAllUUIDs("patch")
radiation.interpolateSpectrumFromPrimitiveData(
primitive_uuids=leaf_patches,
spectra_labels=["upper_canopy", "middle_canopy", "lower_canopy"],
values=[15.0, 8.0, 2.0],
primitive_data_query_label="height",
primitive_data_radprop_label="reflectance"
)
radiation.blendSpectra(
new_label="canopy_average",
spectrum_labels=["sunlit_leaf", "shaded_leaf"],
weights=[0.3, 0.7]
)
Multi-View Camera Imaging
Camera rendering order
writeCameraImage(), writeNormCameraImage() and writeCameraImageData() do not render anything. They only write out pixel data that a previous runBand() call produced. Three ordering rules follow from this:
- Add the camera before calling
runBand(). runBand() renders only the cameras that exist when it runs. A camera added afterwards has no pixel data until the next runBand().
- Render every band you intend to write.
runBand() fills in pixel data only for the bands passed to it, so runBand(["red", "green", "blue"]) followed by writeCameraImage(..., bands=["NIR"]) fails even though the NIR band exists.
- Re-run
runBand() after moving a camera or changing the scene. Pixel data reflects the scene as of the last render.
radiation.addRadiationCamera("cam", ["red", "green", "blue"], ...)
radiation.updateGeometry()
radiation.runBand(["red", "green", "blue"])
radiation.writeCameraImage("cam", ["red", "green", "blue"], "out")
Getting this wrong raises a RadiationModelError naming the camera and band that were never rendered. On helios-core versions before v1.3.79 the underlying library reported this as a bare invalid map<K, T> key; PyHelios now checks the precondition itself and reports which runBand() call is missing.
Time-Series Camera Capture
radiation.addRadiationCamera("timelapse", ["red", "green", "blue"],
position=vec3(0, 0, 20),
lookat_or_direction=vec3(0, 0, 0))
viewpoints = [
vec3(10, 0, 15), vec3(0, 10, 15),
vec3(-10, 0, 15), vec3(0, -10, 15)
]
for i, viewpoint in enumerate(viewpoints):
radiation.setCameraPosition("timelapse", viewpoint)
radiation.setCameraLookat("timelapse", vec3(0, 0, 5))
radiation.runBand(["red", "green", "blue"])
radiation.writeCameraImage(
"timelapse", ["red", "green", "blue"],
f"view_{i}", frame=i
)
- Note
writeCameraImage() renders nothing itself – it only writes out pixel data that runBand() has already produced. See Camera rendering order for the ordering rules this implies.
Multi-Spectral Imaging with Standard Cameras
radiation.addRadiationCamera("iphone_cam", ["red", "green", "blue"],
position=vec3(0, 0, 10),
lookat_or_direction=vec3(0, 0, 0))
radiation.setCameraSpectralResponseFromLibrary("iphone_cam", "iPhone13")
radiation.addRadiationCamera("dslr_cam", ["red", "green", "blue"],
position=vec3(5, 0, 10),
lookat_or_direction=vec3(0, 0, 0))
radiation.setCameraSpectralResponseFromLibrary("dslr_cam", "NikonD850")
radiation.runBand(["red", "green", "blue"])
iphone_img = radiation.writeCameraImage("iphone_cam", ["red", "green", "blue"], "iphone")
dslr_img = radiation.writeCameraImage("dslr_cam", ["red", "green", "blue"], "dslr")
Advanced Workflows
Growth Simulation with Spectral Changes
for day in range(100):
leaf_patches = context.getAllUUIDs("patch")
for patch_id in leaf_patches:
current_age = context.getPrimitiveData(patch_id, "age")[0]
context.setPrimitiveDataFloat(patch_id, "age", current_age + 1.0)
radiation.interpolateSpectrumFromPrimitiveData(
primitive_uuids=leaf_patches,
spectra_labels=["juvenile", "mature", "senescent"],
values=[0.0, 50.0, 100.0],
primitive_data_query_label="age",
primitive_data_radprop_label="reflectance"
)
radiation.runBand("PAR")
flux = radiation.getTotalAbsorbedFlux()
daily_radiation[day] = sum(f * context.getPrimitiveArea(u)
for f, u in zip(flux, context.getAllUUIDs()))
Multi-Source Lighting Scenarios
sun = radiation.addSunSphereRadiationSource(1.0, 45.0, 180.0)
radiation.setSourceFlux(sun, "PAR", 600.0)
led1 = radiation.addRectangleRadiationSource(
position=vec3(-3, 0, 4), size=vec2(1, 2), rotation=vec3(0, 0, 0)
)
led2 = radiation.addRectangleRadiationSource(
position=vec3(3, 0, 4), size=vec2(1, 2), rotation=vec3(0, 0, 0)
)
led_spectrum = [
(400, 0.0), (450, 0.8), (500, 0.2),
(600, 0.1), (660, 1.0), (700, 0.0)
]
radiation.setSourceSpectrum([led1, led2], led_spectrum)
radiation.setSourceFlux([led1, led2], "PAR", 200.0)
radiation.runBand("PAR")
Quality Control with Source Position Tracking
all_cameras = radiation.getAllCameraLabels()
print(f"Configured cameras: {all_cameras}")
for source_id in [sun, led1, led2]:
pos = radiation.getSourcePosition(source_id)
print(f"Source {source_id} at: {pos}")
bands = ["PAR", "NIR", "SW"]
for band in bands:
if radiation.doesBandExist(band):
flux = radiation.getDiffuseFlux(band)
print(f"{band}: {flux} W/m² diffuse")
Complete Workflow Example
from pyhelios import Context, WeberPennTree, WPTType, RadiationModel
from pyhelios import RadiationModelError
try:
context = Context()
ground_uuid = context.addPatch(
center=vec3(0, 0, 0),
size=vec2(10, 10),
color=RGBcolor(0.3, 0.2, 0.1)
)
wpt = WeberPennTree(context)
tree_id = wpt.buildTree(WPTType.LEMON)
with RadiationModel(context) as radiation:
radiation.addRadiationBand("PAR")
radiation.addRadiationBand("NIR")
radiation.addRadiationBand("SW")
sun_id = radiation.addSunSphereRadiationSource(
radius=0.5,
zenith=30.0,
azimuth=135.0
)
radiation.setSourceFlux(sun_id, "PAR", 600.0)
radiation.setSourceFlux(sun_id, "NIR", 500.0)
radiation.setSourceFlux(sun_id, "SW", 1200.0)
radiation.setDiffuseRadiationFlux("PAR", 100.0)
radiation.setDiffuseRadiationFlux("NIR", 80.0)
radiation.setDiffuseRadiationFlux("SW", 200.0)
for band in ["PAR", "NIR", "SW"]:
radiation.setDirectRayCount(band, 2000)
radiation.setDiffuseRayCount(band, 6000)
radiation.setScatteringDepth(band, 2)
radiation.updateGeometry()
radiation.runBand(["PAR", "NIR", "SW"])
results = radiation.getTotalAbsorbedFlux()
leaf_uuids = wpt.getLeafUUIDs(tree_id)
leaf_absorption = 0
all_uuids = context.getAllUUIDs()
for i, uuid in enumerate(all_uuids):
if uuid in leaf_uuids and i < len(results):
leaf_absorption += results[i] * context.getPrimitiveArea(uuid)
total_absorption = sum(f * context.getPrimitiveArea(u)
for f, u in zip(results, all_uuids))
ground_index = all_uuids.index(ground_uuid)
ground_absorption = (results[ground_index]
* context.getPrimitiveArea(ground_uuid))
print(f"Total scene absorption: {total_absorption:.2f} W")
print(f"Leaf absorption: {leaf_absorption:.2f} W")
print(f"Ground absorption: {ground_absorption:.2f} W")
for band in ["PAR", "NIR", "SW"]:
print(f"Band {band} results available in primitive data: radiation_flux_{band}")
except RadiationModelError as e:
print(f"Radiation modeling failed: {e}")
print("Ensure GPU with Vulkan support is available (or NVIDIA GPU with CUDA/OptiX)")
print("Check that radiation plugin is compiled: build_scripts/build_helios --plugins radiation")
except Exception as e:
print(f"Simulation setup failed: {e}")
print("Check geometry creation and tree generation parameters")
Data Storage
results = radiation.getTotalAbsorbedFlux()
all_uuids = context.getAllUUIDs()
for i, uuid in enumerate(all_uuids):
if i < len(results):
context.setPrimitiveDataFloat(uuid, "flux_density_PAR", results[i])
area = context.getPrimitiveArea(uuid)
context.setPrimitiveDataFloat(uuid, "absorbed_power_PAR", results[i] * area)
context.colorPrimitiveByDataPseudocolor(
all_uuids, "flux_density_PAR", "hot", 256
)
Camera and Image Functions
Note: Camera functions are available in Helios core v1.3.47+ and PyHelios v0.0.4+
The RadiationModel now includes advanced camera functionality for generating synthetic images, object detection training data, and auto-calibrated imagery.
Camera Image Generation
filename = radiation.writeCameraImage(
camera="overhead_camera",
bands=["Red", "Green", "Blue"],
imagefile_base="scene_rgb",
image_path="./images",
frame=-1
)
print(f"Camera image saved to: {filename}")
filename = radiation.writeNormCameraImage(
camera="side_camera",
bands=["NIR", "Red"],
imagefile_base="false_color",
image_path="./output"
)
radiation.writeCameraImageData(
camera="overhead_camera",
band="RGB",
imagefile_base="raw_data",
image_path="./data"
)
radiation.writeCameraImageDataEXR(
camera="overhead_camera",
band="PAR",
imagefile_base="raw_float_data",
image_path="./data"
)
radiation.writeCameraImageDataEXR(
camera="overhead_camera",
band=["Red", "Green", "Blue"],
imagefile_base="rgb_float_data",
image_path="./data"
)
Depth Image Export
radiation.writeDepthImageData(
camera_label="overhead_camera",
imagefile_base="depth_data",
image_path="./data"
)
radiation.writeDepthImageDataEXR(
camera_label="overhead_camera",
imagefile_base="depth_float",
image_path="./data"
)
radiation.writeNormDepthImage(
camera_label="overhead_camera",
imagefile_base="depth_visual",
max_depth=50.0,
image_path="./data"
)
Object Detection Training Data
Generate YOLO-format bounding boxes for machine learning training:
radiation.writeImageBoundingBoxes(
camera_label="training_camera",
primitive_data_labels="leaf_type",
object_class_ids=1,
image_file="training_001.jpg",
classes_txt_file="plant_classes.txt",
image_path="./annotations"
)
radiation.writeImageBoundingBoxes(
camera_label="training_camera",
primitive_data_labels=["leaves", "stems", "fruits"],
object_class_ids=[0, 1, 2],
image_file="training_002.jpg",
classes_txt_file="classes.txt"
)
radiation.writeImageBoundingBoxes(
camera_label="training_camera",
object_data_labels=["tree_1", "tree_2", "shrub_1"],
object_class_ids=[10, 10, 20],
image_file="training_003.jpg"
)
Segmentation Masks for Instance Segmentation
Generate COCO-format JSON files for semantic/instance segmentation:
radiation.writeImageSegmentationMasks(
camera_label="segmentation_camera",
primitive_data_labels="plant_part",
object_class_ids=1,
json_filename="segmentation_001.json",
image_file="seg_image_001.jpg",
append_file=False
)
radiation.writeImageSegmentationMasks(
camera_label="segmentation_camera",
primitive_data_labels=["leaf", "bark", "soil"],
object_class_ids=[1, 2, 3],
json_filename="multi_class_seg.json",
image_file="scene_segmentation.jpg",
append_file=True
)
radiation.writeImageSegmentationMasks(
camera_label="segmentation_camera",
object_data_labels=["individual_plant_1", "individual_plant_2"],
object_class_ids=[100, 101],
json_filename="instance_segmentation.json",
image_file="plant_instances.jpg"
)
Per-Pixel Data Label Maps
Export a camera's per-pixel primitive- or object-data values as a dense, row-major map — useful for ground-truth regression targets (e.g. per-pixel temperature, leaf age, or any numeric primitive/object data field). Float, double, uint, and int data types are supported; background pixels (no primitive struck) receive a configurable padvalue (default NaN).
radiation.writePrimitiveDataLabelMap(
camera="overhead_camera",
primitive_data_label="temperature",
imagefile_base="temp_map",
image_path="./data",
padvalue=float("nan"),
)
radiation.writeObjectDataLabelMap(
camera="overhead_camera",
object_data_label="plant_id",
imagefile_base="plantid_map",
image_path="./data",
)
temp = radiation.getPrimitiveDataLabelMap("overhead_camera", "temperature")
plant_id = radiation.getObjectDataLabelMap("overhead_camera", "plant_id")
Camera Exposure
CameraProperties carries an exposure field that is passed through to the native camera by addRadiationCamera() and updateCameraParameters(). Supported modes are "auto" (automatic exposure, the default), "manual" (no automatic exposure scaling), and "ISOXXX" (ISO-based, e.g. "ISO100", calibrated against auto-exposure at reference settings). Use "manual" when you need radiometrically comparable pixel values across frames or cameras.
from pyhelios import RadiationModel, CameraProperties
props = CameraProperties()
props.exposure = "manual"
radiation.addRadiationCamera("fixed_exposure_cam", ["red", "green", "blue"], position, lookat, props)
Auto-Calibrated Camera Images
Automatic color correction for realistic imagery:
filename = radiation.autoCalibrateCameraImage(
camera_label="rgb_camera",
red_band_label="Red",
green_band_label="Green",
blue_band_label="Blue",
output_file_path="auto_calibrated_image.jpg"
)
filename = radiation.autoCalibrateCameraImage(
camera_label="multispectral_camera",
red_band_label="Band_670nm",
green_band_label="Band_550nm",
blue_band_label="Band_450nm",
output_file_path="calibrated_multispectral.jpg",
print_quality_report=True,
algorithm="MATRIX_3X3_AUTO",
ccm_export_file_path="color_correction_matrix.txt"
)
print(f"Calibrated image saved to: {filename}")
Color Correction Algorithms
Choose the appropriate algorithm based on your needs:
algorithms = {
"DIAGONAL_ONLY": "Simple white balance correction (fastest)",
"MATRIX_3X3_AUTO": "Full 3x3 matrix with stability fallback (recommended)",
"MATRIX_3X3_FORCE": "Force 3x3 matrix even if potentially unstable"
}
for algorithm, description in algorithms.items():
filename = radiation.autoCalibrateCameraImage(
camera_label="test_camera",
red_band_label="R", green_band_label="G", blue_band_label="B",
output_file_path=f"calibrated_{algorithm.lower()}.jpg",
algorithm=algorithm
)
print(f"{algorithm}: {description} -> {filename}")
Complete Camera Pipeline Example
from pyhelios import Context, WeberPennTree, WPTType, RadiationModel
from pyhelios import RadiationModelError
context = Context()
wpt = WeberPennTree(context)
tree_id = wpt.buildTree(WPTType.APPLE)
leaf_uuids = wpt.getLeafUUIDs(tree_id)
branch_uuids = wpt.getBranchUUIDs(tree_id)
for uuid in leaf_uuids:
context.setPrimitiveDataString(uuid, "plant_part", "leaf")
context.setPrimitiveDataString(uuid, "species", "apple")
for uuid in branch_uuids:
context.setPrimitiveDataString(uuid, "plant_part", "branch")
context.setPrimitiveDataString(uuid, "species", "apple")
ground = context.addPatch(center=vec3(0,0,0), size=vec2(10,10))
context.setPrimitiveDataString(ground, "plant_part", "soil")
try:
with RadiationModel(context) as radiation:
radiation.addRadiationBand("Red")
radiation.addRadiationBand("Green")
radiation.addRadiationBand("Blue")
radiation.addRadiationBand("NIR")
sun_id = radiation.addSunSphereRadiationSource(
radius=0.5, zenith=45.0, azimuth=180.0)
radiation.setSourceFlux(sun_id, "Red", 250.0)
radiation.setSourceFlux(sun_id, "Green", 350.0)
radiation.setSourceFlux(sun_id, "Blue", 200.0)
radiation.setSourceFlux(sun_id, "NIR", 400.0)
radiation.addRadiationCamera(
"overhead_rgb", ["Red", "Green", "Blue"],
position=vec3(0, 0, 20),
lookat_or_direction=vec3(0, 0, 0))
radiation.addRadiationCamera(
"side_view", ["NIR"],
position=vec3(15, 0, 5),
lookat_or_direction=vec3(0, 0, 5))
radiation.updateGeometry()
radiation.runBand(["Red", "Green", "Blue", "NIR"])
rgb_filename = radiation.writeCameraImage(
camera="overhead_rgb",
bands=["Red", "Green", "Blue"],
imagefile_base="apple_tree_rgb"
)
nir_filename = radiation.writeCameraImage(
camera="side_view",
bands=["NIR"],
imagefile_base="apple_tree_nir"
)
radiation.writeImageBoundingBoxes(
camera_label="overhead_rgb",
primitive_data_labels=["leaf", "branch", "soil"],
object_class_ids=[0, 1, 2],
image_file=rgb_filename,
classes_txt_file="plant_classes.txt"
)
radiation.writeImageSegmentationMasks(
camera_label="overhead_rgb",
primitive_data_labels=["leaf", "branch", "soil"],
object_class_ids=[0, 1, 2],
json_filename="apple_tree_segmentation.json",
image_file=rgb_filename
)
calibrated_filename = radiation.autoCalibrateCameraImage(
camera_label="overhead_rgb",
red_band_label="Red",
green_band_label="Green",
blue_band_label="Blue",
output_file_path="apple_tree_calibrated.jpg",
print_quality_report=True
)
print("Camera pipeline completed:")
print(f" RGB Image: {rgb_filename}")
print(f" NIR Image: {nir_filename}")
print(f" Calibrated: {calibrated_filename}")
print(f" Training data: plant_classes.txt + YOLO format labels")
print(f" Segmentation: apple_tree_segmentation.json")
except RadiationModelError as e:
print(f"Camera processing failed: {e}")
Camera Function Error Handling
try:
filename = radiation.writeCameraImage(
camera="test_camera",
bands=["R", "G", "B"],
imagefile_base="test"
)
except TypeError as e:
print(f"Parameter error: {e}")
except ValueError as e:
print(f"Value error: {e}")
except RuntimeError as e:
print(f"Camera operation failed: {e}")
Error Handling
from pyhelios import RadiationModel
if not RadiationModel.probeAnyGPUBackend():
print("No compatible GPU backend found")
print("Supported backends: OptiX 8 (NVIDIA driver >= 560), OptiX 6 (NVIDIA driver < 560), Vulkan")
try:
radiation = RadiationModel(context)
print(f"Using backend: {radiation.getBackendName()}")
except Exception as e:
print(f"RadiationModel initialization failed: {e}")
if "Vulkan" in str(e):
print("Vulkan backend error - check GPU drivers")
print(" macOS: brew install vulkan-loader molten-vk")
print(" Linux: sudo apt-get install libvulkan-dev")
print(" Windows: No additional packages needed")
elif "OptiX" in str(e):
print("OptiX backend error - check CUDA/OptiX installation and NVIDIA drivers")
elif "GPU" in str(e):
print(f"GPU initialization failed: {e}")
Build Requirements
# Build with radiation plugin
build_scripts/build_helios --plugins radiation
# Or use GPU profile
build_scripts/build_helios --plugins radiation
# Check if radiation is available
python -c "from pyhelios.plugins import get_plugin_registry; print(get_plugin_registry().is_plugin_available('radiation'))"
This documentation covers the actual RadiationModel implementation in PyHelios, verified against the wrapper code and high-level interface.