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.
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. 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")
Wavelength bounds are in nanometers. The lower bound must not be negative (a band may start at 0 nm) and the upper bound must exceed it by at least 1 nm. Bounds change how a band is treated:
- Surface
reflectivity_spectrum and transmissivity_spectrum primitive data set a primitive's band reflectivity and transmissivity only in a band that has bounds (see Spectral Surface Properties).
- An emission-enabled band with bounds emits only the in-band part of the Planck spectrum (see Emission Control).
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)
The defaults are 100 direct rays and 1000 diffuse rays per primitive. The diffuse ray count is also used for the scattering iterations.
Note: Bands run together in one runBand() call share a single ray launch, which uses the largest direct and diffuse ray counts set for any of those bands. If "PAR" is set to 100 direct rays and "NIR" to 1000, runBand(["PAR", "NIR"]) traces both with 1000. To give each band its own ray count, run the bands in separate runBand() calls.
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)
The energy still waiting to be scattered after the last iteration is not dropped: the remaining iterations are summed as a geometric series. A scattering depth of 3 to 5 is therefore usually enough for absorbed fluxes, even in strongly scattering bands such as near-infrared. Radiation camera images are not extrapolated this way, so images of strongly scattering bands need a higher depth.
Later scattering iterations launch fewer diffuse rays per primitive, in proportion to the energy left to scatter (minimum 16). This makes strongly scattering bands faster, at the cost of somewhat noisier scattered flux on deeply shaded primitives and in camera images. Call radiation.disableScatteringRayReduction() to launch the full diffuse ray count in every iteration, and enableScatteringRayReduction() to restore the default.
Note: Before helios-core 1.3.92 the energy left after the last iteration was absorbed where it sat, which overstated near-infrared leaf absorption by about 20% at a scattering depth of 3, and setMinScatterEnergy() had no effect.
Diffuse Sky Hemisphere
The diffuse (ambient) sky set by setDiffuseRadiationFlux() fills only the upper hemisphere. Rays that leave all geometry heading below the horizon – past the edge of a finite ground, or from the underside of an isolated object – receive no diffuse radiation, and camera pixels looking below the horizon show no sky.
radiation.enforcePeriodicBoundary("xy")
radiation.enableBelowHorizonDiffuseRadiation()
radiation.disableBelowHorizonDiffuseRadiation()
Note: Before helios-core 1.3.92 the sky filled the full sphere, so an isolated two-sided horizontal patch absorbed twice the diffuse flux. Diffuse fluxes near the edges of finite domains and on isolated objects are lower than in earlier versions.
Invisible Primitives
Setting the uint primitive data invisibility_flag to 1 makes a primitive invisible to the radiation model: it is not intersected by any ray, does not appear in camera images, and neither absorbs nor emits radiation. It takes precedence over twosided_flag, including one set through a material. Call updateGeometry() after setting it on existing primitives.
context.setPrimitiveDataUInt(uuid, "invisibility_flag", 1)
radiation.updateGeometry()
Reproducibility
The ray tracer draws its random numbers from the Context's random generator, so seeding the Context with context.seedRandomGenerator(seed) makes runBand() fluxes and camera images reproducible on a given backend (helios-core 1.3.92+). Results are not identical across backends.
Emission Control
radiation.enableEmission("thermal")
radiation.disableEmission("PAR")
Each primitive emits with emissivity emissivity_<band> (default 1) at its temperature primitive data (default 300 K). How much it emits depends on whether the band has wavelength bounds:
- A band added without bounds, such as
addRadiationBand("LW"), is treated as broadband longwave and emits the full εσT⁴. This is the usual setup for the longwave band of an energy balance.
- A band added with bounds, such as
addRadiationBand("thermal", 8000, 14000), emits only the part of the Planck spectrum inside them, εσT⁴ times the blackbody band fraction. A thermal camera band with bounds therefore records in-band radiance: at 300 K the 8-14 µm band holds about 38% of σT⁴.
The band fraction and the Planck spectral radiance are available as Global.blackbodyBandFraction(wavelength_min_nm, wavelength_max_nm, temperature_K) and Global.blackbodySpectralRadiance(wavelength_nm, temperature_K) (from pyhelios import Global), for example to convert an in-band flux back to a temperature or to check the emission of a surface.
Any diffuse (sky) flux given to a bounded emission band with setDiffuseRadiationFlux() should also be the in-band value rather than the broadband longwave flux; SolarPosition.getThermalSkyFlux() computes it for bands within 5.5-15.3 µm.
Note: Before helios-core 1.3.90 every emission band emitted the full broadband σT⁴ regardless of its bounds. Results for bounded emission bands are lower than in earlier versions (by about 2.7x for 8-14 µm at 300 K), and a visible band with bounds and emission left enabled no longer emits several hundred W/m².
runBand() records, for each band, its emission state as global data named emission_enabled_<band> (uint, 1 when emission is enabled for that band and 0 otherwise), its scattering depth as scattering_depth_<band> (uint), and, for a band with wavelength bounds, the bounds as wavelength_bounds_<band> (vec2, nm). The energy balance model uses these so that the thermal radiation it emits matches the emission of its bands here. They record each band as configured at the most recent runBand() call, so a band whose emission, scattering depth, or bounds are changed afterwards should be run again before the energy balance. The emitting bands given to the energy balance model must account for the blackbody spectrum once — either a single band without bounds, or adjacent bounded bands that together span the thermal spectrum — so a bounded thermal camera band should not be added to the energy balance model alongside the longwave band. You can read the values back through the Context:
radiation.runBand(["PAR", "thermal"])
context.getGlobalData("emission_enabled_thermal")
context.getGlobalData("emission_enabled_PAR")
The values exist only for bands that have actually been run, since runBand() is what writes them.
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. As of v1.3.92 it also rebuilds when primitives have been moved, rotated, scaled, reshaped or re-textured. Setting primitive data does not trigger a rebuild, so call updateGeometry() yourself after changing twosided_flag or invisibility_flag on existing primitives.
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.
updateGeometry() raises for a scene containing voxels, which no ray-tracing backend supports, and for a triangle whose texture has a transparency channel but which has no texture (u,v) coordinates, such as one textured with setPrimitiveTextureFile().
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")
Bands run together are traced with the largest direct and diffuse ray counts set for any of them (see Ray Count Settings), so run bands separately when they need different ray counts.
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
)
source_camera_weighted = radiation.integrateSpectrum(
leaf_reflectance, source_id=sun_id, camera_spectrum=camera_response
)
sun_par_flux = radiation.integrateSourceSpectrum(sun_id, 400, 700)
print(f"Sun PAR flux: {sun_par_flux} W/m²")
When integrating over a wavelength range, the spectrum is linearly interpolated to the bounds, so a bound that falls between tabulated points contributes only the part of its segment inside the range (helios-core 1.3.89+; earlier versions integrated the whole segment containing each bound).
The source- and camera-weighted forms return weighted averages rather than plain integrals, and treat every spectrum as zero outside the wavelengths at which it is tabulated. A source tabulated from 500 to 600 nm therefore weights only that part of a 400-700 nm range (helios-core 1.3.90+; earlier versions held the source's edge values across the rest of the range). The source must have a spectrum, and the call raises if there is nothing to weight by: a source spectrum with no energy between the bounds or where the camera response is tabulated, or a camera response that is zero everywhere. Earlier versions returned NaN.
Spectral Surface Properties
Setting the primitive data reflectivity_spectrum or transmissivity_spectrum to the label of a spectrum in Context global data makes the model compute that primitive's reflectivity or transmissivity in each band that has wavelength bounds, by averaging the spectrum over the band weighted by the incident radiation's spectrum.
radiation.addRadiationBand("PAR", 400, 700)
context.setPrimitiveDataString(leaf_uuids, "reflectivity_spectrum", "leaf_reflectivity")
context.setPrimitiveDataString(leaf_uuids, "transmissivity_spectrum", "leaf_transmissivity")
- A band value overrides the spectrum. If
reflectivity_<band> or transmissivity_<band> is also set on the primitive, that value is used in that band instead of the spectrum, including a value of 0. Before helios-core 1.3.90 a value of 0 was ignored and the spectrum used, so a scene that carries a band value of 0 alongside a spectrum (for example, one loaded from XML) now uses 0.
- Spectra are zero outside their tabulated range. This holds for source spectra and for reflectivity and transmissivity spectra alike. A reflectivity or transmissivity spectrum that does not overlap a band at all has no defined value in it, and
runBand() raises when that band is run; set a constant reflectivity_<band> or transmissivity_<band> for that band instead.
- A source needs energy where it has flux. If a source has a flux in a band (for example from
setSourceFlux()) but its spectrum has no energy in that band, or a diffuse flux has a diffuse spectrum with no energy in the band, runBand() raises when the band is run with surfaces that have spectral properties. Earlier versions silently used a reflectivity and transmissivity of 0, so surfaces absorbed all of that radiation.
- Several sources. Radiation coming directly from a source uses properties weighted by that source's spectrum. Diffuse, scattered and emitted radiation use properties weighted by the band's combined incident spectrum: each source's spectrum and the diffuse spectrum, in proportion to their band fluxes. Before helios-core 1.3.90 they were weighted by the spectrum of source 0 alone, so results with several differently coloured sources in one band depended on the order the sources were added.
The model supports at most 255 radiation sources.
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. The Fluspect-B kernel acts on the excitation flux incident on each leaf (leaf absorption is already contained in the kernel). At the default excitation_scattering_depth=0, only excitation arriving directly from the sources is counted, so leaves inside a canopy miss the excitation scattered to them by other leaves (most in the far-red end of the excitation range, where leaf reflectance and transmittance are both large). Set excitation_scattering_depth >= 1 to include inter-leaf scattering, at the cost of additional excitation-band ray traces.
Each leaf face emits the Fluspect-B backward spectrum of the excitation it receives plus the forward spectrum of the excitation received by the opposite face, so for a leaf lit from above the top face emits the backward spectrum. The two differ most in the red band near 685 nm, where backward emission is about twice as strong for a typical leaf because red fluorescence is reabsorbed crossing the leaf.
The fluorescence quantum yield Φ_F follows van der Tol et al. (2014), Eq. 19, with its unstressed parameter set (the SCOPE default). It is driven by the per-leaf electron_transport_ratio primitive data — the relative light saturation Ja/Je, which is 1 in the dark and falls as light saturates photosynthesis — and by leaf temperature (the radiation model's default of 300 K when absent; a non-positive temperature is an error). electron_transport_ratio is written by the Farquhar and C4 photosynthesis models when requested as an optional output (see Photosynthesis optional outputs); the empirical model cannot drive SIF. Primitives without fluspect_spectrum do not fluoresce, but a primitive that has fluspect_spectrum and no electron_transport_ratio when a SIF band is run makes runBand() raise an error. Run the PAR band before photosynthesis, or every leaf sees zero light and gets the dark-adapted yield (Ja/Je = 1) regardless of its actual illumination.
Note: helios-core 1.3.89 corrected the SIF emission scaling and fluorescence yield, so absolute SIF values are much lower than those produced by earlier versions.
Per-leaf excitation-flux 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 not 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.
Optional Outputs
radiation.optionalOutputPrimitiveData("reflectivity")
radiation.optionalOutputPrimitiveData("transmissivity")
- Note
getSkyEnergy() is deprecated: no ray-tracing backend computes the energy lost to the sky, so it always returned 0. As of helios-core 1.3.92 it emits a DeprecationWarning and raises, and it will be removed in helios-core v2.0.
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.
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.
Camera and Image Functions
Note: Camera functions are available in Helios core v1.3.47+ and PyHelios v0.0.4+
The RadiationModel includes 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")
Simulating Satellite Imagery
A camera normally records the radiance leaving the scene. A sensor above the atmosphere, such as a satellite, instead records that radiance attenuated by the atmosphere, plus light scattered into its view by the atmosphere itself (path radiance) and by the surrounding ground (the adjacency effect). enableCameraAtmosphere() converts a camera's images to the radiance at such a sensor, using spectra computed by SolarPosition.calculateSensorAtmosphereSpectra() (see the Solar Position documentation for the model and its accuracy). After each camera ray trace, every pixel in a band becomes L_path + T↑ · L_pixel + L_adj, with the path and adjacency radiances integrated over the band with the camera's spectral response and the upward direct transmittance T↑ averaged over the band.
Illuminate the scene with the direct and diffuse irradiance spectra computed together with the atmosphere spectra, so that the light reaching the scene and the light leaving it pass through the same atmosphere:
from pyhelios import Context, RadiationModel, SolarPosition, CameraProperties
camera_position = vec3(0, 0, 50)
camera_lookat = vec3(0, 0, 0)
ground = context.addPatch(center=vec3(0, 0, 0), size=vec2(20, 20))
context.setPrimitiveDataFloat(ground, "reflectivity_red", 0.1)
with SolarPosition(context, utc_offset=8, latitude=38.5, longitude=121.7) as solar:
solar.setAtmosphericConditions(101325, 298, 0.5, 0.1)
solar.setGroundAlbedo(0.15)
solar.calculateSensorAtmosphereSpectra("satellite_atmosphere", vec3(0, 0, 1))
sun_direction = solar.getSunDirectionVector()
with RadiationModel(context) as radiation:
radiation.addRadiationBand("red", 620, 670)
radiation.disableEmission("red")
radiation.setScatteringDepth("red", 1)
sun = radiation.addCollimatedRadiationSource(sun_direction)
radiation.setSourceSpectrum(sun, "satellite_atmosphere_direct_irradiance")
radiation.setDiffuseSpectrum("red", "satellite_atmosphere_diffuse_irradiance")
properties = CameraProperties(
HFOV=5.0,
exposure="manual",
white_balance="off",
)
radiation.addRadiationCamera("satellite", ["red"], camera_position, camera_lookat, properties, 1)
radiation.enableCameraAtmosphere("satellite", "satellite_atmosphere")
radiation.runBand("red")
radiance = radiation.getCameraPixelData("satellite", "red")
The camera does not need to be at orbital altitude: the atmosphere is applied analytically, so only the viewing direction matters. A camera a short distance above the scene with a narrow field of view avoids numerical precision problems, and its image can be averaged to the footprint of the satellite pixels being simulated.
The atmosphere spectra are read when runBand() is called, so these requirements are checked there, and violating any of them raises an error:
- The atmosphere spectra must have been computed for the direction from the camera's look-at point toward its position, to within 1 degree.
- Every pixel must see the scene. Sky pixels have no surface for the atmosphere to act on.
- A reflective band must have wavelength bounds or a camera spectral response, and must lie within the wavelength range of the atmosphere spectra (300-2600 nm at the default 1 nm resolution).
- A thermal band must have wavelength bounds within 5502-15326 nm, its spectral response must not be zero over the band, and each pixel's brightness temperature must lie within 150-400 K.
Emission (thermal) bands are converted with the thermal atmosphere computed by SolarPosition.calculateSensorThermalAtmosphere() under the same label. Because a bounded emission band emits only the in-band part of the Planck spectrum, the pixels are in-band radiances. Each pixel's radiance is converted to a brightness temperature; the Planck spectrum at that temperature is multiplied by the atmospheric transmittance wavelength by wavelength and integrated over the band with the camera's spectral response, and the upwelling atmospheric radiance is added. Any band within 5.5-15.3 µm can be used (for example GOES ABI bands 8-16, MODIS bands 27-36, Landsat 8/9 TIRS bands 10 and 11, and 7.5-13.5 µm microbolometer cameras), but not the 3.5-5 µm bands, where reflected sunlight is significant.
solar.calculateSensorThermalAtmosphere("satellite_atmosphere", vec3(0, 0, 1))
radiation.addRadiationBand("TIR", 10600, 11190)
radiation.setDiffuseRadiationFlux("TIR", solar.getThermalSkyFlux(10600, 11190))
context.setPrimitiveDataFloat(ground_uuids, "emissivity_TIR", 0.96)
The emissivity of many surfaces varies with wavelength in the thermal infrared, so each thermal band needs its own emissivity_<band>. The sky radiance the scene reflects is whatever diffuse flux the band is given, which must be the in-band value.
Any exposure other than "manual" and any white balance other than "off" rescales the images after the atmosphere is applied, so use exposure="manual" and white_balance="off" when radiance values are needed.
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)
Camera Image Corrections
Two methods post-process a rendered RGB camera image before it is written. Both change the camera's stored pixel data in place, so applying one twice applies it twice, and neither clamps values.
applyCameraColorCorrectionMatrix(camera_label, red, green, blue, ccm_file_path) multiplies each pixel's (r, g, b) by a 3x3 matrix, or by a 3x3 matrix plus an offset column (matrix_type="4x3"), read from an XML file such as the one autoCalibrateCameraImage(..., ccm_export_file_path=...) exports. A missing file raises FileNotFoundError.
applyCameraImageCorrections(camera_label, red, green, blue, saturation_adjustment=1.0, brightness_adjustment=1.0, contrast_adjustment=1.0) applies lens flare (if enabled for the camera), then brightness and contrast in linear space (0.5 + (v * brightness - 0.5) * contrast), then saturation about each pixel's luminance (0 gives grayscale). A factor of 1.0 skips its step. The factors are recorded in camera metadata when enableCameraMetadata() is on.
Auto exposure and white balance are not part of these calls: they are applied during runBand() according to the camera's exposure and white_balance CameraProperties. sRGB gamma compression is applied by writeCameraImage() when it writes a 3-band image. The full order is therefore:
radiation.runBand(["red", "green", "blue"])
radiation.applyCameraColorCorrectionMatrix("cam", "red", "green", "blue", "cam_ccm.xml")
radiation.applyCameraImageCorrections("cam", "red", "green", "blue",
saturation_adjustment=1.5,
brightness_adjustment=1.2,
contrast_adjustment=1.1)
radiation.writeCameraImage("cam", ["red", "green", "blue"], "corrected")
A CCM file has one <row> per line:
<helios>
<ColorCorrectionMatrix camera_label="cam" matrix_type="3x3">
<row>1.10 -0.05 0.02</row>
<row>-0.03 1.08 0.01</row>
<row>0.01 -0.04 1.12</row>
</ColorCorrectionMatrix>
</helios>
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'))"