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")
Running in Docker? See Running PyHelios in Docker with GPU Support. Containers need NVIDIA_DRIVER_CAPABILITIES to include graphics before the OptiX backend is available; without it the model silently falls back to Vulkan.
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.runBand("PAR")
uuids = context.getAllUUIDs()
results = radiation.getAbsorbedFlux("PAR")
print(f"Absorbed flux density: {results[0]:.2f} W/m^2")
power = float((results * context.getPrimitiveArea(uuids)).sum(dtype="float64"))
print(f"Total absorbed power: {power:.2f} W")
Units: getAbsorbedFlux() returns absorbed flux density in W/m² for each primitive (the radiation_flux_<band> primitive data) — 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.
⚠️ Use getAbsorbedFlux(band), not getTotalAbsorbedFlux(), for per-primitive results. getTotalAbsorbedFlux() has two traps:
- Its ordering is not
context.getAllUUIDs(). The values are ordered by the radiation model's internal primitive ordering, built during updateGeometry(). getAllUUIDs() iterates an unordered map and returns a hash order — with two patches it returns [1, 0]. Pairing the two by index silently assigns each primitive's flux to a different primitive.
- It sums over every band. With PAR, NIR and SW registered, the result is PAR + NIR + SW per primitive, which double-counts because SW already spans the other two.
getAbsorbedFlux(band) is keyed by UUID and returns one band, so neither applies.
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
As of helios-core v1.3.79 the no-argument form is optional: runBand() builds the geometry itself before tracing, and rebuilds it whenever primitives have been added to or deleted from the Context since the last build.
radiation.updateGeometry()
radiation.updateGeometry([patch_uuid, triangle_uuid])
The two forms are not interchangeable. A subset passed to updateGeometry(uuids) is never rebuilt automatically, because an automatic rebuild would discard the subset you asked for. After modifying Context geometry you must call updateGeometry(uuids) again yourself:
radiation.updateGeometry([patch_uuid])
context.addPatch(center=vec3(1, 0, 0))
radiation.runBand("PAR")
radiation.updateGeometry([patch_uuid, new_uuid])
Calling updateGeometry() with no argument clears the subset and returns the model to tracking the full Context.
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
getAbsorbedFlux(band) returns one band's absorbed flux density in W/m², with element i belonging to uuids[i].
all_uuids = context.getAllUUIDs()
results = radiation.getAbsorbedFlux("PAR")
import numpy as np
areas = context.getPrimitiveArea(all_uuids)
power = np.asarray(results, dtype=np.float64) * areas
print(f"Total absorbed power: {power.sum(dtype=np.float64):.2f} W")
for uuid, flux_density, p in zip(all_uuids, results, power):
print(f"Primitive {uuid}: {flux_density:.2f} W/m² ({p:.2f} W)")
bands = radiation.getAbsorbedFlux(["PAR", "NIR", "SW"])
print(f"PAR on first primitive: {bands['PAR'][0]:.2f} W/m²")
leaf_flux = radiation.getAbsorbedFlux("PAR", uuids=leaf_uuids)
Radiation Analysis
radiation_data = radiation.getAbsorbedFlux("PAR")
mean_flux = radiation_data.mean(dtype="float64")
max_flux = radiation_data.max()
min_flux = radiation_data.min()
std_flux = radiation_data.std(dtype="float64")
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.getAbsorbedFlux("PAR")
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²")
getDiffuseFlux() is an input, not a result. It returns the band's configured diffuse source flux — a single scalar, the value you set with setDiffuseRadiationFlux() (or derived from the diffuse spectrum) — not per-primitive absorbed diffuse radiation. There is no separate accessor for the absorbed diffuse component: getAbsorbedFlux(band) already reports each primitive's total absorbed flux for that band, direct plus diffuse plus scattered.
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.
-
The one case it will not auto-build is a model holding a primitive subset from
updateGeometry(uuids) that has not been traced yet: building the full Context there would silently discard the subset, so it raises instead and tells you to call updateGeometry(uuids) again (or updateGeometry() if you want the whole Context). After a runBand() the geometry is already built, so the G-function is simply reported over the subset you selected.
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.runBand(["red", "green", "blue"])
radiation.writeCameraImage("cam", ["red", "green", "blue"], "out")
(runBand() pushes the geometry itself, so no updateGeometry() step is needed.)
Getting this wrong raises a RadiationModelError naming the camera and band that were never rendered. PyHelios checks the precondition itself before calling into the native library and reports which runBand() call is missing. This check is load-bearing: helios-core v1.3.79 signals the same failure by returning an empty filename rather than raising, so without the PyHelios check a missed runBand() would look like a successful write that produced no file.
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()
ages = context.getPrimitiveDataArray(leaf_patches, "age")
context.setPrimitiveDataFloat(leaf_patches, "age", ages + 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.getAbsorbedFlux("PAR")
daily_radiation[day] = float((flux * context.getPrimitiveArea(leaf_patches)).sum(dtype="float64"))
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"])
all_uuids = context.getAllUUIDs()
results = radiation.getAbsorbedFlux("PAR")
import numpy as np
areas = context.getPrimitiveArea(all_uuids)
power = np.asarray(results, dtype=np.float64) * areas
leaf_uuids = set(wpt.getLeafUUIDs(tree_id))
is_leaf = np.array([u in leaf_uuids for u in all_uuids])
leaf_absorption = float(power[is_leaf].sum(dtype=np.float64))
total_absorption = float(power.sum(dtype=np.float64))
ground_absorption = float(power[all_uuids.index(ground_uuid)])
print(f"Total scene PAR absorption: {total_absorption:.2f} W")
print(f"Leaf PAR absorption: {leaf_absorption:.2f} W")
print(f"Ground PAR absorption: {ground_absorption:.2f} W")
per_band = radiation.getAbsorbedFlux(["PAR", "NIR", "SW"])
for band, flux in per_band.items():
mean_flux = flux.mean(dtype="float64")
print(f"Band {band}: mean absorbed flux density {mean_flux:.2f} W/m²")
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
runBand() already stores each band's absorbed flux density as primitive data under radiation_flux_<band>, so there is nothing to store for that quantity — just read it back with getAbsorbedFlux(band). You only need to write primitive data for quantities you derive yourself:
import numpy as np
all_uuids = context.getAllUUIDs()
results = radiation.getAbsorbedFlux("PAR")
areas = context.getPrimitiveArea(all_uuids)
power = np.asarray(results, dtype=np.float64) * areas
context.setPrimitiveDataFloat(all_uuids, "absorbed_power_PAR", power.tolist())
context.colorPrimitiveByDataPseudocolor(
all_uuids, "radiation_flux_PAR", "hot", 256
)
Reading primitive data in bulk: either context.getPrimitiveDataArray(uuids,
label) (returns a NumPy array) or a list comprehension over context.getPrimitiveDataFloat() works, and both are linear in the primitive count. Note that on PyHelios ≤ 0.1.26 getPrimitiveDataArray() was O(N²) — roughly 15 s on a 20,000-primitive scene — because it validated each UUID by rescanning the whole UUID list; if you are pinned to an older release, prefer the loop.
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")
Updating Camera Parameters
updateCameraParameters() changes an existing camera's intrinsics in place, preserving its position, lookat direction, and spectral band configuration.
Changing camera_resolution discards the camera's rendered image data. The per-pixel buffers are sized to the resolution the camera was rendered at, so helios-core v1.3.79+ clears them rather than reinterpreting them at the new size. Re-render with runBand() before writing an image, or writeCameraImage() will report the camera as unrendered. Changing any other parameter leaves image data intact.
radiation.runBand(["red", "green", "blue"])
radiation.writeCameraImage("cam", ["red", "green", "blue"], "before")
radiation.updateCameraParameters("cam", CameraProperties(camera_resolution=(512, 512), HFOV=45))
radiation.updateCameraParameters("cam", CameraProperties(camera_resolution=(1024, 1024), HFOV=45))
radiation.runBand(["red", "green", "blue"])
radiation.writeCameraImage("cam", ["red", "green", "blue"], "after")
Camera Flux Smoothing
By default each pixel reports the outgoing flux of the primitive behind it, so a coarsely tessellated curved surface renders as a set of flat panels. enableCameraFluxSmoothing() averages each primitive's outgoing flux onto the mesh vertices it shares with its neighbours and interpolates it back across the facet, so the tessellated curve reads as a curve.
This applies to Tube, Sphere, Cone, Polymesh, Tile and AdaptiveTile objects. A Box or Disk object, and any primitive belonging to no object, is left alone: its faces are genuinely flat, so the per-facet value is already correct everywhere on them. A polymesh must carry the face table that loadOBJ() and loadPLY() retain; one assembled from loose primitives by addPolymeshObject() has no topology and is left alone.
Smoothing changes what the camera reports. A pixel no longer carries the outgoing flux of the primitive behind it, but a blend of that primitive's value with those of its neighbours, so pixel values no longer correspond exactly to the radiation_flux_* primitive data. Nothing else is affected: the radiation solve, the radiation_flux_* values themselves, and the pixel-label and depth images are all unchanged. Smoothing is off by default for this reason and should be enabled deliberately.
The crease angle is the angle between adjacent facet normals above which a shared edge is treated as a hard crease, so flux is not averaged across it. It applies to polymesh objects only, whose facets may meet at a genuine edge the tessellation resolved; the curved object types are smooth by construction and are never creased. It must be between 0 and 180 degrees.
with RadiationModel(context) as radiation:
radiation.enableCameraFluxSmoothing(crease_angle_degrees=45.0)
print(radiation.isCameraFluxSmoothingEnabled())
print(radiation.getCameraFluxSmoothingCreaseAngle())
radiation.updateGeometry()
radiation.runBand(["red", "green", "blue"])
radiation.writeCameraImage("cam", ["red", "green", "blue"], "smoothed")
radiation.disableCameraFluxSmoothing()
Toggling smoothing after geometry has been built re-uploads the geometry and rebuilds the acceleration structure, which is not cheap — prefer calling it before updateGeometry().
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)
CameraProperties.exposure_target (helios-core 1.3.85) is the target median scene luminance that "auto" exposure scales every band toward, as a fraction of full scale. The default of 0.18 is the middle-grey convention, which assumes a scene whose average reflectance is that of a grey card. A canopy imaged from above is darker than that, so matching a real camera's output may need a lower value; conversely a bright scene may need a higher one. It is ignored for "manual" and "ISOXXX" exposure, and a non-positive value is rejected.
props = CameraProperties(exposure="auto", exposure_target=0.10)
radiation.addRadiationCamera("nadir_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.